UNPKG

@anthropic-ai/sdk

Version:
334 lines 15.9 kB
"use strict"; /** * Node-only skill plumbing for the agent toolset: downloading a session * agent's skills into the workdir and extracting the archives. Kept in its own * file because it is a distinct concern from the tool implementations in * `node.ts` — distinct enough, and large enough, to review on its own. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.setupSkills = setupSkills; exports.resolveSkillVersion = resolveSkillVersion; exports.classifyArchiveListing = classifyArchiveListing; exports.assertOnlyPlainEntries = assertOnlyPlainEntries; exports.extractSkillArchive = extractSkillArchive; const tslib_1 = require("../../internal/tslib.js"); const fs = tslib_1.__importStar(require("node:fs/promises")); const fssync = tslib_1.__importStar(require("node:fs")); const path = tslib_1.__importStar(require("node:path")); const node_child_process_1 = require("node:child_process"); const node_util_1 = require("node:util"); const node_stream_1 = require("node:stream"); const promises_1 = require("node:stream/promises"); const error_1 = require("../../core/error.js"); const log_1 = require("../../internal/utils/log.js"); const fs_util_1 = require("./fs-util.js"); const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile); /** * Download the session agent's skills into `{ctx.workdir}/skills/<name>/`. * * No-op (returns a no-op cleanup) unless both `ctx.client` and `ctx.sessionId` * are set. Looks up the session's resolved agent and, for each skill, fetches * its files via `client.beta.skills.versions.download` and extracts the archive * (a zip or tar.* archive) into a directory named after the skill. A failure on * one skill is logged and does not block the others. Call this before starting * the session tool runner (e.g. right after the bash session / workdir is * ready). * * Returns a cleanup function that removes the skill directories this call * created — call it once the work item is done so downloaded skills do not * accumulate in the workdir across sessions. */ async function setupSkills(ctx) { const { client, sessionId } = ctx; if (!client || !sessionId) return async () => { }; const log = (0, log_1.loggerFor)(client); const session = await client.beta.sessions.retrieve(sessionId); const skillsRoot = path.resolve(ctx.workdir, 'skills'); const created = []; for (const skill of session.agent.skills) { try { const versionId = await resolveSkillVersion(client, skill.skill_id, skill.version); const version = await client.beta.skills.versions.retrieve(versionId, { skill_id: skill.skill_id }); // The directory is the skill's name, reduced to a single safe path // component so a hostile name can't escape `skillsRoot`. let dirname = path.basename(version.name.trim()); if (dirname === '' || dirname === '.' || dirname === '..') dirname = skill.skill_id; const dest = path.resolve(skillsRoot, dirname); if (dest !== skillsRoot && !dest.startsWith(skillsRoot + path.sep)) { log.warn('skill name escapes the skills dir; skipping', { component: 'agent-tool-context', name: version.name, }); continue; } const resp = await client.beta.skills.versions.download(versionId, { skill_id: skill.skill_id }); await fs.rm(dest, { recursive: true, force: true }); await fs.mkdir(dest, { recursive: true, mode: fs_util_1.DIR_CREATE_MODE }); created.push(dest); await extractSkillArchive(resp, dest); log.info('downloaded skill', { component: 'agent-tool-context', skill_id: skill.skill_id, version: versionId, dest, }); } catch (e) { log.warn('failed to download skill', { component: 'agent-tool-context', skill_id: skill.skill_id, error: String(e), }); } } return async () => { for (const dest of created) { await fs.rm(dest, { recursive: true, force: true }).catch((e) => { log.warn('failed to clean up skill', { component: 'agent-tool-context', dest, error: String(e) }); }); } }; } /** * Resolve `version` to the concrete numeric timestamp the * `/v1/skills/{id}/versions/{version}` endpoints require — `session.agent.skills[].version` * can be an alias such as `"latest"`, which those endpoints reject. Numeric * versions pass through unchanged. */ async function resolveSkillVersion(client, skillId, version) { if (/^\d+$/.test(version)) return version; let newest; for await (const v of client.beta.skills.versions.list(skillId)) { if (/^\d+$/.test(v.version) && (newest === undefined || BigInt(v.version) > BigInt(newest))) { newest = v.version; } } if (newest === undefined) { throw new error_1.AnthropicError(`skill ${JSON.stringify(skillId)} has no concrete version to resolve ${JSON.stringify(version)} against`); } return newest; } /** Reject archive members that are absolute or contain a `..` component. */ function assertSafeMemberNames(names) { for (const raw of names) { const entry = raw.trim(); if (!entry) continue; if (path.isAbsolute(entry) || entry.split(/[\\/]/).includes('..')) { throw new error_1.AnthropicError(`refusing to extract unsafe archive member: ${entry}`); } } } const INCONSISTENT_LISTING = 'skill archive listing is inconsistent; refusing to extract'; /** * Type chars (first byte of each `ls`-style line from `unzip -Z` / `tar -tvf`) * that denote a regular file or directory. `zipinfo` prints `?` for entries * with no Unix type bits, which `unzip` extracts as regular files; GNU tar * prints `C` for contiguous files. Everything else — `l` symlink, `h` * hardlink, `b`/`c` device, `p` fifo, `s` socket, unknown tar types — is a * special member. */ const PLAIN_TYPE_CHARS = { unzip: new Set(['-', 'd', '?']), tar: new Set(['-', 'd', 'C']) }; function listingLines(listing) { const lines = listing.split('\n'); if (lines[lines.length - 1] === '') lines.pop(); return lines; } /** * A special member is excluded by handing its listed name back to the CLI as * a pattern, so the name must be byte-identical to what is stored. `tar`, * `bsdtar` and `unzip` print bytes they cannot show literally as `\ooo`, `^X` * or `#U` escapes, or as raw non-ASCII; any such name cannot be excluded * reliably. A leading `-` would let `unzip` parse the pattern as an option. */ function canExcludeVerbatim(cmd, name) { return /^[\x20-\x7E]+$/.test(name) && !/[\\^#]/.test(name) && !(cmd === 'unzip' && name.startsWith('-')); } /** * Pair an archive's name listing (`unzip -Z1` / `tar -tf`) with its typed * listing (`unzip -Z --h --t` / `tar -tvf`) and split the members into plain * (regular file or directory) and special (everything else). Special members * are excluded from extraction rather than rejected; the archive is refused * only when the two listings disagree in length or a special member's name * cannot be passed back to the CLI verbatim (see {@link canExcludeVerbatim}). */ function classifyArchiveListing(cmd, names, typed) { const nameLines = listingLines(names); const typedLines = listingLines(typed); if (nameLines.length !== typedLines.length) throw new error_1.AnthropicError(INCONSISTENT_LISTING); const plain = []; const special = []; nameLines.forEach((name, i) => { if (PLAIN_TYPE_CHARS[cmd].has(typedLines[i].charAt(0))) { plain.push(name); return; } if (!canExcludeVerbatim(cmd, name)) { throw new error_1.AnthropicError(`refusing to extract archive: cannot safely exclude member ${JSON.stringify(name)}`); } special.push(name); }); return { plain, special }; } /** * Walk `dir` with `lstat` semantics and reject anything that is not a regular * file or directory. Never follows a link and never descends into anything * but a real directory. */ async function assertOnlyPlainEntries(dir) { for (const entry of await fs.readdir(dir, { withFileTypes: true })) { if (entry.isDirectory()) await assertOnlyPlainEntries(path.join(dir, entry.name)); else if (!entry.isFile()) throw new error_1.AnthropicError(INCONSISTENT_LISTING); } } /** * Run an archive CLI (`unzip` for zip archives, `tar` for everything else), * returning its stdout. Both binaries must be on `PATH`; a missing one would * otherwise surface as an opaque `ENOENT` spawn failure, so it is turned into a * clear, specific error naming the missing command. */ async function runArchiveTool(cmd, args) { try { const { stdout } = await execFileAsync(cmd, args); return stdout; } catch (e) { if ((0, fs_util_1.errnoCode)(e) === 'ENOENT') { throw new error_1.AnthropicError(`skill extraction requires the \`${cmd}\` command, but it was not found on PATH`); } throw e; } } /** * The single top-level directory shared by every entry in an archive listing, * or `''` if entries don't all live under one common directory. Skill bundles * are packaged wrapped in one directory named after the skill (e.g. * `pdf/SKILL.md`, `pdf/scripts/...`); the extractor strips it so contents land * directly in the skill's dir instead of a redundant nested `<skill>/<skill>/` * level. A flat or multi-root archive yields `''`. */ function archiveTopDir(names) { let top; let nested = false; for (const raw of names) { // Drop `.` / empty segments so a `./pdf/...`-style listing (e.g. from // `tar -C dir .`) is treated the same as `pdf/...`. const parts = raw .trim() .split('/') .filter((p) => p !== '' && p !== '.'); if (parts.length === 0) continue; const first = parts[0]; if (top === undefined) top = first; else if (first !== top) return ''; if (parts.length > 1) nested = true; } return top !== undefined && nested ? top : ''; } /** * Extract a skill download (a zip or tar.* archive) into `dest`. Streams the * response body straight to a temp file beside `dest` (so the whole archive is * never buffered in memory — skills can contain large binaries), then shells out * to `unzip`/`tar` — consistent with the rest of the toolset, which already * invokes `bash` and `rg`. Both `unzip` and `tar` must be available on `PATH`; a * missing binary surfaces as a clear error (see {@link runArchiveTool}). Refuses * any member that would escape `dest` (zip-slip / tar-slip): skill archives * come from the API, but skills can be third-party. Members that are not a * regular file or directory (symlink, hardlink, device, fifo) are excluded * from extraction rather than rejected; an archive whose special members * cannot be excluded reliably is refused (see {@link classifyArchiveListing}). * `tar` matches exclusions unanchored, so a plain member sharing a special * member's name may be dropped too. The staging tree is verified to hold only * regular files and directories before anything is promoted into `dest`. * * The skill bundle's single wrapper directory is stripped: the archive is * extracted into a staging dir and the wrapper's contents are promoted into * `dest`, so files land at `dest/SKILL.md` rather than a doubled * `dest/<skill>/SKILL.md` (`unzip` has no `--strip-components`, so this is * done uniformly by staging + promote rather than per-tool flags). */ async function extractSkillArchive(resp, dest) { const tmp = path.join(dest, `.skill-archive-${process.pid}-${Date.now()}`); if (!resp.body) { throw new error_1.AnthropicError('skill download response had no body'); } await (0, promises_1.pipeline)(node_stream_1.Readable.fromWeb(resp.body), fssync.createWriteStream(tmp)); const stage = path.join(path.dirname(dest), `.skill-stage-${process.pid}-${Date.now()}`); const excludeFile = path.join(path.dirname(dest), `.skill-exclude-${process.pid}-${Date.now()}`); try { // Sniff the first bytes: zip archives start with "PK\x03\x04"; treat // anything else as a tar.* archive (`tar -xf` autodetects gzip/bzip2/xz). const head = await readHead(tmp, 4); const isZip = head.length >= 4 && head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04; const archiveCmd = isZip ? 'unzip' : 'tar'; // List first, validate, then extract — `tar`/`unzip` will happily write a // `../` member (or follow a symlink member) outside `-C`/`-d` otherwise. const names = await runArchiveTool(archiveCmd, isZip ? ['-Z1', tmp] : ['-tf', tmp]); const typed = await runArchiveTool(archiveCmd, isZip ? ['-Z', '--h', '--t', tmp] : ['-tvf', tmp]); const { plain, special } = classifyArchiveListing(archiveCmd, names, typed); assertSafeMemberNames([...plain, ...special]); const top = archiveTopDir(plain); await fs.mkdir(stage, { recursive: true, mode: fs_util_1.DIR_CREATE_MODE }); // `unzip` exits non-zero when every member is excluded, so only run the // extractor when there is something to extract. if (plain.length > 0) { await runArchiveTool(archiveCmd, await extractArgs(archiveCmd, tmp, stage, special, excludeFile)); } await assertOnlyPlainEntries(stage); // Promote the wrapper's contents (or the staged tree itself, if the // archive wasn't wrapped) into the already-created empty `dest`. `stage` // is a sibling of `dest`, so each rename stays on one filesystem. const srcRoot = top ? path.join(stage, top) : stage; const entries = await fs.readdir(srcRoot).catch((e) => { throw (0, fs_util_1.errnoCode)(e) === 'ENOENT' ? new error_1.AnthropicError(INCONSISTENT_LISTING) : e; }); for (const entry of entries) { await fs.rename(path.join(srcRoot, entry), path.join(dest, entry)); } } finally { await fs.rm(tmp, { force: true }); await fs.rm(excludeFile, { force: true }); await fs.rm(stage, { recursive: true, force: true }); } } /** * Arguments that extract `archive` into `stage` while excluding every member * in `special`. Names are glob-escaped because both CLIs treat exclusions as * patterns; `tar` reads them from `excludeFile`, `unzip` takes them after * `-x`, which must follow `-d` so no pattern is parsed as an option. */ async function extractArgs(cmd, archive, stage, special, excludeFile) { const patterns = special.map((name) => name.replace(/[*?[\\]/g, '\\$&')); if (cmd === 'unzip') { return ['-oq', archive, '-d', stage, ...(patterns.length > 0 ? ['-x', ...patterns] : [])]; } if (patterns.length === 0) return ['-xf', archive, '-C', stage]; await fs.writeFile(excludeFile, patterns.join('\n') + '\n', { flag: 'wx', mode: 0o600 }); return ['-xf', archive, '-C', stage, '-X', excludeFile]; } /** Read the first `n` bytes of `file`. */ async function readHead(file, n) { const handle = await fs.open(file, 'r'); try { const buf = Buffer.alloc(n); const { bytesRead } = await handle.read(buf, 0, n, 0); return buf.subarray(0, bytesRead); } finally { await handle.close(); } } //# sourceMappingURL=skills.js.map