Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,30 @@ 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.
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 `*`
Expand All @@ -77,6 +91,23 @@ 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, 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.

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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/agent-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
36 changes: 25 additions & 11 deletions packages/cli/src/lib/program-agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> { 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 }

Expand Down Expand Up @@ -89,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[] = [];
Expand All @@ -108,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 }));
}
38 changes: 37 additions & 1 deletion packages/cli/tests/agent-setup-windows.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,47 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
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";
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("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%");
Expand Down
Loading
Loading