@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.
- package/.claude/settings.json +0 -1
- package/.opencode/opencode.json +7 -2
- package/dist/commands/cli/claude-stop-hook.d.ts +12 -1
- package/dist/commands/cli/claude-stop-hook.d.ts.map +1 -1
- package/dist/sdk/providers/claude.d.ts +20 -0
- package/dist/sdk/providers/claude.d.ts.map +1 -1
- package/dist/sdk/providers/copilot.d.ts +17 -0
- package/dist/sdk/providers/copilot.d.ts.map +1 -1
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/services/config/definitions.d.ts +7 -0
- package/dist/services/config/definitions.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/commands/cli/chat/index.ts +11 -0
- package/src/commands/cli/claude-stop-hook.test.ts +51 -5
- package/src/commands/cli/claude-stop-hook.ts +216 -13
- package/src/commands/cli/init/onboarding.ts +6 -1
- package/src/commands/cli/workflow-command.test.ts +40 -0
- package/src/commands/cli/workflow.ts +10 -0
- package/src/lib/merge.ts +29 -4
- package/src/sdk/providers/claude.ts +80 -0
- package/src/sdk/providers/copilot.ts +20 -0
- package/src/sdk/providers/headless-hil-policy.test.ts +41 -1
- package/src/sdk/runtime/executor.ts +10 -1
- package/src/services/config/definitions.ts +10 -0
- package/src/services/system/auth.test.ts +194 -0
- package/src/services/system/auth.ts +137 -0
|
@@ -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 [
|
|
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
|
-
|
|
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 {
|
|
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({
|
|
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
|
+
});
|