@bastani/atomic 0.5.28-0 → 0.5.28-2

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.
@@ -34,6 +34,7 @@ import { join } from "node:path";
34
34
  import { tmpdir } from "node:os";
35
35
  import * as realWorkflows from "../../sdk/workflows/index.ts";
36
36
  import * as realDetect from "../../services/system/detect.ts";
37
+ import * as realAuth from "../../services/system/auth.ts";
37
38
  import * as realSpawn from "../../lib/spawn.ts";
38
39
  import { AGENT_CONFIG } from "../../services/config/index.ts";
39
40
  import type {
@@ -99,6 +100,14 @@ const ensureBunInstalledMock = mock<typeof realSpawn.ensureBunInstalled>(
99
100
  async () => {},
100
101
  );
101
102
 
103
+ // Default: pretend auth succeeded. Real probes spawn the agent CLI via
104
+ // its SDK to talk to an auth RPC — that's network/process-heavy and
105
+ // non-deterministic on CI runners, so the auth branch is faked here and
106
+ // exercised directly by `auth.test.ts`.
107
+ const checkAgentAuthMock = mock<typeof realAuth.checkAgentAuth>(async () => ({
108
+ loggedIn: true,
109
+ }));
110
+
102
111
  mock.module("../../sdk/workflows/index.ts", () => ({
103
112
  ...realWorkflows,
104
113
  executeWorkflow: executeWorkflowMock,
@@ -110,6 +119,10 @@ mock.module("../../services/system/detect.ts", () => ({
110
119
  ...realDetect,
111
120
  isCommandInstalled: isCommandInstalledMock,
112
121
  }));
122
+ mock.module("../../services/system/auth.ts", () => ({
123
+ ...realAuth,
124
+ checkAgentAuth: checkAgentAuthMock,
125
+ }));
113
126
  mock.module("../../lib/spawn.ts", () => ({
114
127
  ...realSpawn,
115
128
  ensureTmuxInstalled: ensureTmuxInstalledMock,
@@ -226,6 +239,8 @@ beforeEach(async () => {
226
239
  ensureTmuxInstalledMock.mockImplementation(async () => {});
227
240
  ensureBunInstalledMock.mockClear();
228
241
  ensureBunInstalledMock.mockImplementation(async () => {});
242
+ checkAgentAuthMock.mockClear();
243
+ checkAgentAuthMock.mockImplementation(async () => ({ loggedIn: true }));
229
244
  });
230
245
 
231
246
  afterEach(async () => {
@@ -908,6 +923,31 @@ describe("workflowCommand prereq checks", () => {
908
923
  expect(cap.stderr).toMatch(/(tmux|psmux) is not installed/);
909
924
  });
910
925
 
926
+ test("returns 1 with a login hint when the user isn't authenticated", async () => {
927
+ // Auth probe runs after `isCommandInstalled` and before tmux/bun
928
+ // installer checks — the workflow must bail before spawning a tmux
929
+ // session users would then have to kill manually.
930
+ checkAgentAuthMock.mockImplementationOnce(async () => ({
931
+ loggedIn: false,
932
+ detail: "oauth token missing",
933
+ }));
934
+
935
+ const cap = captureOutput();
936
+ const code = await workflowCommand({
937
+ name: "anything",
938
+ agent: "copilot",
939
+ cwd: tempDir,
940
+ });
941
+ cap.restore();
942
+
943
+ expect(code).toBe(1);
944
+ expect(cap.stderr).toContain("Not logged in to GitHub Copilot CLI");
945
+ expect(cap.stderr).toContain("oauth token missing");
946
+ expect(cap.stderr).toContain("copilot");
947
+ // Downstream installers never run — the auth gate short-circuits.
948
+ expect(ensureTmuxInstalledMock).not.toHaveBeenCalled();
949
+ });
950
+
911
951
  test("best-effort tmux installer errors are swallowed", async () => {
912
952
  // Even if the installer throws, runPrereqChecks falls through to a
913
953
  // second `isTmuxInstalled()` check — if that still says false, we
@@ -13,6 +13,7 @@
13
13
  import { AGENT_CONFIG, type AgentKey } from "../../services/config/index.ts";
14
14
  import { COLORS, createPainter, type PaletteKey } from "../../theme/colors.ts";
15
15
  import { isCommandInstalled } from "../../services/system/detect.ts";
16
+ import { checkAgentAuth, printAuthError } from "../../services/system/auth.ts";
16
17
  import { ensureTmuxInstalled, ensureBunInstalled } from "../../lib/spawn.ts";
17
18
  import { ensureProjectSetup } from "./init/index.ts";
18
19
  import { ensureAtomicGlobalAgentConfigs } from "../../services/config/atomic-global-config.ts";
@@ -299,6 +300,15 @@ async function runPrereqChecks(agent: AgentKey): Promise<number> {
299
300
  return 1;
300
301
  }
301
302
 
303
+ // Fail fast when the SDK reports the user isn't authenticated —
304
+ // otherwise the workflow spawns the agent CLI in a detached tmux pane
305
+ // and silently stalls on a login screen the user never sees.
306
+ const auth = await checkAgentAuth(agent);
307
+ if (!auth.loggedIn) {
308
+ printAuthError(agent, auth);
309
+ return 1;
310
+ }
311
+
302
312
  if (!isTmuxInstalled()) {
303
313
  console.log("Terminal multiplexer not found. Installing...");
304
314
  try {
package/src/lib/merge.ts CHANGED
@@ -10,28 +10,41 @@ type McpConfig = Record<string, unknown>;
10
10
  /** Keys that hold named-object maps (server registries). */
11
11
  const SERVER_MAP_KEYS = ["mcpServers", "servers", "lspServers"] as const;
12
12
 
13
+ function stripKeys(config: McpConfig, keys: readonly string[]): McpConfig {
14
+ if (keys.length === 0) return config;
15
+ const next: McpConfig = { ...config };
16
+ for (const key of keys) delete next[key];
17
+ return next;
18
+ }
19
+
13
20
  /**
14
21
  * Merge source JSON file into destination JSON file
15
22
  * - Preserves all existing keys in destination
16
23
  * - Adds/updates keys from source
17
24
  * - For MCP server maps: preserves user's servers, adds/updates CLI-managed servers
25
+ * - `excludeKeys` are stripped from the source before merging so they
26
+ * never propagate to the destination (destination keeps its own value).
18
27
  *
19
28
  * @param srcPath Path to source JSON file
20
29
  * @param destPath Path to destination JSON file (will be modified in place)
30
+ * @param excludeKeys Top-level source keys to drop before merging
21
31
  */
22
32
  export async function mergeJsonFile(
23
33
  srcPath: string,
24
- destPath: string
34
+ destPath: string,
35
+ excludeKeys: readonly string[] = [],
25
36
  ): Promise<void> {
26
37
  if (resolve(srcPath) === resolve(destPath)) {
27
38
  return;
28
39
  }
29
40
 
30
- const [srcConfig, destConfig] = await Promise.all([
41
+ const [rawSrcConfig, destConfig] = await Promise.all([
31
42
  Bun.file(srcPath).json() as Promise<McpConfig>,
32
43
  Bun.file(destPath).json() as Promise<McpConfig>,
33
44
  ]);
34
45
 
46
+ const srcConfig = stripKeys(rawSrcConfig, excludeKeys);
47
+
35
48
  // Merge top-level config - preserve destination's other keys
36
49
  const mergedConfig: McpConfig = {
37
50
  ...destConfig,
@@ -59,6 +72,9 @@ export async function mergeJsonFile(
59
72
  * merges via {@link mergeJsonFile} (source keys win, server maps
60
73
  * are merged individually)
61
74
  * - Otherwise copies the source as-is
75
+ * - `excludeKeys` drops top-level keys from the source before writing,
76
+ * so they never land in the destination (applies to both the merge
77
+ * and no-destination-yet code paths).
62
78
  *
63
79
  * This is the single entry-point for the merge-or-copy pattern used
64
80
  * by both project-level onboarding and global config sync.
@@ -67,12 +83,21 @@ export async function syncJsonFile(
67
83
  srcPath: string,
68
84
  destPath: string,
69
85
  merge: boolean = true,
86
+ excludeKeys: readonly string[] = [],
70
87
  ): Promise<void> {
71
88
  await ensureDir(dirname(destPath));
72
89
 
73
90
  if (merge && (await pathExists(destPath))) {
74
- await mergeJsonFile(srcPath, destPath);
75
- } else {
91
+ await mergeJsonFile(srcPath, destPath, excludeKeys);
92
+ return;
93
+ }
94
+
95
+ if (excludeKeys.length === 0) {
76
96
  await copyFile(srcPath, destPath);
97
+ return;
77
98
  }
99
+
100
+ const srcConfig = (await Bun.file(srcPath).json()) as McpConfig;
101
+ const stripped = stripKeys(srcConfig, excludeKeys);
102
+ await Bun.write(destPath, JSON.stringify(stripped, null, 2) + "\n");
78
103
  }
@@ -71,6 +71,12 @@ export async function clearClaudeSession(paneId: string): Promise<void> {
71
71
  // Best-effort — if release fails the hook will still exit on its
72
72
  // own safety timeout.
73
73
  }
74
+ try {
75
+ await unlinkAtomicPidFile(state.claudeSessionId);
76
+ } catch {
77
+ // Best-effort — stale pid file is inert; the next session writes a
78
+ // fresh one under its own UUID.
79
+ }
74
80
  }
75
81
  initializedPanes.delete(paneId);
76
82
  }
@@ -258,6 +264,12 @@ export async function createClaudeSession(options: ClaudeSessionOptions): Promis
258
264
  chatFlags,
259
265
  readyTimeoutMs,
260
266
  });
267
+
268
+ // Write our PID so the Stop hook can detect an orphaned session if we
269
+ // crash/get SIGKILL'd without running teardown. Best-effort; failures just
270
+ // mean the hook falls back to waiting out Claude's own hook timeout.
271
+ await writeAtomicPidFile(claudeSessionId);
272
+
261
273
  return claudeSessionId;
262
274
  }
263
275
 
@@ -609,6 +621,38 @@ export async function releaseClaudeSession(claudeSessionId: string): Promise<voi
609
621
  await writeFile(releasePath(claudeSessionId), "");
610
622
  }
611
623
 
624
+ /** @internal */
625
+ function pidDir(): string {
626
+ return claudeHookDirs().pid;
627
+ }
628
+
629
+ /** @internal */
630
+ function pidFilePath(claudeSessionId: string): string {
631
+ return join(pidDir(), claudeSessionId);
632
+ }
633
+
634
+ /**
635
+ * Write `process.pid` to `~/.atomic/claude-pid/<session_id>` so the Stop hook
636
+ * can use it as a liveness signal. If atomic is SIGKILL'd (no chance to run
637
+ * `clearClaudeSession`), the hook detects the dead PID via `process.kill(..,0)`
638
+ * and self-exits instead of parking Claude for the full 24-day timeout.
639
+ */
640
+ async function writeAtomicPidFile(claudeSessionId: string): Promise<void> {
641
+ await mkdir(pidDir(), { recursive: true });
642
+ await writeFile(pidFilePath(claudeSessionId), String(process.pid), "utf-8");
643
+ }
644
+
645
+ /** Remove the pid file for a session. Idempotent — ENOENT is swallowed. */
646
+ async function unlinkAtomicPidFile(claudeSessionId: string): Promise<void> {
647
+ try {
648
+ await unlink(pidFilePath(claudeSessionId));
649
+ } catch (e: unknown) {
650
+ if (!(e instanceof Error && "code" in e && (e as NodeJS.ErrnoException).code === "ENOENT")) {
651
+ throw e;
652
+ }
653
+ }
654
+ }
655
+
612
656
  // ---------------------------------------------------------------------------
613
657
  // Idle detection via marker file watch
614
658
  // ---------------------------------------------------------------------------
@@ -1045,6 +1089,40 @@ export class HeadlessClaudeClientWrapper {
1045
1089
  async stop(): Promise<void> {}
1046
1090
  }
1047
1091
 
1092
+ /**
1093
+ * Resolve the `claude` CLI binary for headless SDK queries.
1094
+ *
1095
+ * Pins the SDK to the same binary interactive stages already spawn via tmux
1096
+ * (`AGENT_CONFIG.claude.cmd` on PATH), bypassing
1097
+ * `@anthropic-ai/claude-agent-sdk`'s built-in resolver. That resolver probes
1098
+ * optional native packages in a fixed order — on Linux it tries
1099
+ * `linux-${arch}-musl` before `linux-${arch}` and returns whichever
1100
+ * `require.resolve` finds first — so on a glibc host where both optional
1101
+ * packages got installed (Bun installs every optionalDependency by default)
1102
+ * it picks the musl binary, which can't exec because its dynamic linker
1103
+ * (`/lib/ld-musl-*.so.1`) is absent. The SDK surfaces the resulting ENOENT
1104
+ * as a misleading "Claude Code native binary not found" error.
1105
+ *
1106
+ * `chatCommand` and `workflowCommand` already fail fast when `claude` isn't
1107
+ * on PATH (see `isCommandInstalled` in each), so in practice this lookup
1108
+ * always succeeds. The throw here is a belt-and-suspenders guard that
1109
+ * prefers a clear failure over silently falling back to the SDK's resolver.
1110
+ */
1111
+ export function resolveHeadlessClaudeBin(): string {
1112
+ // Pass PATH explicitly — the 1-arg form of Bun.which caches the value
1113
+ // captured at process start, which makes the lookup insensitive to later
1114
+ // env mutations (and un-exercisable from tests that tweak `process.env.PATH`).
1115
+ const onPath = Bun.which("claude", { PATH: process.env.PATH ?? "" });
1116
+ if (!onPath) {
1117
+ throw new Error(
1118
+ "`claude` CLI not found on PATH. Install Claude Code via the native " +
1119
+ "installer (https://docs.claude.com/en/docs/claude-code/overview) " +
1120
+ "and retry.",
1121
+ );
1122
+ }
1123
+ return onPath;
1124
+ }
1125
+
1048
1126
  /**
1049
1127
  * Headless session wrapper for Claude stages. Uses the Agent SDK's `query()`
1050
1128
  * directly instead of tmux pane operations. Implements the same `query()`
@@ -1080,6 +1158,8 @@ export class HeadlessClaudeSessionWrapper {
1080
1158
  const sdkOpts = options ?? {};
1081
1159
  const headlessSdkOpts: Partial<SDKOptions> = {
1082
1160
  ...sdkOpts,
1161
+ pathToClaudeCodeExecutable:
1162
+ sdkOpts.pathToClaudeCodeExecutable ?? resolveHeadlessClaudeBin(),
1083
1163
  disallowedTools: mergeDisallowedTools(sdkOpts.disallowedTools, [
1084
1164
  "AskUserQuestion",
1085
1165
  ]),
@@ -7,6 +7,26 @@
7
7
 
8
8
  import { createProviderValidator } from "../types.ts";
9
9
 
10
+ /**
11
+ * Env inherited by the Copilot CLI subprocess the SDK spawns.
12
+ *
13
+ * `NODE_NO_WARNINGS=1` silences the
14
+ * `ExperimentalWarning: SQLite is an experimental feature` banner that
15
+ * Node prints via the CLI's bundled `require("node:sqlite")`. The SDK
16
+ * pipes the subprocess's stderr through `process.stderr` with a
17
+ * `[CLI subprocess]` prefix, so without this override the warning
18
+ * leaks into every `atomic chat -a copilot` and `atomic workflow -a
19
+ * copilot` invocation.
20
+ *
21
+ * The SDK uses `options.env ?? process.env` as-is (no merge) when
22
+ * spawning, so we must fold the existing env in ourselves. Returns a
23
+ * fresh object per call so callers can layer additional env without
24
+ * mutating shared state.
25
+ */
26
+ export function copilotSubprocessEnv(): Record<string, string | undefined> {
27
+ return { ...process.env, NODE_NO_WARNINGS: "1" };
28
+ }
29
+
10
30
  /**
11
31
  * Validate a Copilot workflow source file for common mistakes.
12
32
  */
@@ -8,7 +8,10 @@
8
8
  */
9
9
 
10
10
  import { test, expect, describe } from "bun:test";
11
- import { mergeDisallowedTools } from "./claude.ts";
11
+ import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { mergeDisallowedTools, resolveHeadlessClaudeBin } from "./claude.ts";
12
15
  import {
13
16
  HEADLESS_OPENCODE_CLIENT_ID,
14
17
  withHeadlessOpencodeEnv,
@@ -63,6 +66,43 @@ describe("mergeExcludedTools (Copilot)", () => {
63
66
  });
64
67
  });
65
68
 
69
+ // ---------------------------------------------------------------------------
70
+ // Claude — headless binary resolution pins to the PATH `claude` CLI
71
+ // ---------------------------------------------------------------------------
72
+
73
+ describe("resolveHeadlessClaudeBin", () => {
74
+ const withPath = (path: string, fn: () => void) => {
75
+ const before = process.env.PATH;
76
+ process.env.PATH = path;
77
+ try {
78
+ fn();
79
+ } finally {
80
+ if (before === undefined) delete process.env.PATH;
81
+ else process.env.PATH = before;
82
+ }
83
+ };
84
+
85
+ test("returns the `claude` binary when present on PATH", () => {
86
+ const dir = mkdtempSync(join(tmpdir(), "atomic-claude-bin-"));
87
+ const bin = join(dir, "claude");
88
+ writeFileSync(bin, "#!/usr/bin/env sh\nexit 0\n");
89
+ chmodSync(bin, 0o755);
90
+ withPath(dir, () => {
91
+ expect(resolveHeadlessClaudeBin()).toBe(bin);
92
+ });
93
+ });
94
+
95
+ test("throws with installer URL when PATH has no `claude`", () => {
96
+ const empty = mkdtempSync(join(tmpdir(), "atomic-empty-path-"));
97
+ withPath(empty, () => {
98
+ expect(() => resolveHeadlessClaudeBin()).toThrow(/CLI not found on PATH/);
99
+ expect(() => resolveHeadlessClaudeBin()).toThrow(
100
+ /docs\.claude\.com.*claude-code/,
101
+ );
102
+ });
103
+ });
104
+ });
105
+
66
106
  // ---------------------------------------------------------------------------
67
107
  // OpenCode — OPENCODE_CLIENT override excludes the question tool
68
108
  // ---------------------------------------------------------------------------
@@ -1207,12 +1207,21 @@ async function initProviderClientAndSession<A extends AgentType>(
1207
1207
  switch (agent) {
1208
1208
  case "copilot": {
1209
1209
  const { CopilotClient, approveAll } = await import("@github/copilot-sdk");
1210
+ const { copilotSubprocessEnv } = await import("../providers/copilot.ts");
1210
1211
  const copilotClientOpts = clientOpts as StageClientOptions<"copilot">;
1211
1212
  const copilotSessionOpts = sessionOpts as StageSessionOptions<"copilot">;
1212
1213
  // Headless: let the SDK spawn its own CLI process (no cliUrl).
1213
1214
  // Non-headless: connect to the CLI server running in a tmux pane.
1215
+ // `env` is only meaningful in the headless path — the SDK ignores
1216
+ // it when `cliUrl` is set — but layering in `copilotSubprocessEnv`
1217
+ // when the caller didn't supply their own env keeps the
1218
+ // SQLite `ExperimentalWarning` from leaking through the SDK's
1219
+ // `[CLI subprocess]` stderr forwarder.
1214
1220
  const client = headless
1215
- ? new CopilotClient({ ...copilotClientOpts })
1221
+ ? new CopilotClient({
1222
+ env: copilotSubprocessEnv(),
1223
+ ...copilotClientOpts,
1224
+ })
1216
1225
  : new CopilotClient({ ...copilotClientOpts, cliUrl: serverUrl });
1217
1226
  await client.start();
1218
1227
  // In headless stages, add `ask_user` to the session's excludedTools so
@@ -22,6 +22,13 @@ export interface AgentConfig {
22
22
  source: string;
23
23
  destination: string;
24
24
  merge: boolean;
25
+ /**
26
+ * Top-level keys to strip from the source before copying or merging.
27
+ * Useful when a key is project-local by design (e.g. Claude's
28
+ * `disabledMcpjsonServers`) and must not leak into a global
29
+ * destination like `~/.claude/settings.json`.
30
+ */
31
+ excludeConfigKeys?: readonly string[];
25
32
  }>;
26
33
  }
27
34
 
@@ -55,6 +62,9 @@ export const AGENT_CONFIG: Record<AgentKey, AgentConfig> = {
55
62
  source: ".claude/settings.json",
56
63
  destination: "~/.claude/settings.json",
57
64
  merge: true,
65
+ // `disabledMcpjsonServers` is reconciled per-project by scm-sync
66
+ // and must not leak into the user's global Claude settings.
67
+ excludeConfigKeys: ["disabledMcpjsonServers"],
58
68
  },
59
69
  ],
60
70
  },
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Tests for the SDK-level auth probes in `auth.ts`.
3
+ *
4
+ * Both the Copilot SDK and Claude Agent SDK spawn native agent binaries
5
+ * under the hood, which makes the probes unsuitable for unit tests on a
6
+ * CI runner that has neither binary installed. We `mock.module()` each
7
+ * SDK so the probes read from in-test fakes, then assert the wrapper's
8
+ * translation into `AuthCheckResult` shape.
9
+ */
10
+
11
+ import {
12
+ describe,
13
+ test,
14
+ expect,
15
+ beforeEach,
16
+ mock,
17
+ } from "bun:test";
18
+
19
+ // ─── Copilot SDK fake ──────────────────────────────────────────────────────
20
+ // `CopilotClient` is a class; the constructor captures latest test state.
21
+ // We swap `start` / `stop` / `getAuthStatus` per-test via mockable fns.
22
+
23
+ interface CopilotAuthStatus {
24
+ isAuthenticated: boolean;
25
+ login?: string;
26
+ statusMessage?: string;
27
+ }
28
+
29
+ let copilotStart = mock(async () => {});
30
+ let copilotStop = mock(async () => [] as unknown[]);
31
+ let copilotGetAuthStatus = mock<() => Promise<CopilotAuthStatus>>(async () => ({
32
+ isAuthenticated: true,
33
+ login: "octocat",
34
+ }));
35
+
36
+ class FakeCopilotClient {
37
+ async start(): Promise<void> {
38
+ await copilotStart();
39
+ }
40
+ async stop(): Promise<unknown[]> {
41
+ return copilotStop();
42
+ }
43
+ async getAuthStatus(): Promise<CopilotAuthStatus> {
44
+ return copilotGetAuthStatus();
45
+ }
46
+ }
47
+
48
+ mock.module("@github/copilot-sdk", () => ({
49
+ CopilotClient: FakeCopilotClient,
50
+ }));
51
+
52
+ // ─── Claude Agent SDK fake ────────────────────────────────────────────────
53
+ // `query()` returns something with `initializationResult()` and `close()`.
54
+ // We ignore the `prompt` stream — the real SDK consumes it lazily, and the
55
+ // probe only calls `initializationResult()` before closing.
56
+
57
+ interface ClaudeAccount {
58
+ email?: string;
59
+ tokenSource?: string;
60
+ apiKeySource?: string;
61
+ }
62
+
63
+ let claudeInit = mock<() => Promise<{ account: ClaudeAccount }>>(async () => ({
64
+ account: { email: "user@example.com", tokenSource: "oauth" },
65
+ }));
66
+ let claudeClose = mock(() => {});
67
+
68
+ mock.module("@anthropic-ai/claude-agent-sdk", () => ({
69
+ query: () => ({
70
+ initializationResult: () => claudeInit(),
71
+ close: () => claudeClose(),
72
+ }),
73
+ }));
74
+
75
+ // Stub the claude provider module so we don't probe PATH for `claude`.
76
+ mock.module("../../sdk/providers/claude.ts", () => ({
77
+ resolveHeadlessClaudeBin: () => "/usr/local/bin/claude",
78
+ }));
79
+
80
+ const { checkAgentAuth } = await import("./auth.ts");
81
+
82
+ beforeEach(() => {
83
+ copilotStart.mockClear();
84
+ copilotStart.mockImplementation(async () => {});
85
+ copilotStop.mockClear();
86
+ copilotStop.mockImplementation(async () => []);
87
+ copilotGetAuthStatus.mockClear();
88
+ copilotGetAuthStatus.mockImplementation(async () => ({
89
+ isAuthenticated: true,
90
+ login: "octocat",
91
+ }));
92
+ claudeInit.mockClear();
93
+ claudeInit.mockImplementation(async () => ({
94
+ account: { email: "user@example.com", tokenSource: "oauth" },
95
+ }));
96
+ claudeClose.mockClear();
97
+ claudeClose.mockImplementation(() => {});
98
+ });
99
+
100
+ describe("checkAgentAuth(copilot)", () => {
101
+ test("returns loggedIn=true when the SDK reports isAuthenticated", async () => {
102
+ const result = await checkAgentAuth("copilot");
103
+ expect(result.loggedIn).toBe(true);
104
+ expect(result.identity).toBe("octocat");
105
+ // Hygiene: client must be stopped even on the happy path so we
106
+ // don't leak a long-running CLI subprocess.
107
+ expect(copilotStop).toHaveBeenCalledTimes(1);
108
+ });
109
+
110
+ test("returns loggedIn=false when the SDK reports isAuthenticated=false", async () => {
111
+ copilotGetAuthStatus.mockImplementationOnce(async () => ({
112
+ isAuthenticated: false,
113
+ statusMessage: "no credentials on disk",
114
+ }));
115
+ const result = await checkAgentAuth("copilot");
116
+ expect(result.loggedIn).toBe(false);
117
+ expect(result.detail).toBe("no credentials on disk");
118
+ });
119
+
120
+ test("returns loggedIn=false when the SDK throws on start", async () => {
121
+ copilotStart.mockImplementationOnce(async () => {
122
+ throw new Error("CLI not installed");
123
+ });
124
+ const result = await checkAgentAuth("copilot");
125
+ expect(result.loggedIn).toBe(false);
126
+ expect(result.detail).toContain("CLI not installed");
127
+ });
128
+
129
+ test("swallows errors from stop() on the failure path", async () => {
130
+ copilotGetAuthStatus.mockImplementationOnce(async () => {
131
+ throw new Error("auth probe failed");
132
+ });
133
+ copilotStop.mockImplementationOnce(async () => {
134
+ throw new Error("stop crashed");
135
+ });
136
+ const result = await checkAgentAuth("copilot");
137
+ expect(result.loggedIn).toBe(false);
138
+ expect(result.detail).toContain("auth probe failed");
139
+ // The stop failure must not shadow the probe result.
140
+ expect(result.detail).not.toContain("stop crashed");
141
+ });
142
+ });
143
+
144
+ describe("checkAgentAuth(claude)", () => {
145
+ test("returns loggedIn=true when initializationResult has account email", async () => {
146
+ const result = await checkAgentAuth("claude");
147
+ expect(result.loggedIn).toBe(true);
148
+ expect(result.identity).toBe("user@example.com");
149
+ expect(claudeClose).toHaveBeenCalledTimes(1);
150
+ });
151
+
152
+ test("returns loggedIn=true when only tokenSource is populated", async () => {
153
+ claudeInit.mockImplementationOnce(async () => ({
154
+ account: { tokenSource: "oauth" },
155
+ }));
156
+ const result = await checkAgentAuth("claude");
157
+ expect(result.loggedIn).toBe(true);
158
+ });
159
+
160
+ test("returns loggedIn=true when only apiKeySource is populated", async () => {
161
+ claudeInit.mockImplementationOnce(async () => ({
162
+ account: { apiKeySource: "env" },
163
+ }));
164
+ const result = await checkAgentAuth("claude");
165
+ expect(result.loggedIn).toBe(true);
166
+ });
167
+
168
+ test("returns loggedIn=false when account is empty", async () => {
169
+ claudeInit.mockImplementationOnce(async () => ({ account: {} }));
170
+ const result = await checkAgentAuth("claude");
171
+ expect(result.loggedIn).toBe(false);
172
+ });
173
+
174
+ test("returns loggedIn=false when initializationResult throws", async () => {
175
+ claudeInit.mockImplementationOnce(async () => {
176
+ throw new Error("subprocess init failed — check authentication");
177
+ });
178
+ const result = await checkAgentAuth("claude");
179
+ expect(result.loggedIn).toBe(false);
180
+ expect(result.detail).toContain("subprocess init failed");
181
+ });
182
+ });
183
+
184
+ describe("checkAgentAuth(opencode)", () => {
185
+ test("is a no-op — returns loggedIn=true without probing the SDK", async () => {
186
+ // OpenCode handles auth interactively on first use; there's no
187
+ // equivalent RPC probe, so the wrapper short-circuits.
188
+ const result = await checkAgentAuth("opencode");
189
+ expect(result.loggedIn).toBe(true);
190
+ // Confirm neither SDK fake was touched.
191
+ expect(copilotStart).not.toHaveBeenCalled();
192
+ expect(claudeInit).not.toHaveBeenCalled();
193
+ });
194
+ });