diff --git a/docs/VENDORING.md b/docs/VENDORING.md index f18240a88..77d28399c 100644 --- a/docs/VENDORING.md +++ b/docs/VENDORING.md @@ -140,7 +140,9 @@ fully covered by vendored workflow-definition-loader.ts + shared isAnnotatedDirectorFactory predicate from already-vendored @intx/agent. No first-party consumer imports tool-packaging; stays on transitive published 0.3.0. Revisit if a future ticket establishes tool packages -from closures at runtime. +from closures at runtime. CL-4473 first slice: `src/workflows/tool-packages.ts` +resolves a workflow package's declared tool-package npm deps from its laid-out +`node_modules/` tree; full closure materialization stays open. The license column records what each package declares in its own `package.json`; the corresponding `LICENSE` file travels with every vendored diff --git a/src/workflows/tool-packages.test.ts b/src/workflows/tool-packages.test.ts new file mode 100644 index 000000000..b200ff260 --- /dev/null +++ b/src/workflows/tool-packages.test.ts @@ -0,0 +1,245 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { + mkdir, + mkdtemp, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { resolveWorkflowToolPackages } from "./tool-packages.js"; +import { defined } from "../../testkit/defined.js"; + +const TOOL_NAME = "@corbits/tool-gh"; + +async function writeJSON(path: string, value: unknown): Promise { + await writeFile(path, JSON.stringify(value)); +} + +// A workflow package that declares `toolDeps` as npm dependencies. +async function makeWorkflow( + root: string, + toolDeps: Record, +): Promise { + const dir = join(root, "workflow"); + await mkdir(dir, { recursive: true }); + await writeJSON(join(dir, "package.json"), { + name: "@corbits/workflow-pr-review", + version: "0.1.0", + dependencies: toolDeps, + }); + return dir; +} + +// A laid-out tool package inside the workflow's `node_modules/`. +async function layOutTool( + workflowDir: string, + manifest: Record, +): Promise { + const dir = join(workflowDir, "node_modules", TOOL_NAME); + await mkdir(dir, { recursive: true }); + await writeJSON(join(dir, "package.json"), manifest); + const entry = join(dir, "dist", "tools.js"); + await mkdir(join(dir, "dist"), { recursive: true }); + await writeFile(entry, "export const tools = [];\n"); + return dir; +} + +describe("resolveWorkflowToolPackages", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "wf-tool-pkgs-")); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + test("resolves a declared tool package to its interchange.tools entry", async () => { + const workflowDir = await makeWorkflow(root, { + [TOOL_NAME]: "workspace:*", + }); + const toolDir = await layOutTool(workflowDir, { + name: TOOL_NAME, + version: "1.2.3", + interchange: { tools: "./dist/tools.js" }, + }); + const resolved = defined( + (await resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]))[0], + "resolved tool package", + ); + expect(resolved.name).toBe(TOOL_NAME); + expect(resolved.version).toBe("1.2.3"); + expect(resolved.dir).toBe(await realpath(toolDir)); + expect(resolved.toolsEntry).toBe( + join(await realpath(toolDir), "dist", "tools.js"), + ); + }); + + test("resolves through a symlinked node_modules entry like a workspace layout", async () => { + const workflowDir = await makeWorkflow(root, { + [TOOL_NAME]: "workspace:*", + }); + const target = join(root, "real-tool-pkg"); + await mkdir(join(target, "dist"), { recursive: true }); + await writeJSON(join(target, "package.json"), { + name: TOOL_NAME, + version: "2.0.0", + interchange: { tools: "./dist/tools.js" }, + }); + await writeFile( + join(target, "dist", "tools.js"), + "export const tools = [];\n", + ); + await mkdir(join(workflowDir, "node_modules", "@corbits"), { + recursive: true, + }); + await symlink(target, join(workflowDir, "node_modules", TOOL_NAME)); + const resolved = defined( + (await resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]))[0], + "resolved tool package", + ); + expect(resolved.dir).toBe(await realpath(target)); + expect(resolved.toolsEntry).toBe( + join(await realpath(target), "dist", "tools.js"), + ); + }); + + test("dedupes a tool package requested twice", async () => { + const workflowDir = await makeWorkflow(root, { + [TOOL_NAME]: "workspace:*", + }); + await layOutTool(workflowDir, { + name: TOOL_NAME, + version: "1.2.3", + interchange: { tools: "./dist/tools.js" }, + }); + const resolved = await resolveWorkflowToolPackages(workflowDir, [ + TOOL_NAME, + TOOL_NAME, + ]); + expect(resolved).toHaveLength(1); + }); + + test("rejects a package present on disk but missing from dependencies", async () => { + const workflowDir = await makeWorkflow(root, {}); + await layOutTool(workflowDir, { + name: TOOL_NAME, + version: "1.2.3", + interchange: { tools: "./dist/tools.js" }, + }); + await expect( + resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]), + ).rejects.toThrow(/not a dependency.*declare it as an npm dep/); + }); + + test("rejects a declared dependency that is not installed", async () => { + const workflowDir = await makeWorkflow(root, { + [TOOL_NAME]: "workspace:*", + }); + await expect( + resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]), + ).rejects.toThrow(/could not be resolved.*direct dependency/); + }); + + test("rejects an installed dependency without an interchange.tools entry", async () => { + const workflowDir = await makeWorkflow(root, { + [TOOL_NAME]: "workspace:*", + }); + await layOutTool(workflowDir, { name: TOOL_NAME, version: "1.2.3" }); + await expect( + resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]), + ).rejects.toThrow(/declares no "interchange\.tools" entry/); + }); + + test("rejects an interchange.tools entry that escapes its directory", async () => { + const workflowDir = await makeWorkflow(root, { + [TOOL_NAME]: "workspace:*", + }); + await layOutTool(workflowDir, { + name: TOOL_NAME, + version: "1.2.3", + interchange: { tools: "../evil.js" }, + }); + await expect( + resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]), + ).rejects.toThrow(/escapes its directory/); + }); + + test("rejects an interchange.tools entry that escapes via a file symlink", async () => { + const workflowDir = await makeWorkflow(root, { + [TOOL_NAME]: "workspace:*", + }); + const toolDir = await layOutTool(workflowDir, { + name: TOOL_NAME, + version: "1.2.3", + interchange: { tools: "./dist/tools.js" }, + }); + const outside = join(root, "evil.js"); + await writeFile(outside, "export const tools = [];\n"); + await rm(join(toolDir, "dist", "tools.js")); + await symlink(outside, join(toolDir, "dist", "tools.js")); + await expect( + resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]), + ).rejects.toThrow(/escapes its directory via a symlink/); + }); + + test("rejects an interchange.tools entry that escapes via a directory symlink", async () => { + const workflowDir = await makeWorkflow(root, { + [TOOL_NAME]: "workspace:*", + }); + const outsideDir = join(root, "evil-dir"); + await mkdir(outsideDir, { recursive: true }); + await writeFile( + join(outsideDir, "tools.js"), + "export const tools = [];\n", + ); + const toolDir = join(workflowDir, "node_modules", TOOL_NAME); + await mkdir(toolDir, { recursive: true }); + await writeJSON(join(toolDir, "package.json"), { + name: TOOL_NAME, + version: "1.2.3", + interchange: { tools: "./linked/tools.js" }, + }); + await symlink(outsideDir, join(toolDir, "linked")); + await expect( + resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]), + ).rejects.toThrow(/escapes its directory via a symlink/); + }); + + test("rejects a tool package name that is not a valid npm package name", async () => { + const workflowDir = await makeWorkflow(root, { + [TOOL_NAME]: "workspace:*", + }); + await expect( + resolveWorkflowToolPackages(workflowDir, ["../../evil"]), + ).rejects.toThrow(/not a valid npm package name/); + }); + + test("rejects a resolved directory whose package.json names another package", async () => { + const workflowDir = await makeWorkflow(root, { + [TOOL_NAME]: "workspace:*", + }); + await layOutTool(workflowDir, { + name: "@corbits/tool-other", + version: "1.2.3", + interchange: { tools: "./dist/tools.js" }, + }); + await expect( + resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]), + ).rejects.toThrow(/names itself/); + }); + + test("rejects a workflow package.json missing name and version", async () => { + const workflowDir = join(root, "workflow"); + await mkdir(workflowDir, { recursive: true }); + await writeJSON(join(workflowDir, "package.json"), { private: true }); + await expect( + resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]), + ).rejects.toThrow(/invalid package\.json/); + }); +}); diff --git a/src/workflows/tool-packages.ts b/src/workflows/tool-packages.ts new file mode 100644 index 000000000..d9db6219a --- /dev/null +++ b/src/workflows/tool-packages.ts @@ -0,0 +1,156 @@ +import { readFile, realpath } from "node:fs/promises"; +import { join, sep } from "node:path"; +import { ArkErrors, type } from "arktype"; + +import { isContainedEntryPath, PackageJSON } from "@intx/types/package-json"; +import { ToolPackagePinName } from "@intx/types/tool-packages"; + +// A workflow package declares the tool packages its steps need as npm +// dependencies (CL-4473). Resolution reads the laid-out `node_modules/` tree, +// the same contract the vendored workflow-definition-loader enforces for +// plugin packages: the dep must be declared, resolvable, and carry an +// `interchange.tools` entry contained in its own directory. +const WorkflowPackageJSON = type({ + name: "string", + version: "string", + "dependencies?": "Record", + "peerDependencies?": "Record", +}).onUndeclaredKey("ignore"); + +export interface WorkflowToolPackage { + name: string; + version: string; + // Realpath of the resolved tool package directory. + dir: string; + // Absolute path of the package's `interchange.tools` entry module. + toolsEntry: string; +} + +async function readJSON(dir: string, label: string): Promise { + const path = join(dir, "package.json"); + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (cause) { + throw new Error(`cannot read ${label} package.json at ${path}`, { + cause, + }); + } + try { + return JSON.parse(raw) as unknown; + } catch (cause) { + throw new Error(`cannot parse ${label} package.json at ${path}`, { + cause, + }); + } +} + +// Resolve one declared tool package from the workflow package's laid-out +// `node_modules/`. Realpath first so workspace symlinks and the closure +// materializer's laid-out tree compare realpath-vs-realpath downstream. +async function resolveOne( + workflowDir: string, + declared: ReadonlySet, + name: string, +): Promise { + // A declared dep key is joined onto `node_modules/` below, so the name must + // satisfy npm's package-name rules first — otherwise a key like + // `../../evil` resolves outside the workflow package. Same rule the hub + // enforces on tool-package pins (`ToolPackagePinName`). + if (ToolPackagePinName(name) instanceof ArkErrors) { + throw new Error( + `tool package ${JSON.stringify(name)} is not a valid npm package name`, + ); + } + if (!declared.has(name)) { + throw new Error( + `tool package ${JSON.stringify(name)} is not a dependency of the workflow package at ${workflowDir}; declare it as an npm dep`, + ); + } + const linkedDir = join(workflowDir, "node_modules", name); + let dir: string; + try { + dir = await realpath(linkedDir); + } catch (cause) { + throw new Error( + `tool package ${JSON.stringify(name)} could not be resolved from the workflow package at ${workflowDir}; it must be installed as a direct dependency`, + { cause }, + ); + } + const parsed = PackageJSON(await readJSON(dir, `tool package ${name}`)); + if (parsed instanceof ArkErrors) { + throw new Error( + `tool package ${JSON.stringify(name)} at ${dir} has an invalid package.json: ${String(parsed)}`, + ); + } + if (parsed.name !== name) { + throw new Error( + `tool package at ${dir} names itself ${JSON.stringify(parsed.name)} but was resolved as ${JSON.stringify(name)}`, + ); + } + const entryRel = parsed.interchange?.tools; + if (entryRel === undefined) { + throw new Error( + `tool package ${JSON.stringify(name)} at ${dir} declares no "interchange.tools" entry; it is not a tool package`, + ); + } + if (!isContainedEntryPath(entryRel)) { + throw new Error( + `tool package ${JSON.stringify(name)} at ${dir} declares an "interchange.tools" entry ${JSON.stringify(entryRel)} that escapes its directory`, + ); + } + // Realpath containment, mirroring the vendored workflow-definition-loader: + // the string check above rejects `..`/absolute paths, and this check + // rejects an escape through a file or directory symlink inside the package. + // Both sides are realpath'd so the comparison holds under a symlinked + // parent (e.g. macOS `/tmp` -> `/private/tmp`). + const entryAbs = join(dir, entryRel); + let realDir: string; + let realEntry: string; + try { + realDir = await realpath(dir); + realEntry = await realpath(entryAbs); + } catch (cause) { + throw new Error( + `tool package ${JSON.stringify(name)} at ${dir} declares an "interchange.tools" entry ${JSON.stringify(entryRel)} that could not be resolved`, + { cause }, + ); + } + const containmentRoot = realDir.endsWith(sep) ? realDir : realDir + sep; + if (realEntry !== realDir && !realEntry.startsWith(containmentRoot)) { + throw new Error( + `tool package ${JSON.stringify(name)} at ${dir} declares an "interchange.tools" entry ${JSON.stringify(entryRel)} that escapes its directory via a symlink`, + ); + } + return { + name, + version: parsed.version, + dir, + toolsEntry: entryAbs, + }; +} + +// Resolve a workflow package's declared tool-package dependencies to their +// `interchange.tools` entries. Names must appear in the workflow package's +// `dependencies` (or `peerDependencies`); anything else is rejected even when +// present on disk, so the declaration — not the layout — is authoritative. +export async function resolveWorkflowToolPackages( + workflowDir: string, + toolPackageNames: readonly string[], +): Promise { + const raw = await readJSON(workflowDir, "workflow package"); + const parsed = WorkflowPackageJSON(raw); + if (parsed instanceof ArkErrors) { + throw new Error( + `workflow package at ${workflowDir} has an invalid package.json: ${String(parsed)}`, + ); + } + const declared = new Set([ + ...Object.keys(parsed.dependencies ?? {}), + ...Object.keys(parsed.peerDependencies ?? {}), + ]); + const unique = [...new Set(toolPackageNames)]; + return Promise.all( + unique.map((name) => resolveOne(workflowDir, declared, name)), + ); +}