@bastani/atomic 0.6.6-0 → 0.6.6

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.
@@ -0,0 +1,365 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { chmodSync, mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import {
6
+ copilotSubprocessEnv,
7
+ copilotSdkLaunchOptions,
8
+ enumeratePathCandidates,
9
+ isCopilotShim,
10
+ resolveCopilotCliPath,
11
+ } from "./copilot.ts";
12
+
13
+ // ── helpers ──────────────────────────────────────────────────────────────────
14
+
15
+ function makeTempCopilotBin(): { dir: string; bin: string } {
16
+ const dir = mkdtempSync(join(tmpdir(), "atomic-copilot-test-"));
17
+ const bin = join(dir, "copilot");
18
+ writeFileSync(bin, "#!/bin/sh\necho copilot\n", { encoding: "utf-8" });
19
+ chmodSync(bin, 0o755);
20
+ return { dir, bin };
21
+ }
22
+
23
+ function makeEmptyTempDir(): string {
24
+ return mkdtempSync(join(tmpdir(), "atomic-copilot-empty-"));
25
+ }
26
+
27
+ /** Creates a dir with a `copilot` file that has a node shebang (shim). */
28
+ function makeNodeShimDir(): string {
29
+ const dir = mkdtempSync(join(tmpdir(), "atomic-copilot-shim-"));
30
+ const bin = join(dir, "copilot");
31
+ writeFileSync(bin, "#!/usr/bin/env node\nconsole.log('shim');\n", { encoding: "utf-8" });
32
+ chmodSync(bin, 0o755);
33
+ return dir;
34
+ }
35
+
36
+ /** Creates a dir with a `copilot` file that has a .js extension. */
37
+ function makeJsExtensionDir(): string {
38
+ const dir = mkdtempSync(join(tmpdir(), "atomic-copilot-jsext-"));
39
+ const bin = join(dir, "copilot.js");
40
+ writeFileSync(bin, "#!/usr/bin/env node\n", { encoding: "utf-8" });
41
+ chmodSync(bin, 0o755);
42
+ return dir;
43
+ }
44
+
45
+ /** Creates a node_modules/.bin/copilot symlink pointing to a .js loader file. */
46
+ function makeNpmLoaderShimDir(): { dir: string; bin: string; target: string } {
47
+ const dir = mkdtempSync(join(tmpdir(), "atomic-copilot-npmloader-"));
48
+ const binDir = join(dir, "node_modules", ".bin");
49
+ mkdirSync(binDir, { recursive: true });
50
+ const target = join(dir, "npm-loader.js");
51
+ writeFileSync(target, "// npm-loader.js shim\n", { encoding: "utf-8" });
52
+ const bin = join(binDir, "copilot");
53
+ symlinkSync(target, bin);
54
+ return { dir, bin, target };
55
+ }
56
+
57
+ /** Creates a dir with a `copilot` file containing npm-loader.js marker in header. */
58
+ function makeNpmLoaderMarkerDir(): string {
59
+ const dir = mkdtempSync(join(tmpdir(), "atomic-copilot-npmmarker-"));
60
+ const bin = join(dir, "copilot");
61
+ writeFileSync(bin, "#!/bin/sh\n# loads npm-loader.js\n", { encoding: "utf-8" });
62
+ chmodSync(bin, 0o755);
63
+ return dir;
64
+ }
65
+
66
+ // ── resolveCopilotCliPath ─────────────────────────────────────────────────
67
+
68
+ describe("resolveCopilotCliPath", () => {
69
+ let origCliPath: string | undefined;
70
+ let origPath: string | undefined;
71
+
72
+ beforeEach(() => {
73
+ origCliPath = process.env["COPILOT_CLI_PATH"];
74
+ origPath = process.env["PATH"];
75
+ });
76
+
77
+ afterEach(() => {
78
+ if (origCliPath === undefined) {
79
+ delete process.env["COPILOT_CLI_PATH"];
80
+ } else {
81
+ process.env["COPILOT_CLI_PATH"] = origCliPath;
82
+ }
83
+ if (origPath === undefined) {
84
+ delete process.env["PATH"];
85
+ } else {
86
+ process.env["PATH"] = origPath;
87
+ }
88
+ });
89
+
90
+ test("COPILOT_CLI_PATH env var takes precedence over PATH", () => {
91
+ const explicit = "/custom/bin/copilot";
92
+ process.env["COPILOT_CLI_PATH"] = explicit;
93
+ // even if PATH has a copilot binary, the env var wins
94
+ expect(resolveCopilotCliPath()).toBe(explicit);
95
+ });
96
+
97
+ test("PATH-resolved copilot binary populates cliPath when env var unset", () => {
98
+ delete process.env["COPILOT_CLI_PATH"];
99
+ const { dir, bin } = makeTempCopilotBin();
100
+ process.env["PATH"] = `${dir}:${origPath ?? ""}`;
101
+ const result = resolveCopilotCliPath();
102
+ expect(result).toBe(bin);
103
+ });
104
+
105
+ test("returns undefined when copilot not on PATH and env var unset", () => {
106
+ delete process.env["COPILOT_CLI_PATH"];
107
+ const emptyDir = makeEmptyTempDir();
108
+ process.env["PATH"] = emptyDir;
109
+ const result = resolveCopilotCliPath();
110
+ expect(result).toBeUndefined();
111
+ });
112
+ });
113
+
114
+ // ── copilotSubprocessEnv ──────────────────────────────────────────────────
115
+
116
+ describe("copilotSubprocessEnv", () => {
117
+ test("NODE_NO_WARNINGS is set to '1'", () => {
118
+ const env = copilotSubprocessEnv({});
119
+ expect(env["NODE_NO_WARNINGS"]).toBe("1");
120
+ });
121
+
122
+ test("UTF-8 locale defaults applied when base env empty", () => {
123
+ const env = copilotSubprocessEnv({});
124
+ expect(env["LANG"]).toBe("en_US.UTF-8");
125
+ expect(env["LC_ALL"]).toBe("en_US.UTF-8");
126
+ expect(env["LC_CTYPE"]).toBe("en_US.UTF-8");
127
+ });
128
+
129
+ test("UTF-8 env vars from base merged into result", () => {
130
+ const base = {
131
+ LANG: "fr_FR.UTF-8",
132
+ LC_ALL: "fr_FR.UTF-8",
133
+ LC_CTYPE: "fr_FR.UTF-8",
134
+ MY_CUSTOM: "hello",
135
+ };
136
+ const env = copilotSubprocessEnv(base);
137
+ expect(env["LANG"]).toBe("fr_FR.UTF-8");
138
+ expect(env["LC_ALL"]).toBe("fr_FR.UTF-8");
139
+ expect(env["MY_CUSTOM"]).toBe("hello");
140
+ expect(env["NODE_NO_WARNINGS"]).toBe("1");
141
+ });
142
+
143
+ test("NODE_NO_WARNINGS=1 overrides any base value", () => {
144
+ const env = copilotSubprocessEnv({ NODE_NO_WARNINGS: "0" });
145
+ expect(env["NODE_NO_WARNINGS"]).toBe("1");
146
+ });
147
+
148
+ test("returns fresh object per call (no shared state)", () => {
149
+ const a = copilotSubprocessEnv({});
150
+ const b = copilotSubprocessEnv({});
151
+ expect(a).not.toBe(b);
152
+ });
153
+ });
154
+
155
+ // ── copilotSdkLaunchOptions ───────────────────────────────────────────────
156
+
157
+ describe("copilotSdkLaunchOptions", () => {
158
+ let origCliPath: string | undefined;
159
+ let origPath: string | undefined;
160
+
161
+ beforeEach(() => {
162
+ origCliPath = process.env["COPILOT_CLI_PATH"];
163
+ origPath = process.env["PATH"];
164
+ });
165
+
166
+ afterEach(() => {
167
+ if (origCliPath === undefined) {
168
+ delete process.env["COPILOT_CLI_PATH"];
169
+ } else {
170
+ process.env["COPILOT_CLI_PATH"] = origCliPath;
171
+ }
172
+ if (origPath === undefined) {
173
+ delete process.env["PATH"];
174
+ } else {
175
+ process.env["PATH"] = origPath;
176
+ }
177
+ });
178
+
179
+ test("env contains NODE_NO_WARNINGS=1", () => {
180
+ const opts = copilotSdkLaunchOptions();
181
+ expect(opts.env?.["NODE_NO_WARNINGS"]).toBe("1");
182
+ });
183
+
184
+ test("cliPath populated from COPILOT_CLI_PATH", () => {
185
+ process.env["COPILOT_CLI_PATH"] = "/my/copilot";
186
+ const opts = copilotSdkLaunchOptions();
187
+ expect(opts.cliPath).toBe("/my/copilot");
188
+ });
189
+
190
+ test("cliPath omitted when copilot not resolvable", () => {
191
+ delete process.env["COPILOT_CLI_PATH"];
192
+ const emptyDir = makeEmptyTempDir();
193
+ process.env["PATH"] = emptyDir;
194
+ const opts = copilotSdkLaunchOptions();
195
+ expect("cliPath" in opts).toBe(false);
196
+ });
197
+
198
+ test("cliPath populated from PATH-resolved binary", () => {
199
+ delete process.env["COPILOT_CLI_PATH"];
200
+ const { dir, bin } = makeTempCopilotBin();
201
+ process.env["PATH"] = `${dir}:${origPath ?? ""}`;
202
+ const opts = copilotSdkLaunchOptions();
203
+ expect(opts.cliPath).toBe(bin);
204
+ });
205
+
206
+ test("cliPath omitted when only shim on PATH", () => {
207
+ delete process.env["COPILOT_CLI_PATH"];
208
+ const shimDir = makeNodeShimDir();
209
+ process.env["PATH"] = shimDir;
210
+ const opts = copilotSdkLaunchOptions();
211
+ expect("cliPath" in opts).toBe(false);
212
+ });
213
+ });
214
+
215
+ // ── isCopilotShim ─────────────────────────────────────────────────────────
216
+
217
+ describe("isCopilotShim", () => {
218
+ test("returns true for .js extension", () => {
219
+ const dir = mkdtempSync(join(tmpdir(), "atomic-shim-jsext-"));
220
+ const p = join(dir, "copilot.js");
221
+ writeFileSync(p, "#!/usr/bin/env node\n", { encoding: "utf-8" });
222
+ expect(isCopilotShim(p)).toBe(true);
223
+ });
224
+
225
+ test("returns true for .mjs extension", () => {
226
+ const dir = mkdtempSync(join(tmpdir(), "atomic-shim-mjs-"));
227
+ const p = join(dir, "copilot.mjs");
228
+ writeFileSync(p, "export default {}\n", { encoding: "utf-8" });
229
+ expect(isCopilotShim(p)).toBe(true);
230
+ });
231
+
232
+ test("returns true for .cjs extension", () => {
233
+ const dir = mkdtempSync(join(tmpdir(), "atomic-shim-cjs-"));
234
+ const p = join(dir, "copilot.cjs");
235
+ writeFileSync(p, "module.exports = {}\n", { encoding: "utf-8" });
236
+ expect(isCopilotShim(p)).toBe(true);
237
+ });
238
+
239
+ test("returns true for #!/usr/bin/env node shebang", () => {
240
+ const dir = mkdtempSync(join(tmpdir(), "atomic-shim-shebang-"));
241
+ const p = join(dir, "copilot");
242
+ writeFileSync(p, "#!/usr/bin/env node\nconsole.log('hi');\n", { encoding: "utf-8" });
243
+ chmodSync(p, 0o755);
244
+ expect(isCopilotShim(p)).toBe(true);
245
+ });
246
+
247
+ test("returns true for #!/usr/bin/node shebang", () => {
248
+ const dir = mkdtempSync(join(tmpdir(), "atomic-shim-shebang2-"));
249
+ const p = join(dir, "copilot");
250
+ writeFileSync(p, "#!/usr/bin/node\nconsole.log('hi');\n", { encoding: "utf-8" });
251
+ chmodSync(p, 0o755);
252
+ expect(isCopilotShim(p)).toBe(true);
253
+ });
254
+
255
+ test("returns true when header contains npm-loader.js marker", () => {
256
+ const dir = mkdtempSync(join(tmpdir(), "atomic-shim-marker-"));
257
+ const p = join(dir, "copilot");
258
+ writeFileSync(p, "#!/bin/sh\n# loads npm-loader.js internally\n", { encoding: "utf-8" });
259
+ chmodSync(p, 0o755);
260
+ expect(isCopilotShim(p)).toBe(true);
261
+ });
262
+
263
+ test("returns true for node_modules/.bin/copilot symlink pointing to .js file", () => {
264
+ const { bin } = makeNpmLoaderShimDir();
265
+ expect(isCopilotShim(bin)).toBe(true);
266
+ });
267
+
268
+ test("returns false for plain shell script binary", () => {
269
+ const dir = mkdtempSync(join(tmpdir(), "atomic-shim-shell-"));
270
+ const p = join(dir, "copilot");
271
+ writeFileSync(p, "#!/bin/sh\necho copilot\n", { encoding: "utf-8" });
272
+ chmodSync(p, 0o755);
273
+ expect(isCopilotShim(p)).toBe(false);
274
+ });
275
+
276
+ test("returns false for missing file (let SDK surface the error)", () => {
277
+ expect(isCopilotShim("/nonexistent/path/copilot")).toBe(false);
278
+ });
279
+ });
280
+
281
+ // ── enumeratePathCandidates ───────────────────────────────────────────────
282
+
283
+ describe("enumeratePathCandidates", () => {
284
+ test("returns empty array when PATH has no matching binary", () => {
285
+ const empty = makeEmptyTempDir();
286
+ expect(enumeratePathCandidates("copilot", empty)).toEqual([]);
287
+ });
288
+
289
+ test("returns single match when one dir has binary", () => {
290
+ const { dir, bin } = makeTempCopilotBin();
291
+ const result = enumeratePathCandidates("copilot", dir);
292
+ expect(result).toEqual([bin]);
293
+ });
294
+
295
+ test("returns ordered matches from multiple PATH dirs", () => {
296
+ const { dir: dir1, bin: bin1 } = makeTempCopilotBin();
297
+ const { dir: dir2, bin: bin2 } = makeTempCopilotBin();
298
+ const result = enumeratePathCandidates("copilot", `${dir1}:${dir2}`);
299
+ expect(result).toEqual([bin1, bin2]);
300
+ });
301
+
302
+ test("skips dirs that do not contain the binary", () => {
303
+ const empty = makeEmptyTempDir();
304
+ const { dir, bin } = makeTempCopilotBin();
305
+ const result = enumeratePathCandidates("copilot", `${empty}:${dir}`);
306
+ expect(result).toEqual([bin]);
307
+ });
308
+ });
309
+
310
+ // ── resolveCopilotCliPath shim rejection ──────────────────────────────────
311
+
312
+ describe("resolveCopilotCliPath shim rejection", () => {
313
+ let origCliPath: string | undefined;
314
+ let origPath: string | undefined;
315
+
316
+ beforeEach(() => {
317
+ origCliPath = process.env["COPILOT_CLI_PATH"];
318
+ origPath = process.env["PATH"];
319
+ delete process.env["COPILOT_CLI_PATH"];
320
+ });
321
+
322
+ afterEach(() => {
323
+ if (origCliPath === undefined) {
324
+ delete process.env["COPILOT_CLI_PATH"];
325
+ } else {
326
+ process.env["COPILOT_CLI_PATH"] = origCliPath;
327
+ }
328
+ if (origPath === undefined) {
329
+ delete process.env["PATH"];
330
+ } else {
331
+ process.env["PATH"] = origPath;
332
+ }
333
+ });
334
+
335
+ test("returns undefined when only node-shebang shim exists on PATH", () => {
336
+ process.env["PATH"] = makeNodeShimDir();
337
+ expect(resolveCopilotCliPath()).toBeUndefined();
338
+ });
339
+
340
+ test("returns undefined when only npm-loader.js symlink shim on PATH", () => {
341
+ const { dir } = makeNpmLoaderShimDir();
342
+ const binDir = join(dir, "node_modules", ".bin");
343
+ process.env["PATH"] = binDir;
344
+ expect(resolveCopilotCliPath()).toBeUndefined();
345
+ });
346
+
347
+ test("returns undefined when only npm-loader.js marker shim on PATH", () => {
348
+ process.env["PATH"] = makeNpmLoaderMarkerDir();
349
+ expect(resolveCopilotCliPath()).toBeUndefined();
350
+ });
351
+
352
+ test("skips shim and returns second PATH candidate (real binary)", () => {
353
+ const shimDir = makeNodeShimDir();
354
+ const { dir: realDir, bin: realBin } = makeTempCopilotBin();
355
+ process.env["PATH"] = `${shimDir}:${realDir}`;
356
+ expect(resolveCopilotCliPath()).toBe(realBin);
357
+ });
358
+
359
+ test("returns first non-shim when multiple real binaries on PATH", () => {
360
+ const { dir: dir1, bin: bin1 } = makeTempCopilotBin();
361
+ const { dir: dir2 } = makeTempCopilotBin();
362
+ process.env["PATH"] = `${dir1}:${dir2}`;
363
+ expect(resolveCopilotCliPath()).toBe(bin1);
364
+ });
365
+ });
@@ -5,27 +5,135 @@
5
5
  * `s.client` and `s.session` instead of manual SDK client creation.
6
6
  */
7
7
 
8
- import type { SessionConfig as CopilotSessionConfig } from "@github/copilot-sdk";
8
+ import { closeSync, existsSync, openSync, readSync, realpathSync } from "node:fs";
9
+ import { delimiter, join, sep } from "node:path";
10
+ import type {
11
+ CopilotClientOptions,
12
+ SessionConfig as CopilotSessionConfig,
13
+ } from "@github/copilot-sdk";
14
+ import { normalizedTerminalEnv } from "../../lib/terminal-env.ts";
15
+ import { getCommandPath } from "../../services/system/detect.ts";
9
16
  import { createProviderValidator } from "../types.ts";
10
17
 
18
+ const JS_EXT_RE = /\.(js|mjs|cjs)$/i;
19
+ const NODE_SHEBANG_RE = /^#!.*\bnode\b/;
20
+ const NPM_LOADER_MARKER = "npm-loader.js";
21
+ const HEADER_BYTES = 256;
22
+
23
+ /**
24
+ * Read the first {@link HEADER_BYTES} of a file as a UTF-8 string.
25
+ * Returns `null` on any filesystem error (file missing, not readable, etc.).
26
+ */
27
+ function readCandidateHeader(filePath: string): string | null {
28
+ let fd: number | undefined;
29
+ try {
30
+ fd = openSync(filePath, "r");
31
+ const buffer = Buffer.alloc(HEADER_BYTES);
32
+ const bytesRead = readSync(fd, buffer, 0, HEADER_BYTES, 0);
33
+ return buffer.subarray(0, bytesRead).toString("utf8");
34
+ } catch {
35
+ return null;
36
+ } finally {
37
+ if (fd !== undefined) closeSync(fd);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Resolve symlink chain to final target path.
43
+ * Returns the original path on any filesystem error.
44
+ */
45
+ function safeRealpath(filePath: string): string {
46
+ try {
47
+ return realpathSync(filePath);
48
+ } catch {
49
+ return filePath;
50
+ }
51
+ }
52
+
11
53
  /**
12
- * Env inherited by the Copilot CLI subprocess the SDK spawns.
54
+ * Return `true` when `candidate` is a Node.js / npm-loader JavaScript shim
55
+ * that should not be passed to the Copilot SDK as the CLI executable.
13
56
  *
14
- * `NODE_NO_WARNINGS=1` silences the
15
- * `ExperimentalWarning: SQLite is an experimental feature` banner that
16
- * Node prints via the CLI's bundled `require("node:sqlite")`. The SDK
17
- * pipes the subprocess's stderr through `process.stderr` with a
18
- * `[CLI subprocess]` prefix, so without this override the warning
19
- * leaks into every `atomic chat -a copilot` and `atomic workflow -a
20
- * copilot` invocation.
57
+ * Filesystem errors for a given candidate are treated as "not a shim" so
58
+ * that the SDK can surface the real error (e.g. permission denied, ENOENT).
59
+ */
60
+ export function isCopilotShim(candidate: string): boolean {
61
+ if (JS_EXT_RE.test(candidate)) return true;
62
+
63
+ if (candidate.includes(`node_modules${sep}.bin`) || candidate.includes("node_modules/.bin")) {
64
+ const real = safeRealpath(candidate);
65
+ if (JS_EXT_RE.test(real)) return true;
66
+ }
67
+
68
+ const header = readCandidateHeader(candidate);
69
+ if (header === null) return false;
70
+
71
+ return NODE_SHEBANG_RE.test(header) || header.includes(NPM_LOADER_MARKER);
72
+ }
73
+
74
+ /**
75
+ * Build the subprocess environment for the Copilot CLI process.
76
+ * Normalises locale to UTF-8 and suppresses Node.js deprecation warnings.
77
+ */
78
+ export function copilotSubprocessEnv(
79
+ baseEnv: NodeJS.ProcessEnv = process.env,
80
+ ): Record<string, string | undefined> {
81
+ return { ...normalizedTerminalEnv(baseEnv), NODE_NO_WARNINGS: "1" };
82
+ }
83
+
84
+ /**
85
+ * Enumerate every existing `cmd` candidate across PATH order.
86
+ */
87
+ export function enumeratePathCandidates(cmd: string, pathEnv: string): string[] {
88
+ const dirs = pathEnv.split(delimiter).filter(Boolean);
89
+ const results: string[] = [];
90
+ for (const dir of dirs) {
91
+ const full = join(dir, cmd);
92
+ if (existsSync(full)) results.push(full);
93
+ }
94
+ return results;
95
+ }
96
+
97
+ export type CommandPathResolver = (cmd: string) => string | null;
98
+
99
+ export function resolveCopilotCliPath(
100
+ resolveCommandPath: CommandPathResolver = getCommandPath,
101
+ ): string | undefined {
102
+ const envPath = process.env["COPILOT_CLI_PATH"];
103
+ if (envPath) return envPath;
104
+
105
+ const primary = resolveCommandPath("copilot");
106
+ if (primary === null) return undefined;
107
+ if (!isCopilotShim(primary)) return primary;
108
+
109
+ const pathEnv = process.env["PATH"] ?? "";
110
+ const candidates = enumeratePathCandidates("copilot", pathEnv);
111
+ for (const candidate of candidates) {
112
+ if (!isCopilotShim(candidate)) return candidate;
113
+ }
114
+
115
+ return undefined;
116
+ }
117
+
118
+ /**
119
+ * Build options suitable for `new CopilotClient(...)`.
21
120
  *
22
- * The SDK uses `options.env ?? process.env` as-is (no merge) when
23
- * spawning, so we must fold the existing env in ourselves. Returns a
24
- * fresh object per call so callers can layer additional env without
25
- * mutating shared state.
121
+ * Includes:
122
+ * - `env` from {@link copilotSubprocessEnv} (UTF-8 locale + `NODE_NO_WARNINGS=1`).
123
+ * - `cliPath` from {@link resolveCopilotCliPath} when resolvable; omitted
124
+ * otherwise so the SDK falls back to its bundled CLI.
26
125
  */
27
- export function copilotSubprocessEnv(): Record<string, string | undefined> {
28
- return { ...process.env, NODE_NO_WARNINGS: "1" };
126
+ export function copilotSdkLaunchOptions(
127
+ resolveCommandPath: CommandPathResolver = getCommandPath,
128
+ ): CopilotClientOptions {
129
+ const options: CopilotClientOptions = {
130
+ env: copilotSubprocessEnv(),
131
+ };
132
+ const cliPath = resolveCopilotCliPath(resolveCommandPath);
133
+ if (cliPath !== undefined) {
134
+ options.cliPath = cliPath;
135
+ }
136
+ return options;
29
137
  }
30
138
 
31
139
  /**
@@ -18,9 +18,9 @@
18
18
  * confirmation.
19
19
  */
20
20
 
21
- import { tmpdir } from "node:os";
22
21
  import { join } from "node:path";
23
22
  import { readFileSync, writeFileSync } from "node:fs";
23
+ import { ensureAtomicTempDir } from "../../lib/atomic-temp.ts";
24
24
 
25
25
  /** Quiet period (ms) the user must leave between presses for the next
26
26
  * one to be forwarded. Must exceed every integrated agent's exit-confirm
@@ -44,7 +44,7 @@ export function shouldForward(
44
44
  * something with shell metacharacters. */
45
45
  function stateFileFor(paneId: string): string {
46
46
  const safe = paneId.replace(/[^a-zA-Z0-9_%-]/g, "_");
47
- return join(tmpdir(), `atomic-cc-${safe}`);
47
+ return join(ensureAtomicTempDir(), `atomic-cc-${safe}`);
48
48
  }
49
49
 
50
50
  function readLastPress(stateFile: string): number {
@@ -1046,6 +1046,22 @@ describe("buildPaneCommand", () => {
1046
1046
  expect(command).not.toContain("--port");
1047
1047
  });
1048
1048
 
1049
+ test("claude: scopes temp files to the user's Atomic temp directory", () => {
1050
+ const { envVars } = buildPaneCommand("claude");
1051
+ expect(envVars.TMPDIR).toMatch(/\/\.atomic\/tmp$/);
1052
+ expect(envVars.TMP).toBe(envVars.TMPDIR);
1053
+ expect(envVars.TEMP).toBe(envVars.TMPDIR);
1054
+ });
1055
+
1056
+ test("claude: explicit temp env overrides the Atomic default", () => {
1057
+ const { envVars } = buildPaneCommand("claude", {
1058
+ envVars: { TMPDIR: "/custom/tmp", TMP: "/custom/tmp", TEMP: "/custom/tmp" },
1059
+ });
1060
+ expect(envVars.TMPDIR).toBe("/custom/tmp");
1061
+ expect(envVars.TMP).toBe("/custom/tmp");
1062
+ expect(envVars.TEMP).toBe("/custom/tmp");
1063
+ });
1064
+
1049
1065
  test("overrides.envVars merges with defaults for copilot", () => {
1050
1066
  const { envVars } = buildPaneCommand("copilot", {
1051
1067
  envVars: { MY_VAR: "hello" },
@@ -1074,6 +1090,19 @@ describe("buildPaneCommand", () => {
1074
1090
  const { command } = buildPaneCommand("opencode", {}, ["--extra-flag"]);
1075
1091
  expect(command).not.toContain("--extra-flag");
1076
1092
  });
1093
+
1094
+ test("copilot: respects COPILOT_CLI_PATH env var for binary resolution", () => {
1095
+ const origCliPath = process.env["COPILOT_CLI_PATH"];
1096
+ process.env["COPILOT_CLI_PATH"] = "/custom/path/copilot";
1097
+ try {
1098
+ const { command } = buildPaneCommand("copilot");
1099
+ // The command should start with the COPILOT_CLI_PATH binary.
1100
+ expect(command.startsWith("/custom/path/copilot ")).toBe(true);
1101
+ } finally {
1102
+ if (origCliPath === undefined) delete process.env["COPILOT_CLI_PATH"];
1103
+ else process.env["COPILOT_CLI_PATH"] = origCliPath;
1104
+ }
1105
+ });
1077
1106
  });
1078
1107
 
1079
1108
  // ---------------------------------------------------------------------------
@@ -1250,4 +1279,43 @@ describe("waitForServer", () => {
1250
1279
  const result = await waitForServer("copilot", "%0");
1251
1280
  expect(result).toBe("localhost:50001");
1252
1281
  });
1282
+
1283
+ test("copilot: probe does not pass useLoggedInUser to CopilotClient (external server owns auth)", async () => {
1284
+ mock.module("./tmux.ts", () => ({
1285
+ capturePane: () => PANE_CONTENT_READY,
1286
+ getPanePid: () => 12345,
1287
+ spawnMuxAttach: () => {},
1288
+ }));
1289
+
1290
+ mock.module("./port-discovery.ts", () => ({
1291
+ getListeningPortForPid: async () => 50002,
1292
+ PORT_DISCOVERY_TIMEOUT_MS: 100,
1293
+ }));
1294
+
1295
+ let capturedOptions: unknown;
1296
+ mock.module("@github/copilot-sdk", () => ({
1297
+ CopilotClient: class {
1298
+ constructor(opts: unknown) {
1299
+ capturedOptions = opts;
1300
+ }
1301
+ start() {
1302
+ return Promise.resolve();
1303
+ }
1304
+ listSessions() {
1305
+ return Promise.resolve([]);
1306
+ }
1307
+ stop() {
1308
+ return Promise.resolve();
1309
+ }
1310
+ },
1311
+ }));
1312
+
1313
+ await waitForServer("copilot", "%0");
1314
+ const opts = capturedOptions as Record<string, unknown>;
1315
+ expect(opts).toBeDefined();
1316
+ // cliUrl must be set — connecting to an existing server
1317
+ expect(opts["cliUrl"]).toBe("localhost:50002");
1318
+ // useLoggedInUser must NOT be set — external server owns auth
1319
+ expect(Object.prototype.hasOwnProperty.call(opts, "useLoggedInUser")).toBe(false);
1320
+ });
1253
1321
  });