diff --git a/evals/agent-credentials.md b/evals/agent-credentials.md new file mode 100644 index 0000000..78769f6 --- /dev/null +++ b/evals/agent-credentials.md @@ -0,0 +1,26 @@ +# Per-client agent credential regression + +Run on Node 20+ after building all workspaces: + +```sh +npm exec --workspace packages/cli -- vitest run tests/program-agent-client.test.ts tests/agent-setup.test.ts +``` + +Fixtures use a local fake CMS, synthetic sessions/keys and isolated directories. +They verify separate client keys, owner-session lifecycle headers, idempotent +reuse across pagination, revoked-row replacement, application-key preservation, +partial mint/local-write failure compensation, unknown mint outcomes without +retry, tracked/unsafe/private-file conflicts, concurrent private-file retention, +static doctor and actual generated launcher credential selection. Dry-run, +no-consent and missing-login calls must mint nothing and write nothing. No keys +or session tokens may appear in command output/config. A successful installation +must report an unverified next step and must make no readiness/status call. + +Real Windows CI additionally runs `tests/agent-setup-windows.test.ts` against +cmd.exe and the generated standalone launcher, including a selected private key +with an inherited live key and mixed-case URL override. + +Git preflight regression: start with a real tracked private credential, then wrap +Git to time out, fail to spawn or return unexpected 128/2. Setup must make zero +HTTP requests and preserve original file bytes; doctor reports +`credential_check_failed`. Unknown tracking is never interpreted as untracked. diff --git a/packages/cli/README.md b/packages/cli/README.md index 2f5b9f8..ee785a3 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4,85 +4,111 @@ CLI-native affiliate tracking. Connect Stripe, track commissions, manage partner ## AI agent setup -From the project root, preview the local installation before applying it: +Preview the selected client's installation from your project root. Sign in using +Affitor's existing login command, then apply the plan: ```bash -affitor agent setup --client claude_code --dry-run --json -affitor agent setup --client claude_code --yes +affitor login +affitor agent setup --client claude_code --program-id 123 --dry-run --json +affitor agent setup --client claude_code --program-id 123 --yes affitor doctor --client claude_code --json ``` +`--program-id` is optional when `.affitor/config.json` already links a program. +An interactive terminal can select a program from the signed-in account's list. +Without a linked/selected program, setup remains a local installer using an existing +program key and explains how to rerun for separate agent credentials. It does not +mint a key in this mode. Existing per-client setups cannot be downgraded to this +legacy mode: pass the program id when updating them. This feature requires the CMS program-agent API release; +a missing route or insufficient Owner access fails without falling back to a live key. + Choose `claude_code`, `cursor`, or `codex`; repeat `--client` for more than one. -Without a selection, an interactive terminal asks, with nothing selected by default. -Noninteractive runs require `--yes` and a selection. Inside an agent, setup can only -configure that agent. `--dry-run` writes nothing. Without confirmation, setup exits1. - -Claude Code uses project `.mcp.json`; Cursor uses `.cursor/mcp.json`. Existing -unrelated settings are preserved. Existing file bytes are backed up under -`.affitor/backups/` before replacement, with mode0600; generated `.gitignore` -entries exclude backups and `.affitor/.env`. Updates use atomic replacement and -read-back, rolling back prior writes on failure. Backups and their local ignore -guard are kept on failure so a restored root `.gitignore` cannot expose them. -An existing backup guard must contain only `*`; negation or extra rules are refused. -It is installed before any merged config is written. Temporary writes stay under -this private, ignored backup directory, including after abrupt process termination. -Credential and backup ignore rules are appended at the end of both the root -`.gitignore` and `.affitor/.gitignore`, preserving earlier custom rules while -overriding negations. Existing files are backed up before changing them. The child -guard also protects `credentials.json` when a project directory is your home. -A concurrent edit is preserved; -inspect the files and private backups before retrying. This protects ordinary -local edits, not a hostile process racing filesystem operations. - -The generated `.affitor/agent-mcp.cjs` launcher starts pinned `@affitor/mcp@0.2.2` -from this project's absolute directory. No credential is embedded in an agent -config, launcher, or skill. The launcher reads inherited `AFFITOR_API_KEY`, then -this project's `.affitor/.env` (created by `affitor init`). It never reads the user -login token in `~/.affitor/credentials.json`. If `onboard` wrote a key to your root -`.env`, provide it privately as `AFFITOR_API_KEY` when starting the agent instead. -It removes inherited API URL overrides regardless of their casing and uses the -production API. It disables sale/refund tools even when the parent -environment has `AFFITOR_MCP_WRITE=1`. Other tools still include tracking and -synthetic verification; approve those actions separately. +Detected clients are not selected automatically. Inside an agent, setup can only +configure itself. Noninteractive runs require a selection and `--yes` (or +`--auto-confirm`). `--dry-run` makes no API calls and writes nothing. Without consent, +setup exits1. A linked project with no valid saved login returns `needs_login` +(exit1), with no minted key or local write. Run `affitor login` and retry. + +For a selected program, each client gets its own restricted `aff_ag_` credential. +Only an active program Owner can mint/list/revoke it through the user-JWT lifecycle +API. Neither `--api-key` nor inherited `AFFITOR_API_KEY` is used as that user JWT. +Setup lists existing credentials to reuse a matching active local key, without +calling readiness/status or marking an agent connected. Revoked or missing rows +are replaced on the next approved setup. The keys permit readiness, status, +synthetic checks, clicks and test-only leads. The server forces agent leads into +test mode; they cannot settle real customer attribution or authorize sale/refund. + +Private credentials live at `.affitor/agents/.json` (mode0600 on POSIX), +separate from the application's live key in `.affitor/.env`. Setup preserves the +application key. The JSON includes the key's id, program, client, API origin and +an unverified scope/date note. Do not commit or share this file. A selected +credential belonging to another program/origin must be revoked and removed before +switching. Unsafe paths, malformed private files, POSIX permissions broader than +owner-only and Git-tracked private credentials are refused. Git must confirm an untracked +path in a worktree or an actual directory outside any repository. A timeout, +missing executable, unreadable repository or unexpected exit blocks setup before +any API call or write. + +Claude Code uses project `.mcp.json`; Cursor uses `.cursor/mcp.json`. Their entries +contain only `node`, an absolute `.affitor/agent-mcp.cjs` path and a client selector. +No credential appears in MCP config, the launcher, skill, plan or command output. +The launcher reads only the selected client's private file, ignoring inherited or +application keys. A missing/invalid selected key fails closed. Its API origin comes +from that private record (default production; use an explicit `--api-url` for an +HTTPS custom origin). It never reads a user login token. Legacy entries with no +client selector still read inherited `AFFITOR_API_KEY`, then `.affitor/.env`, using +the same strict env parser as static doctor. Their API origin remains production. + +Existing unrelated settings are preserved. Changed file bytes are backed up under +`.affitor/backups/` before replacement, with mode0600. Credential, login-token and +backup ignores are appended to root and child `.gitignore` files; strict `*` +guards protect private agent files and backups before the first secret write. +Temporary writes stay under the ignored backup directory, including after abrupt +termination. Updates use atomic replacement and read-back, rolling back prior writes +on failure. Concurrent edits are preserved, with private ignore guards retained. +Inspect files and private backups before retrying. This handles ordinary local +edits; it is not a sandbox against hostile local filesystem races. + +Mint requests are never retried automatically. If a later mint or local write +fails, setup attempts to revoke every newly minted key whose id it received. +`revoke_failed_ids` lists known ids needing dashboard cleanup; +`mint_outcome_unknown: true` means a lost/failed response may have created another +Waiting row. Review unused Waiting credentials in Settings → Agents and revoke them +before retrying. An unknown mint result is not reported as a successful rollback. +Reuse does not revoke pre-existing keys when a local update fails. A recovery skill is installed in `.claude/skills/affitor/` or `.cursor/skills/affitor/`. -Codex gets `.agents/skills/affitor/` and a printed `codex mcp add` command; setup -does not edit its TOML config or run that command. Review it, then run it yourself. -If you move the project, run setup again to update absolute paths. The printed -command uses POSIX shell quoting on macOS/Linux. Plan notes flag machine-specific -absolute paths: keep generated MCP configs local or regenerate on each machine. On Windows, no shell command -is generated: add the MCP in Codex settings using `command: "node"` and the exact -absolute path from the preview's `entry.args` as one argument. Do not paste POSIX -commands into cmd.exe. - -**Installed is not verified.** Successful setup returns `needs_human` (exit0). -`doctor` is read-only and static: it does not start an agent, connect to the API, -or prove the key works. It returns `aligned`/`stale`/`missing`/`manual`/`invalid` -per client and `present_unverified`/`missing` for credentials. With no selection, -doctor checks existing JSON client configs; an agent caller checks itself. -Exit0 means all selected static checks align and a credential is present; -otherwise exit1. A `credential_tracked` warning also returns exit1: doctor checks -Git when available without changing its index, including when the repository root is above the -project. Rotate an exposed key and remove it from tracking/history using your -repository process; adding an ignore rule does not untrack a credential. -Restart the agent and ask it to call `affitor_readiness`, checking -the program identity before integration. Only report the readiness checks that pass. - -Doctor and the launcher use the same credential parser: empty, duplicate and -interpolated assignments fail closed. An explicit nonempty environment value -takes precedence and is validated without reading a file. On Windows, the -launcher runs `npx.cmd` through `cmd.exe` with a constant command; project paths -and credentials are passed through `cwd`/environment, outside shell text. -`NoDefaultCurrentDirectoryInExePath=1` prevents a project-root `npx.cmd` from -shadowing the PATH executable. The child receives only runtime paths, home/temp, -locale/terminal settings and the program key; parent proxy, Node options, npm -overrides and unrelated credentials are dropped. The public npm registry is -explicit. Project code, the generated launcher, PATH and project `.npmrc` still -belong to your trusted local environment; this installer is not a sandbox. - -This local installer uses an existing program key. Per-agent key issuance, -pairing, connection telemetry, and the dashboard Agents page are separate work; -this command does not claim to create or verify an agent connection. +Codex gets `.agents/skills/affitor/` and a printed command; setup does not edit its +TOML or run the command. On Windows, add the MCP in Codex settings using the exact +command/argument array in `entries.codex` from the preview. No shell command is +printed. On macOS/Linux the printed command uses POSIX quoting. Paths are specific +to this machine: keep generated configs local or regenerate after moving the +project or on a teammate's machine. + +**Installed is not verified.** Credential setup returns `needs_next_step` (exit0); +local-only installation returns `needs_human` (exit0). Setup does not run an agent, +call readiness, or infer Verified from installation. Restart the client and ask: +“Call affitor_readiness from Affitor and tell me the result.” Check the program +identity and report only checks that actually pass. Approve synthetic checks and +other writes separately. + +`doctor` is read-only and static: it does not start an agent or connect to the API. +It returns `aligned`/`stale`/`missing`/`manual`/`invalid` per client and +`present_unverified`/`missing` for credentials. With no selection it checks existing +JSON client configs; an agent caller checks itself. Exit0 means selected static +checks align, credentials are present and no warning exists. Codex stays manual. +A `credential_check_failed` warning means tracking could not be checked and returns +exit1; fix Git access and retry. A `credential_tracked` warning also returns exit1; Git checks include parent repository +roots and do not change the index. Rotate exposed keys and resolve tracking/history +through your repository process; an ignore rule does not untrack them. + +The launcher starts pinned `@affitor/mcp@0.2.2` with sale/refund tools disabled even +when the parent sets `AFFITOR_MCP_WRITE=1`. Windows uses a constant `cmd.exe` command +with `NoDefaultCurrentDirectoryInExePath=1`; paths/keys stay outside shell text. +The child receives only runtime paths, home/temp, locale/terminal settings, its +credential and explicit origin. Parent proxy, Node options, npm overrides and +unrelated credentials are dropped; npm uses the public registry. Project code, +PATH, `.npmrc` and the launcher remain part of your trusted local environment. ## Quick Start diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index a34d7c4..7d3a7f6 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -1,17 +1,21 @@ import type { Command } from "commander"; -import { checkbox, confirm } from "@inquirer/prompts"; +import { checkbox, confirm, select } from "@inquirer/prompts"; import { existsSync } from "node:fs"; import { join } from "node:path"; -import { CLIENTS, applySetup, callerClient, doctor, planSetup, selectedClients } from "../lib/agent-setup.js"; +import { CLIENTS, applySetup, callerClient, doctor, planSetup, selectedClients, setupProgramId } from "../lib/agent-setup.js"; +import { readCredentials } from "../lib/config.js"; +import { DEFAULT_API_URL } from "../types.js"; +import { AgentSetupError, ProgramAgentClient, agentApiOrigin, installProgramAgents } from "../lib/program-agent-client.js"; import { getFlags } from "../lib/flags.js"; -interface SetupOptions { client?: string[]; dryRun?: boolean; yes?: boolean } +interface SetupOptions { client?: string[]; dryRun?: boolean; yes?: boolean; programId?: string } export function registerAgentCommands(program: Command) { program.command("agent").description("Configure an AI coding agent for this project") .command("setup") .description("Preview and install project-scoped MCP configuration and an Affitor skill") .option("--client ", "claude_code, cursor or codex (repeat to select)", (value: string, prior: string[]) => [...prior, value], []) + .option("--program-id ", "Use this program for independent agent credentials (otherwise use the linked project)") .option("--dry-run", "Show the plan without writing files") .option("--yes", "Apply the selected client's plan") .action(async (opts: SetupOptions, cmd: Command) => { @@ -25,12 +29,23 @@ export function registerAgentCommands(program: Command) { choices: CLIENTS.map(value => ({ value, name: `${value}${existsSync(join(process.cwd(), value === "claude_code" ? ".claude" : value === "cursor" ? ".cursor" : ".agents")) ? " (project directory found)" : ""}`, checked: false })), }), process.env); } - const plan = planSetup(process.cwd(), clients); + let programId = setupProgramId(process.cwd(), opts.programId); + const origin = agentApiOrigin(flags.apiUrl ?? DEFAULT_API_URL); + if (!programId && clients.length && interactive && !opts.dryRun) { + const session = readCredentials(); + if (session?.token && Number.isFinite(Date.parse(session.expires_at)) && Date.parse(session.expires_at) > Date.now()) { + const choices = await new ProgramAgentClient(session.token, origin).programs(); + if (choices.length) programId = await select({ message: "Which program should these agents use?", choices: choices.map(row => ({ value: row.id, name: `${row.name} (${row.id})` })) }); + } + } + const plan = planSetup(process.cwd(), clients, { agentKeys: programId !== null }); // Never serialize plan.actions.content: existing configs can contain // unrelated credentials that must stay local, including in a dry-run. const preview = { project: plan.project, clients, caller: callerClient(process.env) ?? null, - entry: plan.entry, mcpVersion: plan.mcpVersion, + entries: Object.fromEntries(clients.map(client => [client, { command: plan.entry.command, args: [...plan.entry.args, ...(programId !== null ? [client] : [])] }])), + entry: plan.entry, mcpVersion: plan.mcpVersion, program_id: programId, api_url: origin, + agent_keys: plan.agentKeys?.map(({ client, path, state }) => ({ client, path, state })), actions: plan.actions.map(({ path, operation, backup }) => ({ path, operation, backup })), manual: plan.manual, notes: plan.notes, }; @@ -41,23 +56,34 @@ export function registerAgentCommands(program: Command) { approved = await confirm({ message: "Apply this plan? Existing files are backed up first.", default: false }); } if (!opts.dryRun && approved && clients.length) { - applySetup(plan); - status = "needs_human"; + if (programId !== null) { + const session = readCredentials(); + if (!session?.token || !Number.isFinite(Date.parse(session.expires_at)) || Date.parse(session.expires_at) <= Date.now()) status = "needs_login"; + else { + const installed = await installProgramAgents(plan, programId, new ProgramAgentClient(session.token, origin)); + preview.agent_keys = installed.map(({ client, path, state }) => ({ client, path, state })); + status = "needs_next_step"; + } + } else { applySetup(plan); status = "needs_human"; } } const next = !clients.length ? "Select --client claude_code, cursor or codex. Detected agents are not selected automatically." - : status === "needs_confirmation" - ? "Review the plan, then run affitor agent setup --client --yes." - : "Installed is not verified. Configure the program key privately as AFFITOR_API_KEY or in .affitor/.env; restart the selected agent, then ask it to call affitor_readiness. For Codex, first follow the printed manual configuration instructions."; + : status === "needs_login" + ? "Run affitor login, then rerun this setup command. No agent key was minted or local files written." + : status === "needs_confirmation" || status === "planned" + ? `Review the plan, then rerun affitor agent setup --client ${programId !== null ? ` --program-id ${programId}` : ""} --yes with the same API origin.` + : status === "needs_next_step" + ? "Installed, not verified. Restart the selected agent and ask it: Call affitor_readiness from Affitor and tell me the result. For Codex, first follow its printed manual configuration instructions." + : "Local installer only: link a program with affitor init or rerun with --program-id to mint separate agent credentials. Installed is not verified. Configure the program key privately as AFFITOR_API_KEY or in .affitor/.env; restart the selected agent, then ask it to call affitor_readiness. For Codex, first follow the printed manual configuration instructions."; if (flags.json) process.stdout.write(JSON.stringify({ status, ...preview, next }) + "\n"); else process.stdout.write(`${status}\n${next}\n`); - process.exitCode = status === "needs_confirmation" || (!clients.length && !opts.dryRun) ? 1 : 0; + process.exitCode = (status === "needs_confirmation" || status === "needs_login") || (!clients.length && !opts.dryRun) ? 1 : 0; } catch (error) { // Expected installer errors contain only fixed messages and relative // client paths. Do not print arbitrary fs/JSON/credential exceptions. - const message = error instanceof Error && /^(Unknown client|An agent can only|\.mcp\.json |\.cursor\/mcp\.json |Setup failed|A setup file|Unsafe setup|Setup target)/.test(error.message) + const message = error instanceof AgentSetupError ? error.message : error instanceof Error && /^(Unknown client|An agent can only|\.mcp\.json |\.cursor\/mcp\.json |Setup failed|A setup file|Unsafe setup|Setup target)/.test(error.message) ? error.message : "Agent setup could not finish. Check the project paths and run a fresh dry-run."; - process.stdout.write(JSON.stringify({ status: "error", message }) + "\n"); + process.stdout.write(JSON.stringify({ status: "error", message, ...(error instanceof AgentSetupError ? { revoke_failed_ids: error.cleanupIds, mint_outcome_unknown: error.uncertainMint } : {}) }) + "\n"); process.exitCode = 1; } }); @@ -70,7 +96,7 @@ export function registerAgentCommands(program: Command) { const configured = CLIENTS.filter(client => client !== "codex" && existsSync(join(process.cwd(), client === "claude_code" ? ".mcp.json" : ".cursor/mcp.json"))); const results = doctor(process.cwd(), clients.length ? clients : configured.length ? configured : ["claude_code", "cursor"]); const healthy = results.every(result => result.status === "aligned" && result.credential === "present_unverified" && result.warnings.length === 0); - const result = { status: healthy ? "aligned" : "needs_attention", mode: "static", clients: results, next: "Restart the selected agent and call affitor_readiness. A credential_tracked warning requires rotating the key and removing it from Git tracking/history through your repository process. Static checks do not prove a live connection or integration readiness." }; + const result = { status: healthy ? "aligned" : "needs_attention", mode: "static", clients: results, next: "Restart the selected agent and call affitor_readiness. A credential_tracked warning requires rotating the key and removing it from Git tracking/history through your repository process. A credential_check_failed warning means Git tracking is unknown: fix Git access and retry. Static checks do not prove a live connection or integration readiness." }; if (getFlags(cmd).json) process.stdout.write(JSON.stringify(result) + "\n"); else process.stdout.write(JSON.stringify(result, null, 2) + "\n"); process.exitCode = healthy ? 0 : 1; diff --git a/packages/cli/src/lib/agent-launcher.ts b/packages/cli/src/lib/agent-launcher.ts index 49a738e..3641deb 100644 --- a/packages/cli/src/lib/agent-launcher.ts +++ b/packages/cli/src/lib/agent-launcher.ts @@ -17,6 +17,34 @@ export function parseProgramCredential(envKey: unknown, readText: () => string | } catch { return null; } } +export interface StoredAgentCredential { + version: 1; + id: number; + program_id: number; + client: string; + api_url: string; + created_at: string; + credential_note: string; + api_key: string; +} + +/** Shared with the standalone launcher; private files never supply executable code. */ +export function parseStoredAgentCredential(text: string, client: string): StoredAgentCredential | null { + try { + const value = JSON.parse(text); + if (!value || value.version !== 1 || value.client !== client || + !["claude_code", "cursor", "codex"].includes(client) || + !Number.isSafeInteger(value.id) || value.id <= 0 || + !Number.isSafeInteger(value.program_id) || value.program_id <= 0 || + typeof value.api_key !== "string" || !/^aff_ag_[A-Za-z0-9_-]{43}$/.test(value.api_key) || + typeof value.api_url !== "string") return null; + const url = new URL(value.api_url); + if (url.origin !== value.api_url || url.username || url.password || + (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))) return null; + return value as StoredAgentCredential; + } catch { return null; } +} + /** Minimal runtime environment; never forward parent networking, Node or npm overrides. */ export function agentChildEnvironment(source: NodeJS.ProcessEnv, key: string): NodeJS.ProcessEnv { const allowed = ["PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "TMP", "TEMP", "TMPDIR", "LANG", "TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR"]; @@ -37,19 +65,34 @@ const { join, resolve } = require('node:path'); const { spawn } = require('node:child_process'); const project = resolve(__dirname, '..'); const parseProgramCredential = ${parseProgramCredential.toString()}; -const key = parseProgramCredential(process.env.AFFITOR_API_KEY, () => { +const selector = process.argv[2]; +const parseStoredAgentCredential = ${parseStoredAgentCredential.toString()}; +let stored = null; +if (selector !== undefined) { + try { + if (!['claude_code', 'cursor', 'codex'].includes(selector) || process.argv.length !== 3) throw new Error(); + const directory = join(__dirname, 'agents'); + const parent = lstatSync(directory); + const path = join(directory, selector + '.json'); + const stat = lstatSync(path); + if (!parent.isDirectory() || parent.isSymbolicLink() || !stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error(); + stored = parseStoredAgentCredential(readFileSync(path, 'utf8'), selector); + } catch {} +} +const key = selector !== undefined ? stored?.api_key : parseProgramCredential(process.env.AFFITOR_API_KEY, () => { const path = join(__dirname, '.env'); const stat = lstatSync(path); if (!stat.isFile() || stat.isSymbolicLink()) return null; return readFileSync(path, 'utf8'); }); if (!key) { - process.stderr.write('Affitor: set AFFITOR_API_KEY or configure this project with affitor init. No agent call was made.\\n'); + process.stderr.write('Affitor: credential missing or invalid. Run affitor agent setup for this client; no agent call was made.\\n'); process.exit(1); } // Setup never opts into the sale/refund tools, including through inherited env. const agentChildEnvironment = ${agentChildEnvironment.toString()}; const env = agentChildEnvironment(process.env, key); +if (stored) env.AFFITOR_API_URL = stored.api_url; // Windows batch files require cmd.exe. The command contains constants only: // project paths and credentials travel through cwd/env, never shell text. const windows = process.platform === 'win32'; @@ -84,6 +127,6 @@ Do not run sale/refund tools. Ask before running synthetic verification or other | --- | --- | | Missing or stale local files | Run affitor agent setup --dry-run --json, show the plan, then apply the selected client after approval. | | Files aligned, tools absent | Restart the selected agent, then call affitor_readiness. | -| Credential missing or rejected | Configure AFFITOR_API_KEY privately for this program. Never paste the key into chat. | +| Credential missing or rejected | Run affitor login, then affitor agent setup for this client and program. Never paste any key into chat. | | Readiness fails | Report the failed checks and follow the runbook; do not claim integration is verified. | `; diff --git a/packages/cli/src/lib/agent-setup.ts b/packages/cli/src/lib/agent-setup.ts index 23c99d6..2aeac15 100644 --- a/packages/cli/src/lib/agent-setup.ts +++ b/packages/cli/src/lib/agent-setup.ts @@ -2,7 +2,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSyn import { dirname, join, relative, resolve } from "node:path"; import { randomUUID } from "node:crypto"; import { spawnSync } from "node:child_process"; -import { AGENT_LAUNCHER, AGENT_SKILL, MCP_VERSION, parseProgramCredential, agentChildEnvironment } from "./agent-launcher.js"; +import { AGENT_LAUNCHER, AGENT_SKILL, MCP_VERSION, parseProgramCredential, agentChildEnvironment, parseStoredAgentCredential, type StoredAgentCredential } from "./agent-launcher.js"; export const CLIENTS = ["claude_code", "cursor", "codex"] as const; export type AgentClient = typeof CLIENTS[number]; @@ -22,6 +22,7 @@ export interface SetupPlan { actions: FileAction[]; manual: string[]; notes: string[]; + agentKeys?: { client: AgentClient; path: string; id?: number; state: "pending" | "reused" | "minted" }[]; } const snapshots = new WeakMap>(); @@ -76,7 +77,22 @@ export function selectedClients(names: string[], env: NodeJS.ProcessEnv): AgentC return [...new Set(names.length ? names as AgentClient[] : caller ? [caller] : [])]; } -export function planSetup(cwd: string, clients: AgentClient[]): SetupPlan { +export function setupProgramId(cwd: string, explicit?: string): number | null { + const valid = (value: unknown) => (typeof value === "number" || typeof value === "string") && /^[1-9]\d*$/.test(String(value)) && Number.isSafeInteger(Number(value)); + if (explicit !== undefined) { + if (!valid(explicit)) throw new Error("Setup target program id must be a positive decimal integer."); + return Number(explicit); + } + const project = realpathSync(resolve(cwd)); + const raw = readTarget(project, join(project, ".affitor/config.json")); + if (raw === null) return null; + let value: unknown; + try { value = JSON.parse(raw); } catch { throw new Error("Setup target project config is invalid. Resolve it before setup."); } + if (!object(value) || !valid(value.program_id)) throw new Error("Setup target project config has no valid program id. Pass --program-id explicitly."); + return Number(value.program_id); +} + +export function planSetup(cwd: string, clients: AgentClient[], options: { agentKeys?: boolean } = {}): SetupPlan { const project = realpathSync(resolve(cwd)); const launcher = join(project, ".affitor/agent-mcp.cjs"); const entry = { command: "node", args: [launcher] }; @@ -95,6 +111,22 @@ export function planSetup(cwd: string, clients: AgentClient[]): SetupPlan { before.set(path, original); plan.actions.push({ path, content, backup, operation: original === content ? "unchanged" : original === null ? "create" : "update" }); }; + if (!options.agentKeys && clients.some(client => readTarget(project, join(project, `.affitor/agents/${client}.json`)) !== null)) { + throw new Error("Setup target already has per-client credentials. Pass --program-id before updating."); + } + if (options.agentKeys) { + plan.notes.push("Private credential actions may replace revoked/missing keys after sign-in; matching active keys are reused without changing their file."); + plan.agentKeys = clients.map(client => { + const rel = `.affitor/agents/${client}.json`; + const path = join(project, rel), raw = readTarget(project, path); + if (raw !== null && process.platform !== "win32" && (lstatSync(path).mode & 0o077) !== 0) throw new Error("Setup target private agent credential must have owner-only permissions."); + if (raw !== null && !parseStoredAgentCredential(raw, client)) throw new Error("Setup target has an invalid private agent credential. Resolve it before retrying."); + const backup = join(project, ".affitor/backups", `${rel}.affitor.bak`); + safePath(project, backup); if (existsSync(backup)) readTarget(project, backup); + add(rel, "", raw); + return { client, path, state: "pending" }; + }); + } const editable = clients.filter(client => client !== "codex"); for (const client of editable) { const rel = configFile(client); @@ -106,7 +138,9 @@ export function planSetup(cwd: string, clients: AgentClient[]): SetupPlan { if (!object(config) || (config.mcpServers !== undefined && !object(config.mcpServers))) { throw new Error(`${rel} must contain an object with an mcpServers object. Nothing was written.`); } - config.mcpServers = { ...(config.mcpServers as JsonObject | undefined), affitor: entry }; + const existingEntry = (config.mcpServers as JsonObject | undefined)?.affitor; + if (!options.agentKeys && object(existingEntry) && Array.isArray(existingEntry.args) && existingEntry.args.includes(client)) throw new Error("Setup target uses per-client credentials. Pass --program-id before updating."); + config.mcpServers = { ...(config.mcpServers as JsonObject | undefined), affitor: options.agentKeys ? { command: entry.command, args: [...entry.args, client] } : entry }; add(rel, JSON.stringify(config, null, 2) + "\n", raw); add(client === "claude_code" ? ".claude/skills/affitor/SKILL.md" : ".cursor/skills/affitor/SKILL.md", AGENT_SKILL); } @@ -114,9 +148,9 @@ export function planSetup(cwd: string, clients: AgentClient[]): SetupPlan { if (process.platform === "win32") { // A path may contain cmd.exe expansion/separator characters. Do not emit // executable shell text when the invoking Windows shell is unknown. - plan.manual.push('Windows: add Affitor in Codex MCP settings using command "node" and the exact argument in entry.args. No shell command is generated.'); + plan.manual.push('Windows: add Affitor in Codex MCP settings using command "node" and the exact arguments in entry.args plus codex when using per-client agent keys. No shell command is generated.'); } else { - plan.manual.push(`codex mcp add affitor -- node '${launcher.replaceAll("'", "'\"'\"'")}'`); + plan.manual.push(`codex mcp add affitor -- node '${launcher.replaceAll("'", "'\"'\"'")}'${options.agentKeys ? ' codex' : ''}`); } // Installing a shared skill and launcher needs the same explicit consent. add(".agents/skills/affitor/SKILL.md", AGENT_SKILL); @@ -126,8 +160,8 @@ export function planSetup(cwd: string, clients: AgentClient[]): SetupPlan { // The final matching rule wins at each directory level. Existing negations // must not cancel protection; a child guard also outranks root ignore rules. for (const [rel, entries] of [ - [".gitignore", ["/.affitor/.env", "/.affitor/backups/", "/.affitor/credentials.json"]], - [".affitor/.gitignore", ["/.env", "/backups/", "/credentials.json"]], + [".gitignore", ["/.affitor/.env", "/.affitor/backups/", "/.affitor/credentials.json", "/.affitor/agents/"]], + [".affitor/.gitignore", ["/.env", "/backups/", "/credentials.json", "/agents/"]], ] as const) { const raw = readTarget(project, join(project, rel)); const prior = raw ?? ""; @@ -144,10 +178,37 @@ export function planSetup(cwd: string, clients: AgentClient[]): SetupPlan { // guard with backups on rollback even if the root .gitignore is restored. plan.actions.unshift(plan.actions.pop()!); } + if (options.agentKeys && clients.length) { + const guard = ".affitor/agents/.gitignore", raw = readTarget(project, join(project, guard)); + if (raw !== null && !["*", "*\n", "*\r\n"].includes(raw)) throw new Error("Unsafe setup agent directory guard."); + add(guard, raw ?? "*\n", raw); + const action = plan.actions.pop()!; + plan.actions.splice(1, 0, action); + } snapshots.set(plan, before); return plan; } +/** These APIs keep credential contents out of the public plan metadata. */ +export function readPlannedAgentCredential(plan: SetupPlan, client: AgentClient): StoredAgentCredential | null { + const path = plan.agentKeys?.find(item => item.client === client)?.path; + if (!path) throw new Error("Setup plan does not include this agent credential."); + const raw = snapshots.get(plan)?.get(path); + return raw ? parseStoredAgentCredential(raw, client) : null; +} + +export function stageAgentCredential(plan: SetupPlan, client: AgentClient, record: StoredAgentCredential, minted: boolean) { + const item = plan.agentKeys?.find(item => item.client === client); + if (!item || !parseStoredAgentCredential(JSON.stringify(record), client)) throw new Error("Setup agent credential is invalid."); + const action = plan.actions.find(action => action.path === item.path)!; + const original = snapshots.get(plan)!.get(item.path)!; + action.content = minted ? JSON.stringify(record, null, 2) + "\n" : original!; + action.operation = original === action.content ? "unchanged" : original === null ? "create" : "update"; + action.backup = original !== null && original !== action.content ? join(plan.project, ".affitor/backups", `.affitor/agents/${client}.json.affitor.bak`) : null; + if (action.backup) { safePath(plan.project, action.backup); if (existsSync(action.backup)) readTarget(plan.project, action.backup); } + item.id = record.id; item.state = minted ? "minted" : "reused"; +} + function atomicWrite(project: string, path: string, content: string) { safePath(project, path); const guard = join(project, ".affitor/backups/.gitignore"); @@ -171,9 +232,11 @@ function atomicWrite(project: string, path: string, content: string) { /** Local, synchronous transaction. It detects ordinary concurrent edits; it is not a local sandbox. */ export function applySetup(plan: SetupPlan, beforeWrite?: (action: FileAction) => void) { const before = snapshots.get(plan); + if (plan.agentKeys?.some(item => item.state === "pending")) throw new Error("Setup agent credentials have not been prepared."); if (!before) throw new Error("Generate a fresh setup plan before applying it."); const applied: FileAction[] = []; let backupCreated = false; + let privateWritten = false; try { for (const action of plan.actions) { if (readTarget(plan.project, action.path) !== before.get(action.path)) throw new Error("A setup file changed after planning. Run setup again."); @@ -190,6 +253,7 @@ export function applySetup(plan: SetupPlan, beforeWrite?: (action: FileAction) = writeFileSync(action.backup, before.get(action.path)!, { flag: "wx", mode: 0o600 }); } applied.push(action); + if (plan.agentKeys?.some(item => item.path === action.path)) privateWritten = true; atomicWrite(plan.project, action.path, action.content); } } catch { @@ -199,7 +263,7 @@ export function applySetup(plan: SetupPlan, beforeWrite?: (action: FileAction) = try { const original = before.get(action.path)!; const current = readTarget(plan.project, action.path); - if (backupCreated && action.path === join(plan.project, ".affitor/backups/.gitignore") && original === null && current === action.content) continue; + if ((backupCreated || privateWritten) && [join(plan.project, ".affitor/backups/.gitignore"), join(plan.project, ".affitor/agents/.gitignore")].includes(action.path) && original === null && current === action.content) continue; if (current === original) continue; if (current !== action.content) { concurrent = true; continue; } if (original === null) rmSync(action.path); @@ -210,23 +274,55 @@ export function applySetup(plan: SetupPlan, beforeWrite?: (action: FileAction) = } } -function credentialWarnings(project: string): string[] { +/** No marker means Git really has nowhere to discover a worktree (GIT_* overrides are dropped). */ +function hasGitAncestor(project: string): boolean | null { + let current = project; + for (;;) { + try { lstatSync(join(current, ".git")); return true; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return null; } + const parent = dirname(current); + if (parent === current) return false; + current = parent; + } +} + +export function credentialWarnings(project: string, relativePath = ".affitor/.env"): string[] { const env = agentChildEnvironment(process.env, ""); delete env.AFFITOR_API_KEY; - const checked = spawnSync("git", ["-C", project, "ls-files", "--error-unmatch", "--", ".affitor/.env"], { env, encoding: "utf8", timeout: 1500, windowsHide: true }); - return checked.status === 0 ? ["credential_tracked"] : []; + // Fixed locale permits only Git's exact no-repository result; other failures stay unknown. + env.LC_ALL = "C"; + const run = (args: string[]) => spawnSync("git", ["-C", project, ...args], { env, encoding: "utf8", timeout: 1500, windowsHide: true }); + const discovery = run(["rev-parse", "--is-inside-work-tree"]); + const failed = ["credential_check_failed"]; + if (discovery.error || discovery.signal) return failed; + if (discovery.status === 128) { + const noRepository = /^fatal: not a git repository \(or any of the parent directories\): \.git\r?\n?$/.test(discovery.stderr ?? ""); + return noRepository && discovery.stdout === "" && hasGitAncestor(project) === false ? [] : failed; + } + if (discovery.status !== 0 || discovery.stdout.trim() !== "true") return failed; + const checked = run(["ls-files", "--error-unmatch", "--", relativePath]); + if (checked.error || checked.signal) return failed; + if (checked.status === 0) return ["credential_tracked"]; + return checked.status === 1 ? [] : failed; } export function doctor(cwd: string, clients: AgentClient[]) { return clients.map(client => { try { - const plan = planSetup(cwd, [client]); + const root = realpathSync(resolve(cwd)); + const privatePath = join(root, `.affitor/agents/${client}.json`); + const rawConfig = client === "codex" ? null : readTarget(root, join(root, configFile(client))); + const entry = rawConfig ? JSON.parse(rawConfig)?.mcpServers?.affitor : null; + const perClient = existsSync(privatePath) || (Array.isArray(entry?.args) && entry.args.includes(client)); + const plan = planSetup(cwd, [client], { agentKeys: perClient }); + const saved = perClient ? readPlannedAgentCredential(plan, client) : null; + if (saved) stageAgentCredential(plan, client, saved, false); const keyPath = join(plan.project, ".affitor/.env"); - const credential = parseProgramCredential(process.env.AFFITOR_API_KEY, () => { + const credential = perClient ? saved?.api_key : parseProgramCredential(process.env.AFFITOR_API_KEY, () => { const stat = lstatSync(keyPath); return stat.isFile() && !stat.isSymbolicLink() ? readFileSync(keyPath, "utf8") : null; }); - return { client, warnings: credentialWarnings(plan.project), notes: plan.notes, status: client === "codex" ? "manual" : plan.actions.some(a => a.operation === "create") ? "missing" : plan.actions.some(a => a.operation === "update") ? "stale" : "aligned", credential: credential ? "present_unverified" : "missing", liveSession: "not_checked", manual: plan.manual }; + return { client, warnings: credentialWarnings(plan.project, perClient ? `.affitor/agents/${client}.json` : ".affitor/.env"), notes: plan.notes, status: client === "codex" ? "manual" : plan.actions.some(a => a.operation === "create") ? "missing" : plan.actions.some(a => a.operation === "update") ? "stale" : "aligned", credential: credential ? "present_unverified" : "missing", liveSession: "not_checked", manual: plan.manual }; } catch { return { client, warnings: [], notes: [], status: "invalid", credential: "not_checked", liveSession: "not_checked", manual: [] }; } }); } diff --git a/packages/cli/src/lib/program-agent-client.ts b/packages/cli/src/lib/program-agent-client.ts new file mode 100644 index 0000000..63c51cc --- /dev/null +++ b/packages/cli/src/lib/program-agent-client.ts @@ -0,0 +1,111 @@ +import { applySetup, credentialWarnings, readPlannedAgentCredential, stageAgentCredential, type AgentClient, type SetupPlan } from "./agent-setup.js"; +import type { StoredAgentCredential } from "./agent-launcher.js"; + +export class AgentSetupError extends Error { + constructor(message: string, public readonly cleanupIds: number[] = [], public readonly uncertainMint = false) { super(message); } +} +const positive = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0; +function object(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +export function agentApiOrigin(value: string): string { + try { + const url = new URL(value); + if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || + (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))) throw new Error(); + return url.origin; + } catch { throw new AgentSetupError("Agent setup requires an HTTPS API origin (loopback HTTP is supported for local testing)."); } +} +interface AgentRow { id: number; client: string; key_hint: string; status: string; revoked_at?: string | null } + +/** User JWT only. Mint is never retried: a lost response may already have created a row. */ +export class ProgramAgentClient { + readonly origin: string; + constructor(private token: string, origin: string) { + this.origin = agentApiOrigin(origin); + if (!token || /^aff_|^agt_/.test(token)) throw new AgentSetupError("Sign in with affitor login before creating agent keys."); + } + private async request(path: string, method = "GET", body?: unknown): Promise { + try { + const response = await fetch(this.origin + path, { + method, redirect: "error", signal: AbortSignal.timeout(15_000), + headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (!response.ok) throw new AgentSetupError(`Agent key request failed (HTTP ${response.status}). Check login, Owner access and the CMS release.`, [], method === "POST" && response.status >= 500); + try { return await response.json(); } + catch { throw new AgentSetupError("Agent key response could not be read.", [], method === "POST"); } + } catch (error) { + if (error instanceof AgentSetupError) throw error; + throw new AgentSetupError("Agent key request did not complete. Check the API origin and connection.", [], method === "POST"); + } + } + async programs(): Promise<{ id: number; name: string }[]> { + const result = await this.request("/api/v1/cli/programs"); + const rows = object(result) ? result.data : null; + if (!Array.isArray(rows) || rows.some(row => !object(row) || !positive(row.id) || typeof row.name !== "string")) throw new AgentSetupError("Program list response is invalid."); + return rows.map(row => ({ id: row.id, name: row.name.slice(0, 160).replace(/[\x00-\x1f\x7f]/g, "") })); + } + async list(programId: number): Promise { + const rows: AgentRow[] = []; let before: number | null = null; + for (let page = 0; page < 100; page++) { + const result = await this.request(`/api/v1/programs/${programId}/agents${before ? `?before=${before}` : ""}`); + if (!object(result) || !Array.isArray(result.data) || result.data.length > 100 || !object(result.meta)) throw new AgentSetupError("Agent key list response is invalid."); + for (const row of result.data) { + if (!object(row) || !positive(row.id) || typeof row.client !== "string" || typeof row.key_hint !== "string" || typeof row.status !== "string" || !["waiting", "connected", "verified", "failing", "revoked"].includes(row.status)) throw new AgentSetupError("Agent key list response is invalid."); + rows.push({ id: row.id, client: row.client, key_hint: row.key_hint, status: row.status, revoked_at: typeof row.revoked_at === "string" ? row.revoked_at : null }); + } + const next = result.meta.next_cursor; + if (next === null) return rows; + if (!positive(next) || (before !== null && next >= before)) throw new AgentSetupError("Agent key pagination is invalid."); + before = next; + } + throw new AgentSetupError("Agent key list is too large. Review credentials in the dashboard before retrying."); + } + async mint(programId: number, client: AgentClient): Promise { + const result = await this.request(`/api/v1/programs/${programId}/agents`, "POST", { name: client, client }); + const row = object(result) && object(result.data) ? result.data : null; + if (!row || !positive(row.id) || row.client !== client || typeof row.api_key !== "string" || !/^aff_ag_[A-Za-z0-9_-]{43}$/.test(row.api_key)) { + throw new AgentSetupError("Agent mint response is invalid. Review Waiting agents in the dashboard.", row && positive(row.id) ? [row.id] : [], !(row && positive(row.id))); + } + const created = new Date().toISOString(); + return { version: 1, id: row.id, program_id: programId, client, api_url: this.origin, created_at: created, + credential_note: `Signed-in account; program ${programId}; ${client}; readiness/status/synthetic-test/click/test-only lead; created ${created}; not verified. Never commit this file.`, + api_key: row.api_key }; + } + async revoke(programId: number, id: number): Promise { + const result = await this.request(`/api/v1/programs/${programId}/agents/${id}`, "DELETE"); + if (!object(result) || !object(result.data) || result.data.revoked !== true) throw new AgentSetupError("Agent revocation could not be confirmed."); + } +} + +/** Prepare every client before local writes; compensate only keys minted by this run. */ +export async function installProgramAgents(plan: SetupPlan, programId: number, api: ProgramAgentClient, apply: typeof applySetup = applySetup) { + if (!positive(programId) || !plan.agentKeys || plan.agentKeys.length !== plan.clients.length) throw new AgentSetupError("Generate a fresh agent-key setup plan."); + const existing = plan.clients.map(client => { + const warnings = credentialWarnings(plan.project, `.affitor/agents/${client}.json`); + if (warnings.includes("credential_tracked")) throw new AgentSetupError("A private agent credential is tracked by Git. Rotate it and resolve tracking/history before setup."); + if (warnings.length) throw new AgentSetupError("Git credential tracking could not be checked. Resolve Git access and retry; no agent key was minted or files written."); + const record = readPlannedAgentCredential(plan, client); + if (record && (record.program_id !== programId || record.api_url !== api.origin)) throw new AgentSetupError("A selected agent belongs to another program or API origin. Revoke and remove that private credential before switching."); + return { client, record }; + }); + // Only owner list reads: never call readiness/status to pretend a client connected. + const rows = await api.list(programId), minted: number[] = []; + try { + for (const { client, record } of existing) { + const active = record && rows.some(row => row.id === record.id && row.client === client && row.status !== "revoked" && !row.revoked_at && row.key_hint === `aff_ag_…${record.api_key.slice(-4)}`); + const credential = active ? record : await api.mint(programId, client); + if (!active) minted.push(credential.id); + stageAgentCredential(plan, client, credential, !active); + } + apply(plan); + return plan.agentKeys.map(({ client, path, id, state }) => ({ client, path, id, state })); + } catch (error) { + const known = [...new Set([...minted, ...(error instanceof AgentSetupError ? error.cleanupIds : [])])]; + const failed: number[] = []; + for (const id of known) { try { await api.revoke(programId, id); } catch { failed.push(id); } } + const uncertain = error instanceof AgentSetupError && error.uncertainMint; + throw new AgentSetupError(uncertain || failed.length + ? "Setup did not finish. Review Waiting agents in the dashboard and revoke unused credentials before retrying. Local files/private backups may need recovery." + : "Setup did not finish; newly minted credentials were revoked. Check local files/private backups before retrying.", failed, uncertain); + } +} diff --git a/packages/cli/tests/agent-setup-windows.test.ts b/packages/cli/tests/agent-setup-windows.test.ts index fdf305d..7043bd8 100644 --- a/packages/cli/tests/agent-setup-windows.test.ts +++ b/packages/cli/tests/agent-setup-windows.test.ts @@ -39,12 +39,55 @@ describe.skipIf(process.platform !== "win32")("Windows generated launcher smoke" expect(launcher.status, launcher.stderr + launcher.stdout).toBe(0); expect(JSON.parse(launcher.stdout)).toEqual({ args: ["--registry=https://registry.npmjs.org", "-y", "@affitor/mcp@0.2.2"], cwd: project, key: "fake_windows_key", write: "0", noCwd: "1", unsafe: [], overrides: ["AFFITOR_MCP_WRITE"] }); const doctor = spawnSync(process.execPath, [cli, "doctor", "--client", "claude_code", "--json"], { cwd: project, env, encoding: "utf8" }); - expect(doctor.status, doctor.stderr + doctor.stdout).toBe(0); + // This fixture deliberately removes Git from PATH: unknown tracking is a warning. + expect(doctor.status, doctor.stderr + doctor.stdout).toBe(1); + expect(JSON.parse(doctor.stdout).clients[0].warnings).toEqual(["credential_check_failed"]); expect(JSON.parse(doctor.stdout).clients[0].credential).toBe("present_unverified"); expect(existsSync(join(project, ".claude.json"))).toBe(false); expect(readFileSync(join(project, ".mcp.json"), "utf8")).not.toContain("fake_windows_key"); } finally { rmSync(fixture, { recursive: true, force: true }); } }); + it("uses real Git to distinguish untracked and tracked credentials", () => { + const project = realpathSync(mkdtempSync(join(tmpdir(), "affitor-windows-git-"))); + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !["claudecode", "codex", "codex_thread_id", "cursor_agent"].includes(key.toLowerCase()))); + const cli = resolve("dist/cli.js"); + try { + expect(spawnSync("git", ["init", "-q", project]).status).toBe(0); + const setup = spawnSync(process.execPath, [cli, "agent", "setup", "--client", "claude_code", "--yes", "--json"], { cwd: project, env, encoding: "utf8" }); + expect(setup.status, setup.stderr + setup.stdout).toBe(0); + writeFileSync(join(project, ".affitor/.env"), "AFFITOR_API_KEY=fake_windows_key\r\n"); + const untracked = spawnSync(process.execPath, [cli, "doctor", "--client", "claude_code", "--json"], { cwd: project, env, encoding: "utf8" }); + expect(untracked.status, untracked.stderr + untracked.stdout).toBe(0); + expect(JSON.parse(untracked.stdout).clients[0].warnings).toEqual([]); + expect(spawnSync("git", ["-C", project, "add", "-f", ".affitor/.env"]).status).toBe(0); + const tracked = spawnSync(process.execPath, [cli, "doctor", "--client", "claude_code", "--json"], { cwd: project, env, encoding: "utf8" }); + expect(tracked.status, tracked.stderr + tracked.stdout).toBe(1); + expect(JSON.parse(tracked.stdout).clients[0].warnings).toEqual(["credential_tracked"]); + } finally { rmSync(project, { recursive: true, force: true }); } + }); + it("uses a selected private agent key instead of an inherited live key", () => { + const fixture = realpathSync(mkdtempSync(join(tmpdir(), "affitor-windows-agent-key-"))); + const project = join(fixture, "project & (spaces) %NO_EXPAND%"), bin = join(project, "bin"); + mkdirSync(bin, { recursive: true }); + const env: NodeJS.ProcessEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !["path", "claudecode", "codex", "codex_thread_id", "cursor_agent", "affitor_api_key", "affitor_api_url"].includes(key.toLowerCase()))); + env.PATH = bin; + try { + const setup = spawnSync(process.execPath, [resolve("dist/cli.js"), "agent", "setup", "--client", "claude_code", "--yes", "--json"], { cwd: project, env, encoding: "utf8" }); + expect(setup.status, setup.stderr).toBe(0); + mkdirSync(join(project, ".affitor/agents")); + const key = "aff_ag_" + "x".repeat(43); + writeFileSync(join(project, ".affitor/agents/claude_code.json"), JSON.stringify({ version: 1, id: 1, program_id: 10, client: "claude_code", api_url: "https://api.affitor.com", api_key: key })); + writeFileSync(join(bin, "npx-fixture.cjs"), 'process.stdout.write(JSON.stringify({key:process.env.AFFITOR_API_KEY,url:process.env.AFFITOR_API_URL,write:process.env.AFFITOR_MCP_WRITE}));'); + writeFileSync(join(bin, "npx.cmd"), `@"${process.execPath}" "%~dp0npx-fixture.cjs" %*\r\n`); + env.AFFITOR_API_KEY = "fake_inherited_live_key"; env.Affitor_Api_Url = "https://wrong.invalid"; + const result = spawnSync(process.execPath, [join(project, ".affitor/agent-mcp.cjs"), "claude_code"], { cwd: fixture, env, encoding: "utf8", timeout: 15000 }); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ key, url: "https://api.affitor.com", write: "0" }); + rmSync(join(project, ".affitor/agents/claude_code.json")); + const missing = spawnSync(process.execPath, [join(project, ".affitor/agent-mcp.cjs"), "claude_code"], { env, encoding: "utf8", timeout: 15000 }); + expect(missing.status).toBe(1); expect(missing.stdout).toBe(""); + } finally { rmSync(fixture, { recursive: true, force: true }); } + }); it("refuses to print shell commands for Codex on Windows, including metacharacter paths", () => { const fixture = realpathSync(mkdtempSync(join(tmpdir(), "affitor-windows-codex-"))); const project = join(fixture, "project & echo INJECTED & rem (spaces) %NO_EXPAND% !EXPAND! 'quote'"); diff --git a/packages/cli/tests/program-agent-client.test.ts b/packages/cli/tests/program-agent-client.test.ts new file mode 100644 index 0000000..2647360 --- /dev/null +++ b/packages/cli/tests/program-agent-client.test.ts @@ -0,0 +1,238 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createServer, type Server } from "node:http"; +import { spawn, spawnSync } from "node:child_process"; +import { chmodSync, existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { applySetup, doctor, planSetup } from "../src/lib/agent-setup.js"; +import { AGENT_LAUNCHER, type StoredAgentCredential } from "../src/lib/agent-launcher.js"; +import { AgentSetupError, ProgramAgentClient, agentApiOrigin, installProgramAgents } from "../src/lib/program-agent-client.js"; + +const directories: string[] = [], servers: Server[] = []; +const cleanEnv = { ...process.env, CLAUDECODE: "", CODEX: "", CODEX_THREAD_ID: "", CURSOR_AGENT: "", AFFITOR_API_KEY: "fake_inherited_live_key" }; +const key = (id: number) => "aff_ag_" + String(id).padStart(43, "x"); +function project() { const dir = realpathSync(mkdtempSync(join(tmpdir(), "agent-mint-"))); directories.push(dir); return dir; } +function put(root: string, file: string, content: string) { const path = join(root, file); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content, { mode: 0o600 }); } +async function fixture() { + const requests: { method: string; path: string; auth: string | undefined; body: unknown }[] = []; + const rows: { id: number; client: string; key_hint: string; status: string; revoked_at?: string | null }[] = []; + const behavior = { failMintAt: 0, loseMint: false, failRevoke: false, invalidMint: false, paginate: false, redirectMint: false }; + let mintCount = 0; + const server = createServer(async (req, res) => { + let raw = ""; for await (const chunk of req) raw += chunk; + const body = raw ? JSON.parse(raw) : undefined; + requests.push({ method: req.method!, path: req.url!, auth: req.headers.authorization, body }); + res.setHeader("Content-Type", "application/json"); + if (req.method === "GET") { + if (req.url === "/api/v1/cli/programs") return res.end(JSON.stringify({ data: [{ id: 10, name: "Fixture" }] })); + const before = new URL(req.url!, "http://fixture.test").searchParams.get("before"); + const ordered = rows.filter(row => !before || row.id < Number(before)).sort((a, b) => b.id - a.id); + const data = behavior.paginate ? ordered.slice(0, 100) : ordered; + return res.end(JSON.stringify({ data, meta: { next_cursor: data.length < ordered.length ? data.at(-1)!.id : null } })); + } + if (req.method === "POST") { + mintCount++; + if (behavior.redirectMint) { res.statusCode = 307; res.setHeader("Location", "/mint-redirect-target"); return res.end(); } + if (behavior.failMintAt === mintCount) { res.statusCode = 502; return res.end(JSON.stringify({ error: "fake_inherited_live_key fixture_user_jwt server-private-error" })); } + const id = 200 + mintCount; + const row = { id, client: body.client, key_hint: `aff_ag_…${key(id).slice(-4)}`, status: "waiting" }; rows.push(row); + if (behavior.loseMint) return req.socket.destroy(); + return res.end(JSON.stringify({ data: { ...row, api_key: behavior.invalidMint ? "invalid_key" : key(id) } })); + } + if (behavior.failRevoke) { res.statusCode = 500; return res.end('{"error":"server-private-error"}'); } + const row = rows.find(row => row.id === Number(req.url!.split("/").at(-1))); + if (row) row.status = "revoked"; + res.end(JSON.stringify({ data: { revoked: true } })); + }); + servers.push(server); await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + return { origin, api: new ProgramAgentClient("fixture_user_jwt", origin), requests, rows, behavior }; +} +function stored(origin: string, client = "claude_code", id = 1): StoredAgentCredential { + return { version: 1, id, program_id: 10, client, api_url: origin, created_at: "2026-10-18T00:00:00Z", credential_note: "Fake test credential; not verified.", api_key: key(id) }; +} +function run(root: string, args: string[]) { + return new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveRun, reject) => { + const child = spawn(process.execPath, [resolve("dist/cli.js"), ...args], { cwd: root, env: { ...cleanEnv, HOME: root, USERPROFILE: root }, windowsHide: true }); + let stdout = "", stderr = ""; + child.stdout.on("data", chunk => stdout += chunk); child.stderr.on("data", chunk => stderr += chunk); + const timer = setTimeout(() => { child.kill(); reject(new Error("Fixture CLI timed out")); }, 10_000); + child.on("error", reject); child.on("close", status => { clearTimeout(timer); resolveRun({ status, stdout, stderr }); }); + }); +} +afterEach(async () => { + vi.unstubAllEnvs(); + for (const server of servers.splice(0)) { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); } + for (const root of directories.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("authenticated per-client setup against local HTTP CMS", () => { + it("mints independently, preserves app live key, uses only user JWT lifecycle and reuses keys without tool calls", async () => { + const root = project(), cms = await fixture(); + put(root, ".affitor/.env", "AFFITOR_API_KEY=fake_application_key\n"); + const result = await installProgramAgents(planSetup(root, ["claude_code", "cursor"], { agentKeys: true }), 10, cms.api); + expect(result.map(row => row.state)).toEqual(["minted", "minted"]); + const claude = JSON.parse(readFileSync(join(root, ".affitor/agents/claude_code.json"), "utf8")); + const cursor = JSON.parse(readFileSync(join(root, ".affitor/agents/cursor.json"), "utf8")); + expect(claude.id).not.toBe(cursor.id); expect(claude.api_key).not.toBe(cursor.api_key); + expect(readFileSync(join(root, ".affitor/.env"), "utf8")).toBe("AFFITOR_API_KEY=fake_application_key\n"); + if (process.platform !== "win32") expect(statSync(join(root, ".affitor/agents/claude_code.json")).mode & 0o777).toBe(0o600); + const config = readFileSync(join(root, ".mcp.json"), "utf8"); expect(config).not.toContain(claude.api_key); + expect(JSON.parse(config).mcpServers.affitor.args).toEqual([join(root, ".affitor/agent-mcp.cjs"), "claude_code"]); + expect(cms.requests.every(req => req.auth === "Bearer fixture_user_jwt")).toBe(true); + expect(cms.requests.filter(req => req.method === "POST").map(req => req.body)).toEqual([{ name: "claude_code", client: "claude_code" }, { name: "cursor", client: "cursor" }]); + expect(() => planSetup(root, ["claude_code"])).toThrow("Pass --program-id"); + expect(doctor(root, ["claude_code"])[0]).toMatchObject({ status: "aligned", credential: "present_unverified", liveSession: "not_checked" }); + const before = readFileSync(join(root, ".affitor/agents/claude_code.json")); + const again = await installProgramAgents(planSetup(root, ["claude_code", "cursor"], { agentKeys: true }), 10, cms.api); + expect(again.map(row => row.state)).toEqual(["reused", "reused"]); expect(cms.requests.filter(req => req.method === "POST")).toHaveLength(2); + expect(readFileSync(join(root, ".affitor/agents/claude_code.json"))).toEqual(before); + expect(cms.requests.some(req => /readiness|status|test-event/.test(req.path))).toBe(false); + expect(spawnSync("git", ["init", "-q", root]).status).toBe(0); + expect(spawnSync("git", ["-C", root, "check-ignore", ".affitor/agents/claude_code.json"]).status).toBe(0); + }); + it("replaces a revoked selected key without changing another client", async () => { + const root = project(), cms = await fixture(); + await installProgramAgents(planSetup(root, ["claude_code", "cursor"], { agentKeys: true }), 10, cms.api); + const cursor = readFileSync(join(root, ".affitor/agents/cursor.json")); cms.rows[0].status = "revoked"; + await installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api); + expect(cms.rows).toHaveLength(3); expect(cms.rows[1].status).toBe("waiting"); + expect(readFileSync(join(root, ".affitor/agents/cursor.json"))).toEqual(cursor); + }); + it("finds reusable old credentials across pagination", async () => { + const root = project(), cms = await fixture(); cms.behavior.paginate = true; + for (let id = 1; id <= 103; id++) cms.rows.push({ id, client: "claude_code", key_hint: `aff_ag_…${key(id).slice(-4)}`, status: "waiting" }); + put(root, ".affitor/agents/claude_code.json", JSON.stringify(stored(cms.origin))); + const result = await installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api); + expect(result[0].state).toBe("reused"); expect(cms.requests.map(req => req.path)).toEqual(["/api/v1/programs/10/agents", "/api/v1/programs/10/agents?before=4"]); + }); + it.each([false, true])("compensates partial mint; failed revoke is explicit (%s)", async failRevoke => { + const root = project(), cms = await fixture(); cms.behavior.failMintAt = 2; cms.behavior.failRevoke = failRevoke; + const error = await installProgramAgents(planSetup(root, ["claude_code", "cursor"], { agentKeys: true }), 10, cms.api).catch(error => error); + expect(error).toBeInstanceOf(AgentSetupError); expect(error.cleanupIds).toEqual(failRevoke ? [201] : []); expect(error.uncertainMint).toBe(true); + expect(cms.requests.filter(req => req.method === "POST")).toHaveLength(2); expect(cms.requests.filter(req => req.method === "DELETE")).toHaveLength(1); + expect(error.message).not.toContain("server-private-error"); expect(error.message).not.toContain("fixture_user_jwt"); expect(existsSync(join(root, ".mcp.json"))).toBe(false); + }); + it("does not retry a lost mint response and reports its unknown row", async () => { + const root = project(), cms = await fixture(); cms.behavior.loseMint = true; + await expect(installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api)).rejects.toMatchObject({ uncertainMint: true }); + expect(cms.rows).toHaveLength(1); expect(cms.requests.filter(req => req.method === "POST")).toHaveLength(1); + expect(existsSync(join(root, ".affitor/agents/claude_code.json"))).toBe(false); + }); + it("does not follow a mint redirect or resend a user JWT", async () => { + const root = project(), cms = await fixture(); cms.behavior.redirectMint = true; + await expect(installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api)).rejects.toMatchObject({ uncertainMint: true }); + expect(cms.requests.filter(req => req.method === "POST")).toHaveLength(1); + expect(cms.requests.some(req => req.path === "/mint-redirect-target")).toBe(false); + }); + it("revokes an identifiable row from an invalid mint payload", async () => { + const root = project(), cms = await fixture(); cms.behavior.invalidMint = true; + await expect(installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api)).rejects.toMatchObject({ cleanupIds: [], uncertainMint: false }); + expect(cms.rows[0].status).toBe("revoked"); + }); + it("rolls back private/config writes and revokes only this run's keys after local failure", async () => { + const root = project(), cms = await fixture(); put(root, ".mcp.json", '{"keep":true}'); + const apply = (plan: ReturnType) => applySetup(plan, action => { if (action.path.endsWith(".mcp.json")) throw new Error("fake fs failure"); }); + await expect(installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api, apply)).rejects.toMatchObject({ cleanupIds: [], uncertainMint: false }); + expect(readFileSync(join(root, ".mcp.json"), "utf8")).toBe('{"keep":true}'); expect(existsSync(join(root, ".affitor/agents/claude_code.json"))).toBe(false); + expect(cms.rows[0].status).toBe("revoked"); + }); + it("preserves a concurrent private-file edit and its ignore guards during rollback", async () => { + const root = project(), cms = await fixture(); expect(spawnSync("git", ["init", "-q", root]).status).toBe(0); + const apply = (plan: ReturnType) => applySetup(plan, action => { + if (action.path.endsWith(".mcp.json")) { put(root, ".affitor/agents/claude_code.json", "fake_newer_private_edit"); throw new Error(); } + }); + await expect(installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api, apply)).rejects.toBeInstanceOf(AgentSetupError); + expect(readFileSync(join(root, ".affitor/agents/claude_code.json"), "utf8")).toBe("fake_newer_private_edit"); + expect(spawnSync("git", ["-C", root, "check-ignore", ".affitor/agents/claude_code.json"]).status).toBe(0); + expect(cms.rows[0].status).toBe("revoked"); + }); + it.each(["program", "origin", "tracked"])("rejects %s credential conflict before any HTTP", async conflict => { + const root = project(), cms = await fixture(), record = stored(cms.origin); + if (conflict === "program") record.program_id = 20; + if (conflict === "origin") record.api_url = "https://other.example.test"; + put(root, ".affitor/agents/claude_code.json", JSON.stringify(record)); + if (conflict === "tracked") { expect(spawnSync("git", ["init", "-q", root]).status).toBe(0); expect(spawnSync("git", ["-C", root, "add", ".affitor/agents/claude_code.json"]).status).toBe(0); } + await expect(installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api)).rejects.toBeInstanceOf(AgentSetupError); + expect(cms.requests).toHaveLength(0); expect(JSON.parse(readFileSync(join(root, ".affitor/agents/claude_code.json"), "utf8"))).toEqual(record); + }); + it.skipIf(process.platform === "win32").each(["timeout", "spawn_error", "exit128", "exit2"])("blocks a %s Git check before HTTP or tracked credential writes", async failure => { + const root = project(), cms = await fixture(), original = JSON.stringify(stored(cms.origin)); + put(root, ".affitor/agents/claude_code.json", original); + expect(spawnSync("git", ["init", "-q", root]).status).toBe(0); + expect(spawnSync("git", ["-C", root, "add", ".affitor/agents/claude_code.json"]).status).toBe(0); + const bin = join(root, "test-bin"); + const action = failure === "timeout" ? "setTimeout(()=>process.exit(0),10000)" : `process.exit(${failure === "exit128" ? 128 : 2})`; + put(root, "test-bin/git", failure === "spawn_error" ? "#!/nonexistent/fixture/git-interpreter\n" : `#!${process.execPath}\nif(process.argv.includes('rev-parse'))process.stdout.write('true\\n');else{${action};}\n`); + chmodSync(join(bin, "git"), 0o700); vi.stubEnv("PATH", bin); + const result = await installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api).catch(error => error); + expect({ blocked: result instanceof AgentSetupError, requests: cms.requests.length, bytesUnchanged: readFileSync(join(root, ".affitor/agents/claude_code.json"), "utf8") === original }).toEqual({ blocked: true, requests: 0, bytesUnchanged: true }); + expect(doctor(root, ["claude_code"])[0].warnings).toContain("credential_check_failed"); + }); + it("doctor warns about tracked per-client secrets without touching the index", async () => { + const root = project(), cms = await fixture(); await installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api); + expect(spawnSync("git", ["init", "-q", root]).status).toBe(0); + expect(spawnSync("git", ["-C", root, "add", "-f", ".affitor/agents/claude_code.json"]).status).toBe(0); + const index = spawnSync("git", ["-C", root, "ls-files", "--stage"], { encoding: "utf8" }).stdout; + expect(doctor(root, ["claude_code"])[0]).toMatchObject({ warnings: ["credential_tracked"], liveSession: "not_checked" }); + expect(spawnSync("git", ["-C", root, "ls-files", "--stage"], { encoding: "utf8" }).stdout).toBe(index); + }); + it.each(["malformed", "hardlink", "backup"])("rejects %s private file before HTTP", async kind => { + const root = project(), external = project(), cms = await fixture(); + put(root, ".affitor/agents/claude_code.json", kind === "malformed" ? "{fake_private_invalid" : JSON.stringify(stored(cms.origin))); + if (kind === "hardlink") linkSync(join(root, ".affitor/agents/claude_code.json"), join(external, "shared.json")); + if (kind === "backup") { const parent = ".affitor/backups/.affitor/agents"; mkdirSync(join(root, parent), { recursive: true }); put(external, "backup.json", "fake_backup"); symlinkSync(join(external, "backup.json"), join(root, parent, "claude_code.json.affitor.bak")); } + expect(() => planSetup(root, ["claude_code"], { agentKeys: true })).toThrow(); + expect(cms.requests).toHaveLength(0); + }); + it.skipIf(process.platform === "win32")("refuses private credentials with broad POSIX permissions", async () => { + const root = project(), cms = await fixture(); put(root, ".affitor/agents/claude_code.json", JSON.stringify(stored(cms.origin))); + chmodSync(join(root, ".affitor/agents/claude_code.json"), 0o644); + expect(() => planSetup(root, ["claude_code"], { agentKeys: true })).toThrow("owner-only"); expect(cms.requests).toHaveLength(0); + }); + it("rejects unsafe credential paths and pending direct apply before writes", async () => { + const root = project(), external = project(); mkdirSync(join(root, ".affitor")); + symlinkSync(external, join(root, ".affitor/agents")); + expect(() => planSetup(root, ["claude_code"], { agentKeys: true })).toThrow(); + const other = project(); expect(() => applySetup(planSetup(other, ["claude_code"], { agentKeys: true }))).toThrow("not been prepared"); + expect(existsSync(join(other, ".affitor"))).toBe(false); + }); +}); + +describe("built command and generated launcher", () => { + it("dry-run/no consent/missing login never mint or write, and never print credentials", async () => { + const root = project(), cms = await fixture(); put(root, ".affitor/config.json", '{"program_id":"10","api_key":"fake_legacy_key"}'); + for (const args of [["--dry-run"], [], ["--yes"]]) { + const result = await run(root, ["agent", "setup", "--client", "claude_code", "--json", "--api-url", cms.origin, ...args]); + expect(JSON.parse(result.stdout).status).toBe(args[0] === "--dry-run" ? "planned" : args[0] === "--yes" ? "needs_login" : "needs_confirmation"); + expect(result.stdout).not.toContain("fake_legacy_key"); expect(result.stdout).not.toContain("fake_inherited_live_key"); + } + expect(cms.requests).toHaveLength(0); expect(existsSync(join(root, ".mcp.json"))).toBe(false); + }); + it("uses saved login, reports only installed, and keeps secrets out of verbose JSON", async () => { + const root = project(), cms = await fixture(); + put(root, ".affitor/credentials.json", JSON.stringify({ token: "fixture_user_jwt", expires_at: "2099-01-01T00:00:00Z" })); + const result = await run(root, ["agent", "setup", "--client", "claude_code", "--program-id", "10", "--yes", "--json", "--verbose", "--api-url", cms.origin]); + expect(result.status, result.stderr).toBe(0); expect(JSON.parse(result.stdout).status).toBe("needs_next_step"); + expect(result.stdout).not.toContain("fixture_user_jwt"); expect(result.stderr).not.toContain("fixture_user_jwt"); expect(result.stdout).not.toContain(key(201)); + expect(cms.requests.every(req => req.auth === "Bearer fixture_user_jwt")).toBe(true); + }); + it.skipIf(process.platform === "win32")("launcher uses only its selected private credential, never inherited/application keys", async () => { + const root = project(), cms = await fixture(); await installProgramAgents(planSetup(root, ["claude_code", "cursor"], { agentKeys: true }), 10, cms.api); + put(root, "bin/npx", `#!${process.execPath}\nprocess.stdout.write(JSON.stringify({key:process.env.AFFITOR_API_KEY,url:process.env.AFFITOR_API_URL,write:process.env.AFFITOR_MCP_WRITE}));`); chmodSync(join(root, "bin/npx"), 0o700); + put(root, ".affitor/.env", "AFFITOR_API_KEY=fake_application_key\n"); + for (const [client, id] of [["claude_code", 201], ["cursor", 202]] as const) { + const result = spawnSync(process.execPath, [join(root, ".affitor/agent-mcp.cjs"), client], { env: { ...cleanEnv, PATH: join(root, "bin"), AFFITOR_MCP_WRITE: "1" }, encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); expect(JSON.parse(result.stdout)).toEqual({ key: key(id), url: cms.origin, write: "0" }); + } + rmSync(join(root, ".affitor/agents/claude_code.json")); + const missing = spawnSync(process.execPath, [join(root, ".affitor/agent-mcp.cjs"), "claude_code"], { env: cleanEnv, encoding: "utf8" }); + expect(missing.status).toBe(1); expect(missing.stdout).toBe(""); + }); + it.each(["http://example.test", "https://user:pass@example.test", "https://example.test/path", "https://example.test/?x=1"])("rejects unsafe API origin %s", value => { + expect(() => agentApiOrigin(value)).toThrow(AgentSetupError); + }); + it("still emits a standalone launcher without credential bytes", () => { + expect(AGENT_LAUNCHER).not.toContain("fixture_user_jwt"); expect(AGENT_LAUNCHER).not.toContain(key(201)); + }); +});