From 5639ed8c655d101156ef985fcc040925b2663c34 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sun, 18 Oct 2026 03:27:51 -0700 Subject: [PATCH 1/4] fix: document interrupted agent setup and Windows folder privacy --- packages/cli/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/cli/README.md b/packages/cli/README.md index ee785a3..a640813 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -49,6 +49,12 @@ 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. +On Windows, POSIX mode0600 does not establish private Windows access-control +permissions, and the CLI does not configure or audit those permissions. Keep the +project under your Windows user profile in a folder with restricted access. +Check inherited permissions before storing credentials or backups in shared +folders. The owner-only mode checks above apply to macOS and Linux. + 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. @@ -77,6 +83,13 @@ Waiting row. Review unused Waiting credentials in Settings → Agents and revoke 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. +An abrupt interruption, such as closing the terminal after minting, may also leave +a Waiting credential even when no error JSON was printed and no local credential +file exists. Before retrying, review newly created Waiting rows for the selected +client in Settings → Agents and revoke unused credentials from that attempt. +Keep credentials used by other agents. Then run a fresh dry-run and approve setup +again; a static `doctor` result cannot prove that a server credential was cleaned up. + A recovery skill is installed in `.claude/skills/affitor/` or `.cursor/skills/affitor/`. 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 From de568896b6076bae427e95298bdd54f69e3b1f74 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sun, 18 Oct 2026 03:41:26 -0700 Subject: [PATCH 2/4] fix: restrict agent setup JWTs to trusted API origins --- packages/cli/README.md | 12 ++++- packages/cli/src/lib/program-agent-client.ts | 16 ++++-- .../cli/tests/program-agent-client.test.ts | 52 ++++++++++++++++++- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index a640813..aff3ed1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -60,11 +60,19 @@ contain only `node`, an absolute `.affitor/agent-mcp.cjs` path and a client sele 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 +from that private record. 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. +Agent setup sends the signed-in user's JWT only to `https://api.affitor.com`; +other remote HTTPS origins and other Affitor subdomains are refused before any +request. For local development, set `AFFITOR_AGENT_DEV_API_ORIGIN` to the exact +HTTP(S) loopback origin and pass the same value through `--api-url` (for example, +`http://127.0.0.1:1337`). Only `localhost`, `127.0.0.1` and `[::1]` are supported; +the port must match. This variable cannot authorize an external host. Use a +development login for that local server. The launcher still reads only its +selected program credential, not the login JWT. + 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 `*` diff --git a/packages/cli/src/lib/program-agent-client.ts b/packages/cli/src/lib/program-agent-client.ts index 63c51cc..a6fbe0d 100644 --- a/packages/cli/src/lib/program-agent-client.ts +++ b/packages/cli/src/lib/program-agent-client.ts @@ -6,13 +6,19 @@ export class AgentSetupError extends Error { } 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); } +function rootOrigin(value: string): URL { + const url = new URL(value); + if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || !["https:", "http:"].includes(url.protocol)) throw new Error(); + return url; +} 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)."); } + const url = rootOrigin(value); + if (url.origin === "https://api.affitor.com") return url.origin; + const development = process.env.AFFITOR_AGENT_DEV_API_ORIGIN; + if (development && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) && rootOrigin(development).origin === url.origin) return url.origin; + throw new Error(); + } catch { throw new AgentSetupError("Agent setup sends login credentials only to https://api.affitor.com. Local development requires AFFITOR_AGENT_DEV_API_ORIGIN to match the exact loopback API origin."); } } interface AgentRow { id: number; client: string; key_hint: string; status: string; revoked_at?: string | null } diff --git a/packages/cli/tests/program-agent-client.test.ts b/packages/cli/tests/program-agent-client.test.ts index 2647360..4763e81 100644 --- a/packages/cli/tests/program-agent-client.test.ts +++ b/packages/cli/tests/program-agent-client.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, 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"; @@ -46,6 +46,7 @@ async function fixture() { }); 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}`; + vi.stubEnv("AFFITOR_AGENT_DEV_API_ORIGIN", origin); return { origin, api: new ProgramAgentClient("fixture_user_jwt", origin), requests, rows, behavior }; } function stored(origin: string, client = "claude_code", id = 1): StoredAgentCredential { @@ -53,7 +54,7 @@ function stored(origin: string, client = "claude_code", id = 1): StoredAgentCred } 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 }); + const child = spawn(process.execPath, [resolve("dist/cli.js"), ...args], { cwd: root, env: { ...cleanEnv, AFFITOR_AGENT_DEV_API_ORIGIN: process.env.AFFITOR_AGENT_DEV_API_ORIGIN ?? "", 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); @@ -62,10 +63,43 @@ function run(root: string, args: string[]) { } afterEach(async () => { vi.unstubAllEnvs(); + vi.unstubAllGlobals(); 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("user JWT origin boundary", () => { + beforeEach(() => { vi.stubEnv("AFFITOR_AGENT_DEV_API_ORIGIN", ""); }); + it.each([ + "https://untrusted.example.test", "https://api.affitor.com.example.test", + "https://other.affitor.com", "https://api.affitor.com:8443", + "http://127.0.0.1:1337", "https://localhost:1337", + ])("rejects %s before attaching a JWT or making HTTP requests", origin => { + const request = vi.fn(); vi.stubGlobal("fetch", request); + expect(() => new ProgramAgentClient("fixture_user_jwt", origin)).toThrow(AgentSetupError); + expect(request).not.toHaveBeenCalled(); + }); + it("permits only the production origin by default", async () => { + const request = vi.fn(async () => new Response(JSON.stringify({ data: [{ id: 10, name: "Fixture" }] }))); + vi.stubGlobal("fetch", request); + expect(agentApiOrigin("https://API.AFFITOR.COM:443/")).toBe("https://api.affitor.com"); + await new ProgramAgentClient("fixture_user_jwt", "https://api.affitor.com").programs(); + expect(request).toHaveBeenCalledWith("https://api.affitor.com/api/v1/cli/programs", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer fixture_user_jwt" }) })); + }); + it.each(["http://127.0.0.1:1337", "http://localhost:1337", "https://[::1]:1337"])("requires an exact explicit loopback development origin: %s", origin => { + vi.stubEnv("AFFITOR_AGENT_DEV_API_ORIGIN", origin); + expect(agentApiOrigin(origin)).toBe(origin); + expect(() => agentApiOrigin(origin.replace(":1337", ":1338"))).toThrow(AgentSetupError); + }); + it.each(["https://untrusted.example.test", "https://other.affitor.com", "http://localhost:1337/path", "http://user:pass@localhost:1337", "*"])("development configuration cannot authorize %s", value => { + vi.stubEnv("AFFITOR_AGENT_DEV_API_ORIGIN", value); + const request = vi.fn(); vi.stubGlobal("fetch", request); + expect(() => new ProgramAgentClient("fixture_user_jwt", value)).toThrow(AgentSetupError); + expect(request).not.toHaveBeenCalled(); + expect(agentApiOrigin("https://api.affitor.com")).toBe("https://api.affitor.com"); + }); +}); + 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(); @@ -200,6 +234,20 @@ describe("authenticated per-client setup against local HTTP CMS", () => { }); describe("built command and generated launcher", () => { + it("refuses an unconfigured local origin before HTTP or agent-file writes", async () => { + const root = project(), cms = await fixture(); + vi.stubEnv("AFFITOR_AGENT_DEV_API_ORIGIN", ""); + const login = JSON.stringify({ token: "fixture_user_jwt", expires_at: "2099-01-01T00:00:00Z" }); + put(root, ".affitor/credentials.json", login); + const result = await run(root, ["agent", "setup", "--client", "claude_code", "--program-id", "10", "--yes", "--json", "--api-url", cms.origin]); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ status: "error", revoke_failed_ids: [] }); + expect(result.stdout).not.toContain("fixture_user_jwt"); + expect(cms.requests).toHaveLength(0); + expect(existsSync(join(root, ".mcp.json"))).toBe(false); + expect(existsSync(join(root, ".affitor/agents"))).toBe(false); + expect(readFileSync(join(root, ".affitor/credentials.json"), "utf8")).toBe(login); + }); 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"]]) { From ff31aa18fd7228670f993340eb946374eb5191ab Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sun, 18 Oct 2026 03:42:59 -0700 Subject: [PATCH 3/4] fix: verify agent setup origin policy on Windows --- packages/cli/tests/agent-setup-windows.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/cli/tests/agent-setup-windows.test.ts b/packages/cli/tests/agent-setup-windows.test.ts index 7043bd8..1030e94 100644 --- a/packages/cli/tests/agent-setup-windows.test.ts +++ b/packages/cli/tests/agent-setup-windows.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { AgentSetupError, ProgramAgentClient } from "../src/lib/program-agent-client.js"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -6,6 +7,19 @@ import { spawnSync } from "node:child_process"; // Runs on the real Windows CI worker, rather than mocking process.platform. describe.skipIf(process.platform !== "win32")("Windows generated launcher smoke", () => { + it("restricts user JWT origins and requires explicit local development configuration", () => { + try { + vi.stubEnv("AFFITOR_AGENT_DEV_API_ORIGIN", ""); + expect(new ProgramAgentClient("fixture_user_jwt", "https://api.affitor.com").origin).toBe("https://api.affitor.com"); + expect(() => new ProgramAgentClient("fixture_user_jwt", "https://untrusted.example.test")).toThrow(AgentSetupError); + expect(() => new ProgramAgentClient("fixture_user_jwt", "http://127.0.0.1:1337")).toThrow(AgentSetupError); + vi.stubEnv("AFFITOR_AGENT_DEV_API_ORIGIN", "http://127.0.0.1:1337"); + expect(new ProgramAgentClient("fixture_user_jwt", "http://127.0.0.1:1337").origin).toBe("http://127.0.0.1:1337"); + expect(() => new ProgramAgentClient("fixture_user_jwt", "http://127.0.0.1:1338")).toThrow(AgentSetupError); + vi.stubEnv("AFFITOR_AGENT_DEV_API_ORIGIN", "https://untrusted.example.test"); + expect(() => new ProgramAgentClient("fixture_user_jwt", "https://untrusted.example.test")).toThrow(AgentSetupError); + } finally { vi.unstubAllEnvs(); } + }); it("ignores a cwd npx.cmd decoy and starts the PATH tool with a safe environment", () => { const fixture = realpathSync(mkdtempSync(join(tmpdir(), "affitor-windows-"))); const project = join(fixture, "project & (spaces) %NO_EXPAND%"); From 360b2f2a9d8cf0578d235fe79413c152ac5b2761 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sun, 18 Oct 2026 04:01:45 -0700 Subject: [PATCH 4/4] fix: revoke replaced agent keys after installation --- packages/cli/README.md | 12 ++- packages/cli/src/lib/agent-setup.ts | 2 +- packages/cli/src/lib/program-agent-client.ts | 20 ++-- .../cli/tests/agent-setup-windows.test.ts | 24 ++++- .../cli/tests/program-agent-client.test.ts | 94 +++++++++++++++++++ 5 files changed, 143 insertions(+), 9 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index aff3ed1..29dcfad 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -91,9 +91,19 @@ Waiting row. Review unused Waiting credentials in Settings → Agents and revoke 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. +The preview notes include active-key replacement and revocation before you confirm +the plan. If a saved id is still active for the selected client but its server hint +has changed, approved setup saves a replacement first, then revokes that old id. +Other clients' keys are not revoked. A failed mint or local update leaves the old +key in place and compensates only newly minted credentials. If old-key revocation +cannot be confirmed after saving, the new credentials stay installed and the error's +`revoke_failed_ids` identifies the previous keys that need cleanup. Setup reports +this partial completion without claiming that the new files were rolled back. + An abrupt interruption, such as closing the terminal after minting, may also leave a Waiting credential even when no error JSON was printed and no local credential -file exists. Before retrying, review newly created Waiting rows for the selected +file exists, or leave a previous key unrevoked after its replacement was saved. +Before retrying, review newly created Waiting rows for the selected client in Settings → Agents and revoke unused credentials from that attempt. Keep credentials used by other agents. Then run a fresh dry-run and approve setup again; a static `doctor` result cannot prove that a server credential was cleaned up. diff --git a/packages/cli/src/lib/agent-setup.ts b/packages/cli/src/lib/agent-setup.ts index 2aeac15..034c801 100644 --- a/packages/cli/src/lib/agent-setup.ts +++ b/packages/cli/src/lib/agent-setup.ts @@ -115,7 +115,7 @@ export function planSetup(cwd: string, clients: AgentClient[], options: { agentK 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.notes.push("Matching active keys are reused. Replacing an active selected key revokes its previous key after the new files are saved; failed revocations require dashboard cleanup."); plan.agentKeys = clients.map(client => { const rel = `.affitor/agents/${client}.json`; const path = join(project, rel), raw = readTarget(project, path); diff --git a/packages/cli/src/lib/program-agent-client.ts b/packages/cli/src/lib/program-agent-client.ts index a6fbe0d..85e60d1 100644 --- a/packages/cli/src/lib/program-agent-client.ts +++ b/packages/cli/src/lib/program-agent-client.ts @@ -95,16 +95,18 @@ export async function installProgramAgents(plan: SetupPlan, programId: number, a return { client, record }; }); // Only owner list reads: never call readiness/status to pretend a client connected. - const rows = await api.list(programId), minted: number[] = []; + const rows = await api.list(programId), minted: number[] = [], replaced: 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); + const row = record && rows.find(row => row.id === record.id && row.client === client); + const active = row && row.status !== "revoked" && !row.revoked_at; + const reusable = record && active && row.key_hint === `aff_ag_…${record.api_key.slice(-4)}`; + const credential = reusable ? record : await api.mint(programId, client); + if (!reusable) minted.push(credential.id); + if (record && active && !reusable) replaced.push(record.id); + stageAgentCredential(plan, client, credential, !reusable); } 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[] = []; @@ -114,4 +116,10 @@ export async function installProgramAgents(plan: SetupPlan, programId: number, a ? "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); } + // Local installation has committed. Failure to retire an old key must not + // compensate the new credentials that the client now uses. + const failed: number[] = []; + for (const id of new Set(replaced)) { try { await api.revoke(programId, id); } catch { failed.push(id); } } + if (failed.length) throw new AgentSetupError("New agent credentials were installed, but previous keys could not be confirmed revoked. Review the reported old key ids in Settings → Agents before retrying.", failed); + return plan.agentKeys.map(({ client, path, id, state }) => ({ client, path, id, state })); } diff --git a/packages/cli/tests/agent-setup-windows.test.ts b/packages/cli/tests/agent-setup-windows.test.ts index 1030e94..71446fb 100644 --- a/packages/cli/tests/agent-setup-windows.test.ts +++ b/packages/cli/tests/agent-setup-windows.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { AgentSetupError, ProgramAgentClient } from "../src/lib/program-agent-client.js"; +import { AgentSetupError, ProgramAgentClient, installProgramAgents } from "../src/lib/program-agent-client.js"; +import { planSetup } from "../src/lib/agent-setup.js"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -20,6 +21,27 @@ describe.skipIf(process.platform !== "win32")("Windows generated launcher smoke" expect(() => new ProgramAgentClient("fixture_user_jwt", "https://untrusted.example.test")).toThrow(AgentSetupError); } finally { vi.unstubAllEnvs(); } }); + it("preserves an installed replacement when old-key revocation fails on the real filesystem", async () => { + const project = realpathSync(mkdtempSync(join(tmpdir(), "affitor-windows-replace-"))); + const api = new ProgramAgentClient("fixture_user_jwt", "https://api.affitor.com"); + const rows: { id: number; client: string; key_hint: string; status: string }[] = []; + let next = 201; + vi.spyOn(api, "list").mockResolvedValue(rows); + vi.spyOn(api, "mint").mockImplementation(async (program_id, client) => { + const id = next++, api_key = "aff_ag_" + String(id).padStart(43, "x"); + rows.push({ id, client, key_hint: `aff_ag_…${api_key.slice(-4)}`, status: "waiting" }); + return { version: 1, id, program_id, client, api_url: api.origin, api_key, created_at: "2026-10-18T00:00:00Z", credential_note: "Fake Windows fixture; not verified." }; + }); + const revoke = vi.spyOn(api, "revoke").mockRejectedValue(new Error("fixture revoke failure")); + try { + await installProgramAgents(planSetup(project, ["claude_code"], { agentKeys: true }), 10, api); + rows[0].key_hint = "old-hint-format"; + await expect(installProgramAgents(planSetup(project, ["claude_code"], { agentKeys: true }), 10, api)).rejects.toMatchObject({ cleanupIds: [201], uncertainMint: false, message: expect.stringContaining("installed") }); + expect(JSON.parse(readFileSync(join(project, ".affitor/agents/claude_code.json"), "utf8")).id).toBe(202); + expect(revoke).toHaveBeenCalledExactlyOnceWith(10, 201); + expect(rows.map(row => row.status)).toEqual(["waiting", "waiting"]); + } finally { vi.restoreAllMocks(); rmSync(project, { recursive: true, force: true }); } + }); it("ignores a cwd npx.cmd decoy and starts the PATH tool with a safe environment", () => { const fixture = realpathSync(mkdtempSync(join(tmpdir(), "affitor-windows-"))); const project = join(fixture, "project & (spaces) %NO_EXPAND%"); diff --git a/packages/cli/tests/program-agent-client.test.ts b/packages/cli/tests/program-agent-client.test.ts index 4763e81..dc67f61 100644 --- a/packages/cli/tests/program-agent-client.test.ts +++ b/packages/cli/tests/program-agent-client.test.ts @@ -133,6 +133,75 @@ describe("authenticated per-client setup against local HTTP CMS", () => { expect(cms.rows).toHaveLength(3); expect(cms.rows[1].status).toBe("waiting"); expect(readFileSync(join(root, ".affitor/agents/cursor.json"))).toEqual(cursor); }); + it("shows active-key replacement and revocation in the plan before consent", () => { + const root = project(); + const plan = planSetup(root, ["claude_code"], { agentKeys: true }); + expect(plan.notes.join(" ")).toMatch(/replac.*active.*revok/i); + expect(existsSync(join(root, ".affitor"))).toBe(false); + }); + it("replaces a changed hint then revokes only the old selected key after saving", async () => { + const root = project(), cms = await fixture(); + put(root, ".affitor/.env", "AFFITOR_API_KEY=fake_application_key\n"); + await installProgramAgents(planSetup(root, ["claude_code", "cursor"], { agentKeys: true }), 10, cms.api); + const cursor = readFileSync(join(root, ".affitor/agents/cursor.json")); + cms.rows[0].key_hint = key(201).slice(-4); + const boundary = cms.requests.length; + const apply = vi.fn((plan: Parameters[0]) => { + expect(cms.rows[0].status).toBe("waiting"); + applySetup(plan); + }); + const result = await installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api, apply); + expect(result[0].state).toBe("minted"); + expect(apply).toHaveBeenCalledOnce(); + expect(cms.requests.slice(boundary).map(row => [row.method, row.path])).toEqual([ + ["GET", "/api/v1/programs/10/agents"], ["POST", "/api/v1/programs/10/agents"], ["DELETE", "/api/v1/programs/10/agents/201"], + ]); + expect(cms.rows.map(row => row.status)).toEqual(["revoked", "waiting", "waiting"]); + expect(JSON.parse(readFileSync(join(root, ".affitor/agents/claude_code.json"), "utf8")).id).toBe(203); + expect(readFileSync(join(root, ".affitor/agents/cursor.json"))).toEqual(cursor); + expect(readFileSync(join(root, ".affitor/.env"), "utf8")).toBe("AFFITOR_API_KEY=fake_application_key\n"); + }); + it("keeps installed replacement keys and reports the old id when revocation fails", async () => { + const root = project(), cms = await fixture(); + await installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api); + cms.rows[0].key_hint = key(201).slice(-4); cms.behavior.failRevoke = true; + const error = await installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api).catch(error => error); + expect(error).toBeInstanceOf(AgentSetupError); + expect(error.cleanupIds).toEqual([201]); expect(error.uncertainMint).toBe(false); + expect(error.message).toMatch(/installed/i); expect(error.message).not.toMatch(/rolled back|newly minted credentials were revoked/i); + expect(JSON.parse(readFileSync(join(root, ".affitor/agents/claude_code.json"), "utf8")).id).toBe(202); + expect(cms.rows.map(row => row.status)).toEqual(["waiting", "waiting"]); + expect(cms.requests.filter(row => row.method === "DELETE").map(row => row.path)).toEqual(["/api/v1/programs/10/agents/201"]); + }); + it("keeps the old credential if replacement minting fails", async () => { + const root = project(), cms = await fixture(); + await installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api); + const old = readFileSync(join(root, ".affitor/agents/claude_code.json")); + cms.rows[0].key_hint = key(201).slice(-4); cms.behavior.failMintAt = 2; + await expect(installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api)).rejects.toBeInstanceOf(AgentSetupError); + expect(cms.rows[0].status).toBe("waiting"); + expect(readFileSync(join(root, ".affitor/agents/claude_code.json"))).toEqual(old); + expect(cms.requests.filter(row => row.method === "DELETE")).toHaveLength(0); + }); + it("compensates only the new key when local replacement fails", async () => { + const root = project(), cms = await fixture(); + await installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api); + const old = readFileSync(join(root, ".affitor/agents/claude_code.json")); + cms.rows[0].key_hint = key(201).slice(-4); + const apply = vi.fn(() => { throw new Error("fixture local apply failure"); }); + await expect(installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api, apply)).rejects.toBeInstanceOf(AgentSetupError); + expect(cms.rows.map(row => row.status)).toEqual(["waiting", "revoked"]); + expect(cms.requests.filter(row => row.method === "DELETE").map(row => row.path)).toEqual(["/api/v1/programs/10/agents/202"]); + expect(readFileSync(join(root, ".affitor/agents/claude_code.json"))).toEqual(old); + }); + it("does not revoke a listed row belonging to a different client", async () => { + const root = project(), cms = await fixture(); + await installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api); + cms.rows[0].client = "cursor"; + await installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api); + expect(cms.rows[0].status).toBe("waiting"); + expect(cms.requests.filter(row => row.method === "DELETE")).toHaveLength(0); + }); 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" }); @@ -257,6 +326,31 @@ describe("built command and generated launcher", () => { } expect(cms.requests).toHaveLength(0); expect(existsSync(join(root, ".mcp.json"))).toBe(false); }); + it.each([false, true])("requires consent for replacement and reports old-key cleanup truthfully (%s)", async failRevoke => { + const root = project(), cms = await fixture(); + await installProgramAgents(planSetup(root, ["claude_code"], { agentKeys: true }), 10, cms.api); + cms.rows[0].key_hint = key(201).slice(-4); cms.behavior.failRevoke = failRevoke; + put(root, ".affitor/credentials.json", JSON.stringify({ token: "fixture_user_jwt", expires_at: "2099-01-01T00:00:00Z" })); + const args = ["agent", "setup", "--client", "claude_code", "--program-id", "10", "--json", "--api-url", cms.origin]; + const boundary = cms.requests.length, old = readFileSync(join(root, ".affitor/agents/claude_code.json")); + for (const flags of [["--dry-run"], []]) { + const preview = await run(root, [...args, ...flags]); + expect(JSON.parse(preview.stdout).notes.join(" ")).toMatch(/replac.*active.*revok/i); + expect(cms.requests).toHaveLength(boundary); + expect(readFileSync(join(root, ".affitor/agents/claude_code.json"))).toEqual(old); + } + const result = await run(root, [...args, "--yes"]), output = JSON.parse(result.stdout); + expect(result.status, result.stderr).toBe(failRevoke ? 1 : 0); + if (failRevoke) { + expect(output).toMatchObject({ status: "error", revoke_failed_ids: [201] }); + expect(output.message).toMatch(/installed/); + expect(output.message).not.toMatch(/rolled back|newly minted credentials were revoked/); + } else expect(output.status).toBe("needs_next_step"); + expect(JSON.parse(readFileSync(join(root, ".affitor/agents/claude_code.json"), "utf8")).id).toBe(202); + expect(cms.rows[0].status).toBe(failRevoke ? "waiting" : "revoked"); + expect(cms.requests.slice(boundary).filter(row => row.method === "DELETE").map(row => row.path)).toEqual(["/api/v1/programs/10/agents/201"]); + for (const secret of ["fixture_user_jwt", key(201), key(202)]) expect(result.stdout + result.stderr).not.toContain(secret); + }); 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" }));