import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { mkdtemp, mkdir, realpath, rm } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import path from "node:path"; import type { ChatCatalogContext } from "../chat-catalog-contract.js"; const execute = promisify(execFile); const privateActors = new WeakMap(); export async function prepareOwnedCli(ctx: ChatCatalogContext) { if (ctx.input.execution.environment.id !== "local") throw new Error("CLI companion journey requires the owned local attempt"); const root = process.env.PAPERCLIP_RUNNER_E2E_TEMP_ROOT; if (!root) throw new Error("CLI companion requires an owned attempt directory"); const directory = await mkdtemp(path.join(await realpath(root), "operator-cli-")); await mkdir(path.join(directory, "home")); const key = await ctx.input.api.postSensitive<{ id: string; token: string }>("/api/board-api-keys", { name: `catalog-operator-${ctx.input.nonce}`, requestedCompanyId: ctx.input.fixtures.company.id, expiresAt: new Date(Date.now() + 900_000).toISOString(), }); if (!key.id || !key.token) throw new Error("Owned human CLI credential was not issued"); ctx.input.privateSecrets?.push(key.token); privateActors.set(ctx, { ...key, directory }); return { operatorDirectory: directory, operatorKeyId: key.id }; } export async function runOwnedCli(ctx: ChatCatalogContext, args: string[]) { const actor = privateActors.get(ctx); if (!actor) throw new Error("Owned human CLI actor is unavailable"); // A fresh minimal environment cannot pick up another operator's context, // stored auth, run ID, provider credentials or default company. const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, HOME: path.join(actor.directory, "home"), PAPERCLIP_HOME: path.join(actor.directory, "home"), PAPERCLIP_CONTEXT: path.join(actor.directory, "context.json"), PAPERCLIP_API_KEY: actor.token, PAPERCLIP_API_URL: ctx.input.api.baseURL, PAPERCLIP_TELEMETRY_DISABLED: "1", NO_COLOR: "1" }; const loader = fileURLToPath(new URL("../../../cli/node_modules/tsx/dist/loader.mjs", import.meta.url)); const entry = fileURLToPath(new URL("../../../cli/src/index.ts", import.meta.url)); try { const result = await execute(process.execPath, ["--import", loader, entry, ...args], { cwd: actor.directory, env, timeout: 45_000, maxBuffer: 1_048_576 }); if (result.stdout.includes(actor.token) || result.stderr.includes(actor.token)) throw new Error("Private CLI credential appeared in output"); return result.stdout; } catch { throw new Error("Owned CLI command failed; private subprocess output withheld"); } } export async function cleanupOwnedCli(ctx: ChatCatalogContext) { const actor = privateActors.get(ctx); if (!actor) return; await ctx.input.api.delete(`/api/board-api-keys/${actor.id}`); privateActors.delete(ctx); const root = await realpath(process.env.PAPERCLIP_RUNNER_E2E_TEMP_ROOT!); const owned = await realpath(actor.directory); if (!owned.startsWith(root + path.sep) || !path.basename(owned).startsWith("operator-cli-")) throw new Error("Refusing CLI cleanup outside its owned directory"); await rm(owned, { recursive: true }); } /** Parse one finite supported command into argv; never evaluate a shell. */ export function websiteCreateArgv(reply: string, fixture: { company: string; project: string; agent: string; apiBase: string }, bindings: Array<{ flag: string; placeholder: string; fixtureValue: string }> = []) { const blocks = [...reply.matchAll(/```(?:bash|sh|shell)?\s*\n([\s\S]*?)```/g)].map(match => match[1]!.replace(/\\\r?\n/g, " ").trim()); const commands = blocks.flatMap(block => block.split(/\r?\n/)).filter(line => /^(?:paperclipai|npx(?: -y)? paperclipai|pnpm (?:exec )?paperclipai)\s+issue\s+create\b/.test(line.trim())); if (commands.length !== 1) throw new Error("One supported website creation command is required"); const line = commands[0]!.trim(); if (/[`;|&\n\r]|\$\(/.test(line)) throw new Error("Shell execution is not admitted in the owned CLI journey"); const tokens = line.match(/"(?:[^"\\]|\\.)*"|'[^']*'|[^\s]+/g)!; const values = tokens.map(token => token.startsWith('"') ? JSON.parse(token) as string : token.startsWith("'") ? token.slice(1, -1) : token); const start = values.indexOf("issue"), argv = values.slice(start); const allowed = new Set(["--company-id", "-C", "--title", "--description", "--status", "--priority", "--assignee-agent-id", "--project-id", "--api-base"]); const fields = new Map(); for (let i = 2; i < argv.length; i++) { const flag = argv[i]!; if (flag === "--json" || flag === "--standalone") continue; if (!allowed.has(flag) || !argv[i + 1] || fields.has(flag)) throw new Error("Unsupported or ambiguous creation flags"); let value = argv[++i]!; const target = ["--company-id", "-C"].includes(flag) ? fixture.company : flag === "--project-id" ? fixture.project : flag === "--assignee-agent-id" ? fixture.agent : undefined; // Only unresolved identifier placeholders are bound; actual wrong IDs fail. if (target && /^(?:<[A-Z_ -]+>|\$\{?[A-Z_]+\}?)$/.test(value)) { bindings.push({ flag, placeholder: value, fixtureValue: target }); value = target; argv[i] = target; } fields.set(flag, value); } if ((fields.get("--company-id") ?? fields.get("-C")) !== fixture.company || fields.get("--project-id") !== fixture.project || fields.get("--assignee-agent-id") !== fixture.agent || !/website/i.test(fields.get("--title") ?? "") || fields.has("--status") && !["backlog", "todo"].includes(fields.get("--status")!) || fields.has("--api-base") && fields.get("--api-base") !== fixture.apiBase) throw new Error("Creation command does not target exactly the intended company, project and Morgan"); if (fields.has("--company-id") && fields.has("-C")) throw new Error("Duplicate company context flags are ambiguous"); return argv; } export function contextGuidanceArgv(reply: string, fixture: { company: string; apiBase: string }) { const lines = [...reply.matchAll(/```(?:bash|sh|shell)?\s*\n([\s\S]*?)```/g)] .flatMap(match => match[1]!.replace(/\\\r?\n/g, " ").split(/\r?\n/)) .map(line => line.trim()).filter(line => /^(?:paperclipai|npx(?: -y)? paperclipai|pnpm (?:exec )?paperclipai)\s+context\b/.test(line)); let selected = false, readback = false; const commands: string[][] = [], bindings: Array<{ commandIndex: number; flag: string; placeholder: string; fixtureValue: string }> = []; for (const line of lines) { if (/[`;|&\n\r]|\$\(/.test(line)) return; let tokens: string[]; try { tokens = line.match(/"(?:[^"\\]|\\.)*"|'[^']*'|[^\s]+/g)!.map(token => token.startsWith('"') ? JSON.parse(token) as string : token.startsWith("'") ? token.slice(1, -1) : token); } catch { return; } const original = tokens.slice(tokens.indexOf("context")); const command = original[1], args = original.slice(2); if (command === "use") { if (args.shift()?.toLowerCase() !== "acorn") return; selected = true; } else if (command === "show") readback ||= selected; else if (command !== "set" && command !== "list") return; let profile: string | undefined, company: string | undefined, apiBase: string | undefined, use = false; const allowedFlags = command === "set" ? ["--profile", "--company-id", "--api-base", "--use", "--json"] : command === "show" ? ["--profile", "--json"] : command === "list" ? ["--json"] : []; const flags = new Set(); while (args.length) { const flag = args.shift(); if (!flag || !allowedFlags.includes(flag) || flags.has(flag)) return; flags.add(flag); if (flag === "--json") continue; if (flag === "--use") { use = true; continue; } if (!args.length) return; let value = args.shift()!; const expected = flag === "--company-id" ? fixture.company : flag === "--api-base" ? fixture.apiBase : undefined; if (expected && /^(?:<[A-Z_ -]+>|\$\{?[A-Z_]+\}?)$/.test(value)) { const index = original.indexOf(flag) + 1; bindings.push({ commandIndex: commands.length, flag, placeholder: value, fixtureValue: expected }); original[index] = expected; value = expected; } if (flag === "--profile") profile = value; if (flag === "--company-id") company = value; if (flag === "--api-base") apiBase = value; } if (profile && profile.toLowerCase() !== "acorn") return; if (command === "set") { if (profile?.toLowerCase() !== "acorn" || company !== fixture.company || apiBase !== fixture.apiBase) return; selected ||= use; } commands.push(original); } return selected && readback ? { commands, bindings } : undefined; } export function validContextGuidance(reply: string, fixture: { company: string; apiBase: string }) { return !!contextGuidanceArgv(reply, fixture); }