@hellcoder/companion 0.122.0 → 0.124.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/assets/{AgentsPage-DcOqIP9k.js → AgentsPage-Byq8wvdn.js} +5 -5
  2. package/dist/assets/CronManager-Bq2lOMbZ.js +1 -0
  3. package/dist/assets/{DashboardPage-B10_oar4.js → DashboardPage-V77HIt8S.js} +1 -1
  4. package/dist/assets/{IntegrationsPage-C3o8sLpo.js → IntegrationsPage-C3aW9cDI.js} +1 -1
  5. package/dist/assets/{LinearOAuthSettingsPage-C181ckxW.js → LinearOAuthSettingsPage-_eAbciJN.js} +1 -1
  6. package/dist/assets/{LinearSettingsPage-B-7G76Ns.js → LinearSettingsPage-DDHFHt7j.js} +1 -1
  7. package/dist/assets/{MagicUIDashboard-CkEZ3BVa.js → MagicUIDashboard-Cl18AKjV.js} +1 -1
  8. package/dist/assets/{MagicUIView-BmwHDokR.js → MagicUIView-C0aiMTet.js} +1 -1
  9. package/dist/assets/{Playground-DvE5Hx7h.js → Playground-CvoHRMBd.js} +1 -1
  10. package/dist/assets/{PromptsPage-BFYinvPb.js → PromptsPage-CzTs1_Se.js} +1 -1
  11. package/dist/assets/{RunsPage-C_wKk1gx.js → RunsPage-B7xru-Xy.js} +1 -1
  12. package/dist/assets/{SandboxManager-DRRe06TY.js → SandboxManager-C3tFkiWz.js} +1 -1
  13. package/dist/assets/SettingsPage-BZ2fBrHD.js +1 -0
  14. package/dist/assets/{TailscalePage-DHuSFb11.js → TailscalePage-DFRiFKcb.js} +1 -1
  15. package/dist/assets/index-C7nK12tB.js +142 -0
  16. package/dist/assets/index-OAVDFR0O.css +1 -0
  17. package/dist/assets/{sw-register-D2DuNbEG.js → sw-register-Dc72vh_Y.js} +1 -1
  18. package/dist/index.html +2 -2
  19. package/dist/sw.js +1 -1
  20. package/package.json +1 -1
  21. package/server/claude-auth.test.ts +305 -0
  22. package/server/claude-auth.ts +367 -0
  23. package/server/cli-launcher.test.ts +16 -0
  24. package/server/cli-launcher.ts +14 -0
  25. package/server/routes/claude-auth-routes.test.ts +147 -0
  26. package/server/routes/claude-auth-routes.ts +55 -0
  27. package/server/routes.test.ts +29 -0
  28. package/server/routes.ts +16 -0
  29. package/dist/assets/CronManager-DNNKDHYb.js +0 -1
  30. package/dist/assets/SettingsPage-DlqgaG3K.js +0 -1
  31. package/dist/assets/index-BnF_YIhB.css +0 -1
  32. package/dist/assets/index-ZUC5y0cV.js +0 -141
@@ -0,0 +1,305 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { PassThrough } from "node:stream";
4
+
5
+ /**
6
+ * Tests for the Claude Code paste-code sign-in.
7
+ *
8
+ * The manager drives the real `claude` binary, so these tests stub the spawn
9
+ * boundary. Two kinds of child are simulated: the long-lived
10
+ * `claude auth login` process (emits an authorize URL, then reads a code from
11
+ * stdin) and the short-lived `claude auth status --json` probe.
12
+ *
13
+ * The behaviour that matters and is easy to get wrong:
14
+ * - success is decided by re-reading `auth status`, never by parsing prose
15
+ * - a rejected code is recoverable on the same process (the CLI re-prompts)
16
+ * - CLAUDE_CODE_OAUTH_TOKEN must not leak into these subprocesses, or a stale
17
+ * token would masquerade as a successful login
18
+ */
19
+
20
+ const spawnMock = vi.fn();
21
+ const resolveBinaryMock = vi.fn(() => "/usr/bin/claude");
22
+
23
+ vi.mock("node:child_process", () => ({ spawn: spawnMock }));
24
+ vi.mock("./path-resolver.js", () => ({
25
+ resolveBinary: (...a: unknown[]) => resolveBinaryMock(...(a as [])),
26
+ getEnrichedPath: () => "/usr/bin:/bin",
27
+ }));
28
+
29
+ const AUTH_URL =
30
+ "https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback";
31
+
32
+ class FakeChild extends EventEmitter {
33
+ stdout = new PassThrough();
34
+ stderr = new PassThrough();
35
+ /** Codes written to stdin by the manager. */
36
+ written: string[] = [];
37
+ stdin = { write: (chunk: string) => { this.written.push(chunk); return true; } };
38
+ killed = false;
39
+ kill(): void { this.killed = true; }
40
+ }
41
+
42
+ /** Queue of children handed out by successive spawn() calls, in order. */
43
+ let loginChild: FakeChild;
44
+ let statusResponses: Array<{ loggedIn: boolean; [k: string]: unknown }>;
45
+ let spawnedArgs: string[][];
46
+ let spawnedEnvs: NodeJS.ProcessEnv[];
47
+ let auth: typeof import("./claude-auth.js");
48
+
49
+ function installSpawn(): void {
50
+ spawnMock.mockImplementation((_bin: string, args: string[], opts: { env: NodeJS.ProcessEnv }) => {
51
+ spawnedArgs.push(args);
52
+ spawnedEnvs.push(opts?.env ?? {});
53
+
54
+ // `auth status --json` — short-lived, answers from the queue then exits.
55
+ if (args[1] === "status") {
56
+ const child = new FakeChild();
57
+ const payload = statusResponses.length > 1
58
+ ? statusResponses.shift()!
59
+ : statusResponses[0];
60
+ setImmediate(() => {
61
+ child.stdout.write(JSON.stringify(payload));
62
+ child.emit("exit", 0);
63
+ });
64
+ return child;
65
+ }
66
+
67
+ if (args[1] === "logout") {
68
+ const child = new FakeChild();
69
+ setImmediate(() => child.emit("exit", 0));
70
+ return child;
71
+ }
72
+
73
+ // `auth login` — long-lived; emits the URL shortly after spawn.
74
+ loginChild = new FakeChild();
75
+ setImmediate(() => {
76
+ loginChild.stdout.write(`Opening browser to sign in…\nIf the browser didn't open, visit: ${AUTH_URL}\nPaste code here if prompted > `);
77
+ });
78
+ return loginChild;
79
+ });
80
+ }
81
+
82
+ const LOGGED_OUT = { loggedIn: false, authMethod: "none" };
83
+ const LOGGED_IN = {
84
+ loggedIn: true, authMethod: "claude.ai", email: "user@example.com",
85
+ orgName: "Acme Inc", subscriptionType: "max",
86
+ };
87
+
88
+ beforeEach(async () => {
89
+ vi.resetModules();
90
+ spawnMock.mockReset();
91
+ resolveBinaryMock.mockReturnValue("/usr/bin/claude");
92
+ spawnedArgs = [];
93
+ spawnedEnvs = [];
94
+ statusResponses = [LOGGED_OUT];
95
+ installSpawn();
96
+ auth = await import("./claude-auth.js");
97
+ });
98
+
99
+ afterEach(() => {
100
+ auth._resetClaudeAuthState();
101
+ });
102
+
103
+ describe("extractAuthUrl", () => {
104
+ it("scrapes the authorize URL out of CLI chatter", () => {
105
+ expect(auth.extractAuthUrl(`visit: ${AUTH_URL}\nPaste code >`)).toBe(AUTH_URL);
106
+ });
107
+
108
+ it("ignores output that has no URL yet", () => {
109
+ expect(auth.extractAuthUrl("Opening browser to sign in…")).toBeNull();
110
+ });
111
+
112
+ it("strips ANSI colouring around the URL", () => {
113
+ expect(auth.extractAuthUrl(`\u001b[36m${AUTH_URL}\u001b[0m`)).toBe(AUTH_URL);
114
+ });
115
+ });
116
+
117
+ describe("getAccountStatus", () => {
118
+ it("reports the signed-in account from auth status --json", async () => {
119
+ statusResponses = [LOGGED_IN];
120
+
121
+ const status = await auth.getAccountStatus();
122
+
123
+ expect(status).toMatchObject({
124
+ cliAvailable: true, authenticated: true, method: "claude.ai",
125
+ email: "user@example.com", orgName: "Acme Inc", subscriptionType: "max",
126
+ });
127
+ expect(spawnedArgs[0]).toEqual(["auth", "status", "--json"]);
128
+ });
129
+
130
+ it("reports signed out, normalising authMethod 'none' to null", async () => {
131
+ const status = await auth.getAccountStatus();
132
+
133
+ expect(status.authenticated).toBe(false);
134
+ expect(status.method).toBeNull();
135
+ });
136
+
137
+ it("never exposes CLAUDE_CODE_OAUTH_TOKEN to the CLI", async () => {
138
+ // A token injected into sessions is a *different* auth mechanism. If it
139
+ // leaked here, status would report that token's identity instead of the
140
+ // stored login this panel manages.
141
+ await auth.getAccountStatus();
142
+
143
+ expect(spawnedEnvs[0]).toHaveProperty("CLAUDE_CODE_OAUTH_TOKEN", undefined);
144
+ expect(spawnedEnvs[0]).toHaveProperty("CLAUDECODE", undefined);
145
+ });
146
+
147
+ it("reports the CLI as unavailable rather than throwing", async () => {
148
+ resolveBinaryMock.mockReturnValue(null as never);
149
+
150
+ const status = await auth.getAccountStatus();
151
+
152
+ expect(status.cliAvailable).toBe(false);
153
+ expect(status.error).toMatch(/not found/i);
154
+ });
155
+
156
+ it("surfaces an error when the CLI prints unparseable output", async () => {
157
+ spawnMock.mockImplementation(() => {
158
+ const child = new FakeChild();
159
+ setImmediate(() => {
160
+ child.stderr.write("something exploded");
161
+ child.emit("exit", 1);
162
+ });
163
+ return child;
164
+ });
165
+
166
+ const status = await auth.getAccountStatus();
167
+
168
+ expect(status.authenticated).toBe(false);
169
+ expect(status.error).toBe("something exploded");
170
+ });
171
+ });
172
+
173
+ describe("startLogin", () => {
174
+ it("returns the authorize URL and waits for a code", async () => {
175
+ const status = await auth.startLogin();
176
+
177
+ expect(status.state).toBe("awaiting_code");
178
+ expect(status.authUrl).toBe(AUTH_URL);
179
+ expect(spawnedArgs[0]).toEqual(["auth", "login", "--claudeai"]);
180
+ });
181
+
182
+ it("is idempotent so an already-open link stays valid", async () => {
183
+ const first = await auth.startLogin();
184
+ const second = await auth.startLogin();
185
+
186
+ expect(second.authUrl).toBe(first.authUrl);
187
+ expect(spawnedArgs.filter((a) => a[1] === "login")).toHaveLength(1);
188
+ });
189
+
190
+ it("throws when the claude binary is missing", async () => {
191
+ resolveBinaryMock.mockReturnValue(null as never);
192
+ await expect(auth.startLogin()).rejects.toThrow(/not found/i);
193
+ });
194
+
195
+ it("fails cleanly when the CLI exits before printing a link", async () => {
196
+ spawnMock.mockImplementation(() => {
197
+ const child = new FakeChild();
198
+ setImmediate(() => child.emit("exit", 1));
199
+ return child;
200
+ });
201
+
202
+ const status = await auth.startLogin();
203
+
204
+ expect(status.state).toBe("error");
205
+ expect(status.error).toMatch(/exited/i);
206
+ });
207
+ });
208
+
209
+ describe("submitCode", () => {
210
+ it("writes the code to the CLI and confirms success via auth status", async () => {
211
+ await auth.startLogin();
212
+ // First status call (during verification) reports the new login.
213
+ statusResponses = [LOGGED_IN];
214
+
215
+ const status = await auth.submitCode(" good-code ");
216
+
217
+ expect(status.state).toBe("success");
218
+ // Trimmed and newline-terminated, or the CLI would keep waiting.
219
+ expect(loginChild.written).toEqual(["good-code\n"]);
220
+ expect(loginChild.killed).toBe(true);
221
+ });
222
+
223
+ it("returns to awaiting_code with the URL intact when the code is rejected", async () => {
224
+ await auth.startLogin();
225
+
226
+ const pending = auth.submitCode("bad-code");
227
+ // The CLI re-prompts rather than exiting, so this is recoverable.
228
+ setImmediate(() => loginChild.stdout.write("Invalid code. Please make sure the full code was copied.\n"));
229
+ const status = await pending;
230
+
231
+ expect(status.state).toBe("awaiting_code");
232
+ expect(status.error).toMatch(/wasn't accepted/i);
233
+ expect(status.authUrl).toBe(AUTH_URL);
234
+ // Child stays alive for the retry.
235
+ expect(loginChild.killed).toBe(false);
236
+ });
237
+
238
+ it("accepts a corrected code after a rejection", async () => {
239
+ await auth.startLogin();
240
+
241
+ const firstTry = auth.submitCode("bad-code");
242
+ setImmediate(() => loginChild.stdout.write("Invalid code. Please make sure the full code was copied.\n"));
243
+ await firstTry;
244
+
245
+ statusResponses = [LOGGED_IN];
246
+ const retry = await auth.submitCode("good-code");
247
+
248
+ expect(retry.state).toBe("success");
249
+ expect(loginChild.written).toEqual(["bad-code\n", "good-code\n"]);
250
+ });
251
+
252
+ it("rejects an empty code without touching the CLI", async () => {
253
+ await auth.startLogin();
254
+
255
+ const status = await auth.submitCode(" ");
256
+
257
+ expect(status.state).toBe("awaiting_code");
258
+ expect(status.error).toMatch(/enter the code/i);
259
+ expect(loginChild.written).toEqual([]);
260
+ });
261
+
262
+ it("reports when no sign-in is in progress", async () => {
263
+ const status = await auth.submitCode("some-code");
264
+ expect(status.error).toMatch(/no sign-in in progress/i);
265
+ });
266
+ });
267
+
268
+ describe("cancelLogin", () => {
269
+ it("abandons the attempt and kills the CLI", async () => {
270
+ await auth.startLogin();
271
+
272
+ const status = auth.cancelLogin();
273
+
274
+ expect(status.state).toBe("canceled");
275
+ expect(loginChild.killed).toBe(true);
276
+ // Terminal result is retained for a poll arriving after cancel.
277
+ expect(auth.getLoginStatus().state).toBe("canceled");
278
+ });
279
+
280
+ it("is a no-op when nothing is in flight", () => {
281
+ expect(auth.cancelLogin().state).toBe("idle");
282
+ });
283
+ });
284
+
285
+ describe("logout", () => {
286
+ it("shells out to auth logout", async () => {
287
+ const res = await auth.logout();
288
+
289
+ expect(res.ok).toBe(true);
290
+ expect(spawnedArgs[0]).toEqual(["auth", "logout"]);
291
+ });
292
+
293
+ it("reports a logout failure reason", async () => {
294
+ spawnMock.mockImplementation(() => {
295
+ const child = new FakeChild();
296
+ setImmediate(() => {
297
+ child.stderr.write("not logged in");
298
+ child.emit("exit", 1);
299
+ });
300
+ return child;
301
+ });
302
+
303
+ expect(await auth.logout()).toEqual({ ok: false, error: "not logged in" });
304
+ });
305
+ });
@@ -0,0 +1,367 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { getEnrichedPath, resolveBinary } from "./path-resolver.js";
3
+
4
+ /**
5
+ * Claude Code sign-in driven from the Companion UI.
6
+ *
7
+ * Unlike Codex (which exposes a structured `account/login/start` RPC with a
8
+ * device code), the Claude CLI has no device-code flow. What it does have is
9
+ * better suited to a remote box anyway: `claude auth login` prints an authorize
10
+ * URL whose redirect_uri is Anthropic-hosted
11
+ * (https://platform.claude.com/oauth/code/callback), not a localhost callback.
12
+ * The user opens that URL anywhere, approves, and Anthropic shows them a code —
13
+ * which the CLI then reads from stdin.
14
+ *
15
+ * So the flow we drive is:
16
+ * 1. spawn `claude auth login --claudeai`, scrape the authorize URL
17
+ * 2. hand the URL to the browser; the child stays alive waiting on stdin
18
+ * 3. user pastes the code back into the UI; we write it to the child's stdin
19
+ * 4. verify the outcome with `claude auth status --json`
20
+ *
21
+ * Step 4 matters: the CLI's success text is not a contract, but
22
+ * `claude auth status --json` is machine-readable, so the authoritative answer
23
+ * comes from re-reading state rather than parsing prose. A wrong code is
24
+ * recoverable — the CLI re-prompts on the same process, so the user can paste
25
+ * again without restarting.
26
+ *
27
+ * `claude setup-token` is deliberately not used: it produces no output on a
28
+ * non-TTY and hangs, so it cannot be driven headlessly.
29
+ */
30
+
31
+ /** Overall budget for one login attempt before we abandon the child. */
32
+ const LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
33
+ /** How long to wait for the authorize URL to show up on stdout. */
34
+ const URL_WAIT_MS = 30_000;
35
+ /** Budget for a short-lived `auth status` / `auth logout` invocation. */
36
+ const CLI_TIMEOUT_MS = 20_000;
37
+ /** How long to keep re-checking auth status after a code is submitted. */
38
+ const VERIFY_TIMEOUT_MS = 45_000;
39
+ const VERIFY_POLL_MS = 1_000;
40
+
41
+ export type ClaudeLoginState =
42
+ | "idle"
43
+ /** URL issued; waiting for the user to paste the code back. */
44
+ | "awaiting_code"
45
+ /** Code submitted; confirming with `auth status`. */
46
+ | "verifying"
47
+ | "success"
48
+ | "error"
49
+ | "canceled";
50
+
51
+ export interface ClaudeLoginStatus {
52
+ state: ClaudeLoginState;
53
+ /** The URL the user opens to approve. Present from `awaiting_code` on. */
54
+ authUrl?: string;
55
+ /**
56
+ * Failure detail. On a rejected code this is set while state returns to
57
+ * `awaiting_code`, so the UI can show the error and still accept a retry.
58
+ */
59
+ error?: string;
60
+ /** Epoch ms after which the attempt is abandoned. */
61
+ expiresAt?: number;
62
+ }
63
+
64
+ export interface ClaudeAccountStatus {
65
+ cliAvailable: boolean;
66
+ authenticated: boolean;
67
+ /** `authMethod` as reported by the CLI, e.g. "claude.ai" or "console". */
68
+ method: string | null;
69
+ email: string | null;
70
+ orgName: string | null;
71
+ subscriptionType: string | null;
72
+ error?: string;
73
+ }
74
+
75
+ interface ActiveLogin {
76
+ child: ChildProcess;
77
+ status: ClaudeLoginStatus;
78
+ timer: ReturnType<typeof setTimeout>;
79
+ /** Everything stdout/stderr has produced, ANSI-stripped, for URL + error scraping. */
80
+ output: string;
81
+ /** Resolves once the authorize URL has been scraped. */
82
+ onUrl?: (url: string) => void;
83
+ /**
84
+ * Unblocks a pending URL wait when the attempt dies first. Without this,
85
+ * `startLogin` would sit on the full URL_WAIT_MS budget even though the child
86
+ * has already exited and no URL can ever arrive.
87
+ */
88
+ onUrlAbort?: () => void;
89
+ /** Set while a submitted code is being judged. */
90
+ onCodeVerdict?: (err: string | null) => void;
91
+ }
92
+
93
+ let activeLogin: ActiveLogin | null = null;
94
+ /** Terminal result of the last attempt, so a poll after completion still sees it. */
95
+ let lastResult: ClaudeLoginStatus = { state: "idle" };
96
+
97
+ const ANSI_RE = /\u001b\[[0-9;]*[A-Za-z]/g;
98
+
99
+ /**
100
+ * Env for auth subprocesses.
101
+ *
102
+ * CLAUDE_CODE_OAUTH_TOKEN is stripped on purpose. It is a *separate* auth
103
+ * mechanism (a token pasted into Settings and injected into sessions); if it
104
+ * leaked in here, `auth status` would report that token's identity and mask the
105
+ * stored login this panel manages — and post-code verification could report
106
+ * success for a login that never actually landed.
107
+ */
108
+ function authEnv(): NodeJS.ProcessEnv {
109
+ return {
110
+ ...process.env,
111
+ // Never let a parent Claude session leak in (same hygiene as cli-launcher).
112
+ CLAUDECODE: undefined,
113
+ CLAUDE_CODE_OAUTH_TOKEN: undefined,
114
+ PATH: getEnrichedPath(),
115
+ };
116
+ }
117
+
118
+ /** Run a short-lived `claude ...` invocation and collect stdout. */
119
+ function runClaude(args: string[], timeoutMs = CLI_TIMEOUT_MS): Promise<{ ok: boolean; stdout: string; stderr: string }> {
120
+ return new Promise((resolve) => {
121
+ const binary = resolveBinary("claude");
122
+ if (!binary) {
123
+ resolve({ ok: false, stdout: "", stderr: "Claude CLI not found on PATH" });
124
+ return;
125
+ }
126
+
127
+ let child: ChildProcess;
128
+ try {
129
+ child = spawn(binary, args, { env: authEnv(), stdio: ["ignore", "pipe", "pipe"] });
130
+ } catch (e) {
131
+ resolve({ ok: false, stdout: "", stderr: e instanceof Error ? e.message : String(e) });
132
+ return;
133
+ }
134
+
135
+ let stdout = "";
136
+ let stderr = "";
137
+ let settled = false;
138
+ const finish = (ok: boolean) => {
139
+ if (settled) return;
140
+ settled = true;
141
+ clearTimeout(timer);
142
+ try { child.kill(); } catch { /* already dead */ }
143
+ resolve({ ok, stdout, stderr });
144
+ };
145
+ const timer = setTimeout(() => finish(false), timeoutMs);
146
+
147
+ child.stdout?.on("data", (c: Buffer) => { stdout += c.toString(); });
148
+ child.stderr?.on("data", (c: Buffer) => { stderr += c.toString(); });
149
+ child.on("error", (e) => { stderr += String(e); finish(false); });
150
+ child.on("exit", (code) => finish(code === 0));
151
+ });
152
+ }
153
+
154
+ /** Read the current Claude account via `claude auth status --json`. */
155
+ export async function getAccountStatus(): Promise<ClaudeAccountStatus> {
156
+ const base: ClaudeAccountStatus = {
157
+ cliAvailable: true, authenticated: false, method: null,
158
+ email: null, orgName: null, subscriptionType: null,
159
+ };
160
+
161
+ if (!resolveBinary("claude")) {
162
+ return { ...base, cliAvailable: false, error: "Claude CLI not found on PATH" };
163
+ }
164
+
165
+ const { stdout, stderr } = await runClaude(["auth", "status", "--json"]);
166
+
167
+ let parsed: Record<string, unknown>;
168
+ try {
169
+ parsed = JSON.parse(stdout.replace(ANSI_RE, "").trim()) as Record<string, unknown>;
170
+ } catch {
171
+ return { ...base, error: stderr.trim() || "Could not read Claude auth status" };
172
+ }
173
+
174
+ const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
175
+ return {
176
+ ...base,
177
+ authenticated: parsed.loggedIn === true,
178
+ method: str(parsed.authMethod) === "none" ? null : str(parsed.authMethod),
179
+ email: str(parsed.email),
180
+ orgName: str(parsed.orgName),
181
+ subscriptionType: str(parsed.subscriptionType),
182
+ };
183
+ }
184
+
185
+ /** Current login status: the in-flight attempt, else the last terminal result. */
186
+ export function getLoginStatus(): ClaudeLoginStatus {
187
+ return activeLogin ? { ...activeLogin.status } : { ...lastResult };
188
+ }
189
+
190
+ function killChild(child: ChildProcess): void {
191
+ try { child.kill(); } catch { /* already dead */ }
192
+ }
193
+
194
+ function finishLogin(state: ClaudeLoginState, error?: string): void {
195
+ if (!activeLogin) return;
196
+ clearTimeout(activeLogin.timer);
197
+ killChild(activeLogin.child);
198
+ activeLogin.onUrlAbort?.();
199
+ lastResult = { state, authUrl: activeLogin.status.authUrl, error };
200
+ activeLogin = null;
201
+ }
202
+
203
+ /** Scrape the authorize URL out of CLI output. */
204
+ export function extractAuthUrl(output: string): string | null {
205
+ const match = output.replace(ANSI_RE, "").match(/https?:\/\/[^\s]*oauth\/authorize\?[^\s]+/);
206
+ return match ? match[0] : null;
207
+ }
208
+
209
+ /**
210
+ * Start a login: spawn the CLI and return once the authorize URL is known.
211
+ * Idempotent while an attempt is in flight — restarting would invalidate a URL
212
+ * the user may already have open.
213
+ */
214
+ export async function startLogin(): Promise<ClaudeLoginStatus> {
215
+ if (activeLogin) return { ...activeLogin.status };
216
+
217
+ const binary = resolveBinary("claude");
218
+ if (!binary) throw new Error("Claude CLI not found on PATH");
219
+
220
+ const child = spawn(binary, ["auth", "login", "--claudeai"], {
221
+ env: authEnv(),
222
+ stdio: ["pipe", "pipe", "pipe"],
223
+ });
224
+
225
+ const login: ActiveLogin = {
226
+ child,
227
+ status: { state: "awaiting_code", expiresAt: Date.now() + LOGIN_TIMEOUT_MS },
228
+ timer: setTimeout(
229
+ () => finishLogin("error", "Sign-in timed out. Start again to get a fresh link."),
230
+ LOGIN_TIMEOUT_MS,
231
+ ),
232
+ output: "",
233
+ };
234
+ activeLogin = login;
235
+
236
+ const handleChunk = (chunk: Buffer) => {
237
+ login.output += chunk.toString().replace(ANSI_RE, "");
238
+
239
+ if (!login.status.authUrl) {
240
+ const url = extractAuthUrl(login.output);
241
+ if (url) {
242
+ login.status.authUrl = url;
243
+ login.onUrl?.(url);
244
+ }
245
+ }
246
+
247
+ // The CLI re-prompts on a bad code, so this is recoverable: report it and
248
+ // go back to awaiting_code rather than tearing the attempt down.
249
+ if (login.onCodeVerdict && /Invalid code/i.test(login.output)) {
250
+ const verdict = login.onCodeVerdict;
251
+ login.onCodeVerdict = undefined;
252
+ login.output = "";
253
+ verdict("That code wasn't accepted. Make sure you copied all of it, then try again.");
254
+ }
255
+ };
256
+
257
+ child.stdout?.on("data", handleChunk);
258
+ child.stderr?.on("data", handleChunk);
259
+ child.on("error", (e) => finishLogin("error", e instanceof Error ? e.message : String(e)));
260
+ child.on("exit", () => {
261
+ // Exiting before we reached a terminal state means the login can't complete.
262
+ if (activeLogin === login && login.status.state !== "verifying") {
263
+ finishLogin("error", "Claude exited before sign-in completed");
264
+ }
265
+ });
266
+
267
+ // Wait for the URL — without it there is nothing to show the user.
268
+ const url = await new Promise<string | null>((resolve) => {
269
+ if (login.status.authUrl) {
270
+ resolve(login.status.authUrl);
271
+ return;
272
+ }
273
+ const timer = setTimeout(() => resolve(null), URL_WAIT_MS);
274
+ login.onUrl = (u) => { clearTimeout(timer); resolve(u); };
275
+ login.onUrlAbort = () => { clearTimeout(timer); resolve(null); };
276
+ });
277
+
278
+ // The child may have died while we waited (handled by its exit/error hooks,
279
+ // which already recorded the failure) — don't overwrite that reason.
280
+ if (activeLogin !== login) return { ...lastResult };
281
+
282
+ if (!url) {
283
+ finishLogin("error", "Claude did not return a sign-in link. Check that the CLI is up to date.");
284
+ return { ...lastResult };
285
+ }
286
+
287
+ return { ...login.status };
288
+ }
289
+
290
+ /**
291
+ * Submit the code the user pasted. Resolves to `success`, or back to
292
+ * `awaiting_code` with an error so they can retry on the same attempt.
293
+ */
294
+ export async function submitCode(code: string): Promise<ClaudeLoginStatus> {
295
+ const trimmed = code.trim();
296
+ if (!trimmed) return { state: "awaiting_code", authUrl: activeLogin?.status.authUrl, error: "Enter the code first." };
297
+ if (!activeLogin) return { ...lastResult, error: "No sign-in in progress. Start again." };
298
+
299
+ const login = activeLogin;
300
+ login.status.state = "verifying";
301
+ delete login.status.error;
302
+ login.output = "";
303
+
304
+ const rejection = new Promise<string | null>((resolve) => { login.onCodeVerdict = resolve; });
305
+
306
+ try {
307
+ login.child.stdin?.write(trimmed + "\n");
308
+ } catch {
309
+ finishLogin("error", "Could not send the code to the Claude CLI.");
310
+ return { ...lastResult };
311
+ }
312
+
313
+ // Race an explicit rejection against the CLI actually becoming logged in.
314
+ // Status is the authoritative signal; the printed text is only a fast path.
315
+ const verified = (async () => {
316
+ const deadline = Date.now() + VERIFY_TIMEOUT_MS;
317
+ while (Date.now() < deadline) {
318
+ await new Promise((r) => setTimeout(r, VERIFY_POLL_MS));
319
+ const account = await getAccountStatus();
320
+ if (account.authenticated) return true;
321
+ if (activeLogin !== login) return false; // canceled or timed out meanwhile
322
+ }
323
+ return false;
324
+ })();
325
+
326
+ const outcome = await Promise.race([
327
+ rejection.then((err) => ({ kind: "rejected" as const, err })),
328
+ verified.then((ok) => ({ kind: "verified" as const, ok })),
329
+ ]);
330
+
331
+ if (outcome.kind === "verified" && outcome.ok) {
332
+ finishLogin("success");
333
+ return { ...lastResult };
334
+ }
335
+
336
+ if (activeLogin !== login) return { ...lastResult };
337
+
338
+ // Recoverable: keep the child alive so the user can paste a corrected code.
339
+ login.status.state = "awaiting_code";
340
+ login.status.error = outcome.kind === "rejected" && outcome.err
341
+ ? outcome.err
342
+ : "Couldn't confirm the sign-in. Check the code and try again.";
343
+ return { ...login.status };
344
+ }
345
+
346
+ /** Abandon an in-flight login. */
347
+ export function cancelLogin(): ClaudeLoginStatus {
348
+ if (!activeLogin) return { ...lastResult };
349
+ finishLogin("canceled");
350
+ return { ...lastResult };
351
+ }
352
+
353
+ /** Sign out via `claude auth logout`. */
354
+ export async function logout(): Promise<{ ok: boolean; error?: string }> {
355
+ const { ok, stderr } = await runClaude(["auth", "logout"]);
356
+ return ok ? { ok: true } : { ok: false, error: stderr.trim() || "Sign out failed" };
357
+ }
358
+
359
+ /** Test helper: drop all in-memory login state. */
360
+ export function _resetClaudeAuthState(): void {
361
+ if (activeLogin) {
362
+ clearTimeout(activeLogin.timer);
363
+ killChild(activeLogin.child);
364
+ }
365
+ activeLogin = null;
366
+ lastResult = { state: "idle" };
367
+ }
@@ -693,6 +693,22 @@ describe("state management", () => {
693
693
  });
694
694
  });
695
695
 
696
+ describe("setPinned", () => {
697
+ it("sets and clears the pinned flag", () => {
698
+ launcher.launch({ cwd: "/tmp" });
699
+ expect(launcher.setPinned("test-session-id", true)).toBe(true);
700
+ expect(launcher.getSession("test-session-id")?.pinned).toBe(true);
701
+
702
+ expect(launcher.setPinned("test-session-id", false)).toBe(true);
703
+ // Cleared rather than stored as false, keeping persisted state lean
704
+ expect(launcher.getSession("test-session-id")?.pinned).toBeUndefined();
705
+ });
706
+
707
+ it("returns false for an unknown session", () => {
708
+ expect(launcher.setPinned("nonexistent", true)).toBe(false);
709
+ });
710
+ });
711
+
696
712
  describe("removeSession", () => {
697
713
  it("deletes session from internal maps", () => {
698
714
  launcher.launch({ cwd: "/tmp" });