@cleocode/adapters 2026.9.20 → 2026.9.22

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 (56) hide show
  1. package/dist/cant-context.d.ts +7 -5
  2. package/dist/cant-context.d.ts.map +1 -1
  3. package/dist/index.js +505 -330
  4. package/dist/index.js.map +3 -3
  5. package/dist/providers/claude-code/adapter.d.ts.map +1 -1
  6. package/dist/providers/claude-code/spawn.d.ts +2 -2
  7. package/dist/providers/claude-code/spawn.d.ts.map +1 -1
  8. package/dist/providers/claude-sdk/spawn.d.ts +1 -1
  9. package/dist/providers/codex/adapter.d.ts.map +1 -1
  10. package/dist/providers/codex/spawn.d.ts +1 -1
  11. package/dist/providers/codex/spawn.d.ts.map +1 -1
  12. package/dist/providers/gemini-cli/adapter.d.ts.map +1 -1
  13. package/dist/providers/gemini-cli/spawn.d.ts +1 -1
  14. package/dist/providers/gemini-cli/spawn.d.ts.map +1 -1
  15. package/dist/providers/kimi/adapter.d.ts.map +1 -1
  16. package/dist/providers/kimi/spawn.d.ts.map +1 -1
  17. package/dist/providers/opencode/adapter.d.ts.map +1 -1
  18. package/dist/providers/opencode/spawn.d.ts +3 -2
  19. package/dist/providers/opencode/spawn.d.ts.map +1 -1
  20. package/dist/providers/pi/adapter.d.ts +1 -1
  21. package/dist/providers/pi/adapter.d.ts.map +1 -1
  22. package/dist/providers/pi/spawn.d.ts +3 -3
  23. package/dist/providers/pi/spawn.d.ts.map +1 -1
  24. package/dist/providers/shared/agent-spawn-wrapper.d.ts +5 -0
  25. package/dist/providers/shared/agent-spawn-wrapper.d.ts.map +1 -1
  26. package/dist/providers/shared/cli-spawn.d.ts +28 -0
  27. package/dist/providers/shared/cli-spawn.d.ts.map +1 -0
  28. package/dist/providers/shared/credentials.d.ts +2 -2
  29. package/dist/providers/shared/credentials.d.ts.map +1 -1
  30. package/dist/providers/shared/prompt-file.d.ts +32 -0
  31. package/dist/providers/shared/prompt-file.d.ts.map +1 -0
  32. package/package.json +5 -5
  33. package/src/__tests__/agent-spawn-wrapper-win32.test.ts +42 -0
  34. package/src/__tests__/cant-context.test.ts +89 -37
  35. package/src/__tests__/cli-spawn-win32.test.ts +105 -0
  36. package/src/__tests__/spawn-prompt-file.test.ts +37 -0
  37. package/src/cant-context.ts +11 -15
  38. package/src/providers/claude-code/adapter.ts +6 -11
  39. package/src/providers/claude-code/spawn.ts +11 -25
  40. package/src/providers/claude-sdk/spawn.ts +1 -1
  41. package/src/providers/codex/adapter.ts +6 -11
  42. package/src/providers/codex/spawn.ts +17 -31
  43. package/src/providers/gemini-cli/adapter.ts +6 -11
  44. package/src/providers/gemini-cli/spawn.ts +13 -18
  45. package/src/providers/kimi/adapter.ts +6 -11
  46. package/src/providers/kimi/spawn.ts +3 -11
  47. package/src/providers/opencode/__tests__/spawn-prompt-delivery.test.ts +123 -0
  48. package/src/providers/opencode/adapter.ts +6 -11
  49. package/src/providers/opencode/spawn.ts +23 -25
  50. package/src/providers/pi/adapter.ts +8 -18
  51. package/src/providers/pi/spawn.ts +15 -33
  52. package/src/providers/shared/__tests__/credentials-cleo-home.test.ts +42 -0
  53. package/src/providers/shared/agent-spawn-wrapper.ts +22 -2
  54. package/src/providers/shared/cli-spawn.ts +41 -0
  55. package/src/providers/shared/credentials.ts +6 -5
  56. package/src/providers/shared/prompt-file.ts +44 -0
@@ -27,12 +27,9 @@
27
27
  * @task T648
28
28
  */
29
29
 
30
- import { exec } from 'node:child_process';
31
- import { promisify } from 'node:util';
32
30
  import type { AdapterSpawnProvider, SpawnContext, SpawnResult } from '@cleocode/contracts';
33
31
  import { getErrorMessage } from '@cleocode/contracts';
34
-
35
- const execAsync = promisify(exec);
32
+ import { findOnPath } from '@cleocode/paths';
36
33
 
37
34
  /** Moonshot AI API base URL. */
38
35
  const MOONSHOT_API_BASE = 'https://api.moonshot.cn/v1';
@@ -80,15 +77,10 @@ function resolveMoonshotApiKey(): string | null {
80
77
  *
81
78
  * This is a forward-compatibility hook for any future official Kimi CLI.
82
79
  *
83
- * @returns `true` if `kimi` is found via `which`
80
+ * @returns `true` if `kimi` is found on PATH (PATHEXT-aware on Windows)
84
81
  */
85
82
  async function kimiCliBinaryAvailable(): Promise<boolean> {
86
- try {
87
- await execAsync('which kimi');
88
- return true;
89
- } catch {
90
- return false;
91
- }
83
+ return findOnPath('kimi') !== null;
92
84
  }
93
85
 
94
86
  /**
@@ -0,0 +1,123 @@
1
+ /**
2
+ * T12619 — the opencode prompt is delivered on stdin, never argv.
3
+ *
4
+ * The prompt used to be the last positional argument of `opencode run`. On
5
+ * Windows opencode is a `.cmd` shim launched through cmd.exe, which cannot
6
+ * carry a line break (E_UNSAFE_BATCH_ARG) and caps the line at 8191 chars,
7
+ * so every real multi-line prompt failed there. `--file` would only attach
8
+ * it behind a pointer message; `opencode run` uses piped stdin AS the message
9
+ * when no positional is given, so the prompt is piped on every platform.
10
+ */
11
+
12
+ import type { ChildProcess } from 'node:child_process';
13
+ import { EventEmitter } from 'node:events';
14
+ import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { PassThrough } from 'node:stream';
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
19
+
20
+ vi.mock('@cleocode/paths', async (importOriginal) => {
21
+ const actual = await importOriginal<typeof import('@cleocode/paths')>();
22
+ return {
23
+ ...actual,
24
+ resolveSpawnInvocation: (
25
+ command: string,
26
+ args: readonly string[],
27
+ opts: import('@cleocode/paths').ExecPathOptions = {},
28
+ ) =>
29
+ actual.resolveSpawnInvocation(command, args, {
30
+ ...opts,
31
+ isExecutable: (p) => p === 'C:\\npm\\opencode.cmd',
32
+ }),
33
+ };
34
+ });
35
+ vi.mock('node:child_process', { spy: true });
36
+
37
+ import { spawn } from 'node:child_process';
38
+ import { OpenCodeSpawnProvider } from '../spawn.js';
39
+
40
+ const PROMPT = 'Implement T1.\n\nSteps:\n 1. read "spec" & run tests\n 2. 100% done ^_^';
41
+ const realPlatform = process.platform;
42
+ let cwd: string;
43
+ let child: EventEmitter;
44
+ let stdinText: () => string;
45
+
46
+ function setPlatform(value: NodeJS.Platform): void {
47
+ Object.defineProperty(process, 'platform', { value, configurable: true });
48
+ }
49
+
50
+ /** Temp dirs the old `--file` delivery created. */
51
+ function promptDirs(): string[] {
52
+ return readdirSync(tmpdir()).filter((d) => d.startsWith('cleo-opencode-spawn-'));
53
+ }
54
+
55
+ beforeEach(() => {
56
+ cwd = mkdtempSync(join(tmpdir(), 'opencode-spawn-'));
57
+ child = new EventEmitter();
58
+ const stdin = new PassThrough();
59
+ const chunks: Buffer[] = [];
60
+ stdin.on('data', (c: Buffer) => chunks.push(c));
61
+ stdinText = () => Buffer.concat(chunks).toString('utf-8');
62
+ Object.assign(child, { pid: 4242, unref: () => undefined, stdin });
63
+ vi.mocked(spawn).mockReturnValue(child as ChildProcess);
64
+ });
65
+ afterEach(() => {
66
+ setPlatform(realPlatform);
67
+ vi.unstubAllEnvs();
68
+ vi.mocked(spawn).mockReset();
69
+ rmSync(cwd, { recursive: true, force: true });
70
+ });
71
+
72
+ describe('opencode prompt delivery (T12619)', () => {
73
+ it('POSIX: prompt is piped on stdin byte-exact; argv has no prompt; no temp file', async () => {
74
+ setPlatform('linux');
75
+ const before = promptDirs();
76
+ const result = await new OpenCodeSpawnProvider().spawn({
77
+ taskId: 'T1',
78
+ prompt: PROMPT,
79
+ workingDirectory: cwd,
80
+ });
81
+ expect(result.status).toBe('running');
82
+ const [, args, opts] = vi.mocked(spawn).mock.calls[0] ?? [];
83
+ const argv = (args ?? []) as readonly string[];
84
+ expect(opts).toMatchObject({ stdio: ['pipe', 'ignore', 'ignore'] });
85
+ await vi.waitFor(() => expect(stdinText()).toBe(PROMPT));
86
+ expect(argv.some((a) => a.includes('Implement') || a.includes('\n'))).toBe(false);
87
+ expect(argv).not.toContain('--file');
88
+ // No positional message: argv ends at the title, so stdin IS the message.
89
+ expect(argv.at(-1)).toBe('CLEO T1');
90
+ expect(promptDirs()).toEqual(before);
91
+ });
92
+
93
+ it('win32: a multi-line prompt spawns through cmd.exe and is piped on stdin', async () => {
94
+ setPlatform('win32');
95
+ vi.stubEnv('PATH', 'C:\\npm');
96
+ vi.stubEnv('PATHEXT', '.COM;.EXE;.BAT;.CMD');
97
+ vi.stubEnv('ComSpec', 'C:\\Windows\\system32\\cmd.exe');
98
+ const before = promptDirs();
99
+ const result = await new OpenCodeSpawnProvider().spawn({
100
+ taskId: 'T1',
101
+ prompt: PROMPT,
102
+ workingDirectory: cwd,
103
+ });
104
+ expect(result.status).toBe('running');
105
+ const [file, args, opts] = vi.mocked(spawn).mock.calls[0] ?? [];
106
+ expect(file).toBe('C:\\Windows\\system32\\cmd.exe');
107
+ expect(opts).toMatchObject({ windowsVerbatimArguments: true });
108
+ const line = ((args ?? []) as readonly string[])[3] ?? '';
109
+ expect(line).toContain('C:\\npm\\opencode.cmd');
110
+ expect(line).not.toContain('Implement');
111
+ expect(line).not.toContain('--file');
112
+ await vi.waitFor(() => expect(stdinText()).toBe(PROMPT));
113
+ expect(promptDirs()).toEqual(before);
114
+ });
115
+
116
+ it('a spawn error (no exit) does not leave the instance tracked', async () => {
117
+ setPlatform('linux');
118
+ const provider = new OpenCodeSpawnProvider();
119
+ await provider.spawn({ taskId: 'T1', prompt: PROMPT, workingDirectory: cwd });
120
+ child.emit('error', Object.assign(new Error('spawn opencode ENOENT'), { code: 'ENOENT' }));
121
+ expect(await provider.listRunning()).toEqual([]);
122
+ });
123
+ });
@@ -7,21 +7,18 @@
7
7
  * @task T5240
8
8
  */
9
9
 
10
- import { exec } from 'node:child_process';
11
10
  import { existsSync } from 'node:fs';
12
11
  import { join } from 'node:path';
13
- import { promisify } from 'node:util';
14
12
  import type {
15
13
  AdapterCapabilities,
16
14
  AdapterHealthStatus,
17
15
  CLEOProviderAdapter,
18
16
  } from '@cleocode/contracts';
17
+ import { findOnPath } from '@cleocode/paths';
19
18
  import { OpenCodeHookProvider } from './hooks.js';
20
19
  import { OpenCodeInstallProvider } from './install.js';
21
20
  import { OpenCodeSpawnProvider } from './spawn.js';
22
21
 
23
- const execAsync = promisify(exec);
24
-
25
22
  /**
26
23
  * CLEO provider adapter for OpenCode AI coding assistant.
27
24
  *
@@ -141,13 +138,11 @@ export class OpenCodeAdapter implements CLEOProviderAdapter {
141
138
 
142
139
  // Check OpenCode CLI availability
143
140
  let cliAvailable = false;
144
- try {
145
- const { stdout } = await execAsync('which opencode');
146
- cliAvailable = stdout.trim().length > 0;
147
- details.cliPath = stdout.trim();
148
- } catch {
149
- details.cliAvailable = false;
150
- }
141
+ // In-process PATH/PATHEXT lookup: there is no `which` on Windows (T12604).
142
+ const resolvedCli = findOnPath('opencode');
143
+ cliAvailable = resolvedCli !== null;
144
+ if (resolvedCli) details.cliPath = resolvedCli;
145
+ else details.cliAvailable = false;
151
146
 
152
147
  // Check for OpenCode config directory in the project
153
148
  if (this.projectDir) {
@@ -11,15 +11,13 @@
11
11
  * @task T5240
12
12
  */
13
13
 
14
- import { exec, spawn as nodeSpawn } from 'node:child_process';
15
14
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
16
15
  import { join } from 'node:path';
17
- import { promisify } from 'node:util';
18
16
  import type { AdapterSpawnProvider, SpawnContext, SpawnResult } from '@cleocode/contracts';
17
+ import { findOnPath } from '@cleocode/paths';
18
+ import { spawnCli } from '../shared/cli-spawn.js';
19
19
  import { getCleoTemplatesTildePath } from '../shared/paths.js';
20
20
 
21
- const execAsync = promisify(exec);
22
-
23
21
  /** Name used for the CLEO subagent definition in OpenCode's agent directory. */
24
22
  const OPENCODE_SUBAGENT_NAME = 'cleo-subagent';
25
23
 
@@ -122,7 +120,8 @@ async function ensureSubagentDefinition(
122
120
  *
123
121
  * Spawns detached OpenCode CLI processes for subagent execution.
124
122
  * Each spawn ensures a CLEO subagent definition exists, then runs
125
- * `opencode run --format json --agent <name> --title <title> <prompt>`
123
+ * `opencode run --format json --agent <name> --title <title>` with the
124
+ * prompt piped on stdin (never in argv, T12619)
126
125
  * as a detached, unref'd child process.
127
126
  *
128
127
  * @remarks
@@ -139,15 +138,10 @@ export class OpenCodeSpawnProvider implements AdapterSpawnProvider {
139
138
  /**
140
139
  * Check if the OpenCode CLI is available in PATH.
141
140
  *
142
- * @returns true if `opencode` is found via `which`
141
+ * @returns true if `opencode` is found on PATH (PATHEXT-aware on Windows)
143
142
  */
144
143
  async canSpawn(): Promise<boolean> {
145
- try {
146
- await execAsync('which opencode');
147
- return true;
148
- } catch {
149
- return false;
150
- }
144
+ return findOnPath('opencode') !== null;
151
145
  }
152
146
 
153
147
  /**
@@ -164,7 +158,6 @@ export class OpenCodeSpawnProvider implements AdapterSpawnProvider {
164
158
  const instanceId = `opencode-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
165
159
  const startTime = new Date().toISOString();
166
160
  const workingDirectory = context.workingDirectory ?? process.cwd();
167
-
168
161
  try {
169
162
  // Enrich prompt with CANT bundle, memory bridge, and mental model (T555).
170
163
  // Best-effort: if CANT context is unavailable, the raw prompt is used.
@@ -187,25 +180,26 @@ export class OpenCodeSpawnProvider implements AdapterSpawnProvider {
187
180
  agentName = OPENCODE_FALLBACK_AGENT;
188
181
  }
189
182
 
190
- const child = nodeSpawn(
183
+ // The prompt is piped on stdin, never argv (T12619). With no positional
184
+ // message `opencode run` uses piped stdin AS the message (not an
185
+ // attachment). argv would fail on Windows — opencode is a `.cmd` shim
186
+ // run through cmd.exe, which cannot carry a line break and caps the line
187
+ // at 8191 chars — and argv is visible to every local user everywhere.
188
+ const child = spawnCli(
191
189
  'opencode',
192
- [
193
- 'run',
194
- '--format',
195
- 'json',
196
- '--agent',
197
- agentName,
198
- '--title',
199
- `CLEO ${context.taskId}`,
200
- context.prompt,
201
- ],
190
+ ['run', '--format', 'json', '--agent', agentName, '--title', `CLEO ${context.taskId}`],
202
191
  {
203
192
  cwd: workingDirectory,
204
193
  detached: true,
205
- stdio: 'ignore',
194
+ stdio: ['pipe', 'ignore', 'ignore'],
206
195
  },
207
196
  );
208
197
 
198
+ // A child that dies before reading raises EPIPE on stdin; that is an
199
+ // exit, not an unhandled error.
200
+ child.stdin?.on('error', () => undefined);
201
+ child.stdin?.end(context.prompt, 'utf-8');
202
+
209
203
  child.unref();
210
204
 
211
205
  if (child.pid) {
@@ -219,6 +213,10 @@ export class OpenCodeSpawnProvider implements AdapterSpawnProvider {
219
213
  child.on('exit', () => {
220
214
  this.processMap.delete(instanceId);
221
215
  });
216
+ // A failed async spawn (e.g. ENOENT) emits `error` and never `exit`.
217
+ child.on('error', () => {
218
+ this.processMap.delete(instanceId);
219
+ });
222
220
 
223
221
  return {
224
222
  instanceId,
@@ -16,22 +16,19 @@
16
16
  * @task T553
17
17
  */
18
18
 
19
- import { exec } from 'node:child_process';
20
19
  import { existsSync } from 'node:fs';
21
20
  import { homedir } from 'node:os';
22
21
  import { join } from 'node:path';
23
- import { promisify } from 'node:util';
24
22
  import type {
25
23
  AdapterCapabilities,
26
24
  AdapterHealthStatus,
27
25
  CLEOProviderAdapter,
28
26
  } from '@cleocode/contracts';
27
+ import { findOnPath } from '@cleocode/paths';
29
28
  import { PiHookProvider } from './hooks.js';
30
29
  import { PiInstallProvider } from './install.js';
31
30
  import { PiSpawnProvider } from './spawn.js';
32
31
 
33
- const execAsync = promisify(exec);
34
-
35
32
  /**
36
33
  * Resolve the Pi global state root directory.
37
34
  *
@@ -163,7 +160,7 @@ export class PiAdapter implements CLEOProviderAdapter {
163
160
  *
164
161
  * Checks:
165
162
  * 1. Adapter has been initialized
166
- * 2. Pi CLI is available (via PI_CLI_PATH or `which pi`)
163
+ * 2. Pi CLI is available (via PI_CLI_PATH or a PATH search for `pi`)
167
164
  * 3. Pi global state root (~/.pi/agent/ or PI_CODING_AGENT_DIR) exists
168
165
  *
169
166
  * @returns Health status with details about each check
@@ -182,19 +179,12 @@ export class PiAdapter implements CLEOProviderAdapter {
182
179
  // Check Pi CLI availability
183
180
  let cliAvailable = false;
184
181
  const cliPath = process.env['PI_CLI_PATH'] ?? 'pi';
185
- try {
186
- if (cliPath !== 'pi') {
187
- const { stdout } = await execAsync(`test -x "${cliPath}" && echo ok`);
188
- cliAvailable = stdout.trim() === 'ok';
189
- details.cliPath = cliPath;
190
- } else {
191
- const { stdout } = await execAsync('which pi');
192
- cliAvailable = stdout.trim().length > 0;
193
- details.cliPath = stdout.trim();
194
- }
195
- } catch {
196
- details.cliAvailable = false;
197
- }
182
+ // PI_CLI_PATH (a path) is checked directly, a bare name searched on
183
+ // PATH/PATHEXT; no `test -x`/`which` shell-out (T12604).
184
+ const resolvedCli = findOnPath(cliPath);
185
+ cliAvailable = resolvedCli !== null;
186
+ if (resolvedCli) details.cliPath = cliPath === 'pi' ? resolvedCli : cliPath;
187
+ else details.cliAvailable = false;
198
188
 
199
189
  // Check for Pi global state root
200
190
  const agentDir = getPiAgentDir();
@@ -7,18 +7,17 @@
7
7
  * subagent processes with prompts written to temporary files. Processes
8
8
  * run detached and are tracked by PID for listing and termination.
9
9
  *
10
- * Pi detection: `PI_CLI_PATH` env var or `which pi`.
10
+ * Pi detection: `PI_CLI_PATH` env var or a PATH search for `pi`.
11
11
  *
12
12
  * @task T553
13
13
  */
14
14
 
15
- import { exec, spawn as nodeSpawn } from 'node:child_process';
16
- import { unlink, writeFile } from 'node:fs/promises';
17
- import { promisify } from 'node:util';
15
+ import type { SpawnOptions } from 'node:child_process';
18
16
  import type { AdapterSpawnProvider, SpawnContext, SpawnResult } from '@cleocode/contracts';
19
17
  import { getErrorMessage } from '@cleocode/contracts';
20
-
21
- const execAsync = promisify(exec);
18
+ import { findOnPath } from '@cleocode/paths';
19
+ import { spawnCli } from '../shared/cli-spawn.js';
20
+ import { removeSpawnPromptFile, writeSpawnPromptFile } from '../shared/prompt-file.js';
22
21
 
23
22
  /** Internal tracking entry for a spawned process. */
24
23
  interface TrackedProcess {
@@ -45,7 +44,7 @@ function getPiCliPath(): string {
45
44
  * prompt file as the primary argument as a detached, unref'd child process.
46
45
  *
47
46
  * @remarks
48
- * Prompts are written to temporary files under `/tmp/` and cleaned up
47
+ * Prompts are written to temporary files under a private temp directory (`os.tmpdir()`) and cleaned up
49
48
  * after the child process exits. Processes are tracked by instance ID in
50
49
  * an in-memory map and verified via `kill(pid, 0)` liveness checks.
51
50
  * All failures are best-effort and non-blocking.
@@ -57,23 +56,15 @@ export class PiSpawnProvider implements AdapterSpawnProvider {
57
56
  /**
58
57
  * Check if the Pi CLI is available.
59
58
  *
60
- * Checks `PI_CLI_PATH` env var first, then tries `which pi`.
59
+ * Checks `PI_CLI_PATH` env var first, then searches PATH for `pi`.
61
60
  *
62
61
  * @returns true if the Pi CLI is accessible
63
62
  */
64
63
  async canSpawn(): Promise<boolean> {
65
64
  const cliPath = getPiCliPath();
66
- try {
67
- if (cliPath !== 'pi') {
68
- // Custom path — check if it exists
69
- const { stdout } = await execAsync(`test -x "${cliPath}" && echo ok`);
70
- return stdout.trim() === 'ok';
71
- }
72
- await execAsync('which pi');
73
- return true;
74
- } catch {
75
- return false;
76
- }
65
+ // A custom path is checked directly, a bare name searched on PATH/PATHEXT
66
+ // — no `test -x`/`which` shell-out, neither exists on Windows (T12604).
67
+ return findOnPath(cliPath) !== null;
77
68
  }
78
69
 
79
70
  /**
@@ -91,12 +82,11 @@ export class PiSpawnProvider implements AdapterSpawnProvider {
91
82
  let tmpFile: string | undefined;
92
83
 
93
84
  try {
94
- tmpFile = `/tmp/pi-spawn-${instanceId}.txt`;
95
- await writeFile(tmpFile, context.prompt, 'utf-8');
85
+ tmpFile = await writeSpawnPromptFile('pi-spawn', context.prompt);
96
86
 
97
87
  const cliPath = getPiCliPath();
98
88
  const args = [tmpFile];
99
- const spawnOpts: Parameters<typeof nodeSpawn>[2] = {
89
+ const spawnOpts: SpawnOptions = {
100
90
  detached: true,
101
91
  stdio: 'ignore',
102
92
  };
@@ -105,7 +95,7 @@ export class PiSpawnProvider implements AdapterSpawnProvider {
105
95
  spawnOpts.cwd = context.workingDirectory;
106
96
  }
107
97
 
108
- const child = nodeSpawn(cliPath, args, spawnOpts);
98
+ const child = spawnCli(cliPath, args, spawnOpts);
109
99
  child.unref();
110
100
 
111
101
  if (child.pid) {
@@ -119,11 +109,7 @@ export class PiSpawnProvider implements AdapterSpawnProvider {
119
109
  const capturedTmpFile = tmpFile;
120
110
  child.on('exit', async () => {
121
111
  this.processMap.delete(instanceId);
122
- try {
123
- await unlink(capturedTmpFile);
124
- } catch {
125
- // Ignore cleanup errors
126
- }
112
+ await removeSpawnPromptFile(capturedTmpFile);
127
113
  });
128
114
 
129
115
  return {
@@ -137,11 +123,7 @@ export class PiSpawnProvider implements AdapterSpawnProvider {
137
123
  console.error(`[PiSpawnProvider] Failed to spawn: ${getErrorMessage(error)}`);
138
124
 
139
125
  if (tmpFile) {
140
- try {
141
- await unlink(tmpFile);
142
- } catch {
143
- // Ignore cleanup errors
144
- }
126
+ await removeSpawnPromptFile(tmpFile);
145
127
  }
146
128
 
147
129
  return {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The legacy flat-key tier reads `<getCleoHome()>/anthropic-key` (T12602).
3
+ *
4
+ * Core's `storeAnthropicApiKey()` writes that file under `getCleoHome()`
5
+ * (`~/Library/Application Support/cleo` on macOS). This reader used
6
+ * `$XDG_DATA_HOME ?? ~/.local/share` + `cleo`, so on macOS and Windows it
7
+ * never saw the key core had stored.
8
+ *
9
+ * @task T12602
10
+ */
11
+
12
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
+ import { resolveCredentials } from '../credentials.js';
17
+
18
+ describe('resolveCredentials — legacy flat-key tier (T12602)', () => {
19
+ let root: string;
20
+
21
+ beforeEach(() => {
22
+ root = mkdtempSync(join(tmpdir(), 'cleo-adapters-cred-'));
23
+ const cleoHome = join(root, 'cleo-home');
24
+ mkdirSync(cleoHome, { recursive: true });
25
+ writeFileSync(join(cleoHome, 'anthropic-key'), 'sk-ant-flat-key\n');
26
+ vi.stubEnv('ANTHROPIC_API_KEY', undefined);
27
+ vi.stubEnv('CLEO_HOME', cleoHome);
28
+ // The old resolver read here; it is empty.
29
+ vi.stubEnv('XDG_DATA_HOME', join(root, 'empty-xdg'));
30
+ // Keep tier 3 (~/.claude/.credentials.json) off the real home dir.
31
+ vi.stubEnv('HOME', join(root, 'home'));
32
+ });
33
+
34
+ afterEach(() => {
35
+ vi.unstubAllEnvs();
36
+ rmSync(root, { recursive: true, force: true });
37
+ });
38
+
39
+ it('reads the key core stored under getCleoHome()', () => {
40
+ expect(resolveCredentials('anthropic')).toEqual({ apiKey: 'sk-ant-flat-key' });
41
+ });
42
+ });
@@ -35,6 +35,7 @@
35
35
  import { spawnSync } from 'node:child_process';
36
36
  import { readFileSync } from 'node:fs';
37
37
  import type { AgentContainmentMode, AgentSuiteOwnership } from '@cleocode/contracts';
38
+ import { resolveSpawnInvocation } from '@cleocode/paths';
38
39
 
39
40
  // ---------------------------------------------------------------------------
40
41
  // Constants
@@ -119,6 +120,11 @@ export interface AgentSpawnArgs {
119
120
  command: string;
120
121
  /** Full argument list. */
121
122
  args: string[];
123
+ /**
124
+ * Set (`true`) only when win32 launches a `.cmd`/`.bat` CLI through
125
+ * cmd.exe; pass it to `spawn` so the pre-quoted line is not re-quoted.
126
+ */
127
+ windowsVerbatimArguments?: boolean;
122
128
  /**
123
129
  * Ownership handle to persist in the session tracking record.
124
130
  *
@@ -154,7 +160,8 @@ export function buildAgentSpawnArgs(
154
160
  scopeId?: string,
155
161
  ): AgentSpawnArgs {
156
162
  if (!hasSystemdRun()) {
157
- if (!_demotionLogged) {
163
+ // T12621: pgid is the expected mode off Linux, so only a Linux debug run hears about it.
164
+ if (!_demotionLogged && process.platform === 'linux' && process.env['CLEO_DEBUG']) {
158
165
  _demotionLogged = true;
159
166
  process.stderr.write(
160
167
  '[cleo:agent-spawn-wrapper] systemd-run unavailable — ' +
@@ -162,9 +169,22 @@ export function buildAgentSpawnArgs(
162
169
  );
163
170
  }
164
171
 
165
- // pgid fallback: wrap with ulimit -c 0 for core suppression.
172
+ // pgid fallback: wrap with ulimit -c 0 for core suppression on POSIX.
173
+ // Windows has no `sh`/`ulimit` and writes no core files, so the command
174
+ // is spawned directly there (T12604).
166
175
  // The caller MUST pass { detached: true } to spawn() so that Node creates
167
176
  // a new session+pgid for this child.
177
+ // On win32 the CLI is resolved to its absolute path and a `.cmd` shim is
178
+ // routed through cmd.exe with injection-safe quoting (T12618).
179
+ if (process.platform === 'win32') {
180
+ const inv = resolveSpawnInvocation(command, args);
181
+ return {
182
+ command: inv.file,
183
+ args: inv.args,
184
+ ...(inv.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
185
+ ownership: { mode: 'pgid' as AgentContainmentMode },
186
+ };
187
+ }
168
188
  return {
169
189
  command: 'sh',
170
190
  args: ['-c', 'ulimit -c 0; exec "$@"', 'sh', command, ...args],
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Spawn a provider CLI the same way on every platform.
3
+ *
4
+ * On Windows an npm-installed CLI (`codex`, `gemini`, `opencode`, `pi`) is a
5
+ * `.cmd` shim. Node's bare-name lookup only tries `.exe`/`.com`, and Node
6
+ * refuses to spawn a `.cmd` without a shell (CVE-2024-27980), so a bare
7
+ * `spawn('codex', …)` failed there. {@link resolveSpawnInvocation} spawns the
8
+ * absolute path, routing batch files through cmd.exe with injection-safe
9
+ * quoting. POSIX is unchanged.
10
+ *
11
+ * @task T12618
12
+ */
13
+
14
+ import { type ChildProcess, type SpawnOptions, spawn } from 'node:child_process';
15
+ import { resolveSpawnInvocation } from '@cleocode/paths';
16
+
17
+ /**
18
+ * Spawn `command args` without a user-visible shell on any platform.
19
+ *
20
+ * @param command - CLI name or path, e.g. `codex` or `PI_CLI_PATH`.
21
+ * @param args - Literal arguments; never interpreted by a shell.
22
+ * @param options - Spawn options (cwd, stdio, detached, env).
23
+ * @returns The child process.
24
+ * @throws When a win32 batch-file argument contains a line break.
25
+ * @example
26
+ * ```ts
27
+ * const child = spawnCli('codex', ['--full-auto', promptFile], { detached: true, stdio: 'ignore' });
28
+ * ```
29
+ */
30
+ export function spawnCli(
31
+ command: string,
32
+ args: readonly string[],
33
+ options: SpawnOptions,
34
+ ): ChildProcess {
35
+ const inv = resolveSpawnInvocation(command, args);
36
+ return spawn(
37
+ inv.file,
38
+ inv.args,
39
+ inv.windowsVerbatimArguments ? { ...options, windowsVerbatimArguments: true } : options,
40
+ );
41
+ }
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * ## Resolution tiers (first non-empty match wins)
10
10
  * 1. `ANTHROPIC_API_KEY` environment variable
11
- * 2. `~/.local/share/cleo/anthropic-key` (XDG-aware legacy flat-key file)
11
+ * 2. `<getCleoHome()>/anthropic-key` (legacy flat-key file)
12
12
  * 3. `~/.claude/.credentials.json` OAuth bearer token
13
13
  *
14
14
  * Call-sites use the same `resolveCredentials('anthropic').apiKey` pattern
@@ -24,6 +24,7 @@ import { existsSync, readFileSync } from 'node:fs';
24
24
  import { homedir } from 'node:os';
25
25
  import { join } from 'node:path';
26
26
  import { parseClaudeCodeCredentials } from '@cleocode/contracts';
27
+ import { getCleoHome } from '@cleocode/paths';
27
28
 
28
29
  // ---------------------------------------------------------------------------
29
30
  // Types
@@ -53,7 +54,7 @@ export interface ResolvedCredential {
53
54
  *
54
55
  * Resolution order (first non-empty match wins):
55
56
  * 1. `ANTHROPIC_API_KEY` environment variable
56
- * 2. `~/.local/share/cleo/anthropic-key` (XDG-aware stored key)
57
+ * 2. `<getCleoHome()>/anthropic-key` (stored key)
57
58
  * 3. `~/.claude/.credentials.json` Claude Code OAuth token
58
59
  *
59
60
  * Never throws — all filesystem errors are caught and treated as "not found".
@@ -74,10 +75,10 @@ export function resolveCredentials(_provider: 'anthropic'): ResolvedCredential {
74
75
  const envKey = process.env['ANTHROPIC_API_KEY'];
75
76
  if (envKey?.trim()) return { apiKey: envKey.trim() };
76
77
 
77
- // Tier 2 — XDG-aware legacy flat-key file (~/.local/share/cleo/anthropic-key)
78
+ // Tier 2 — legacy flat-key file (<getCleoHome()>/anthropic-key), the same
79
+ // file core's storeAnthropicApiKey() writes.
78
80
  try {
79
- const xdg = process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share');
80
- const keyFile = join(xdg, 'cleo', 'anthropic-key');
81
+ const keyFile = join(getCleoHome(), 'anthropic-key');
81
82
  if (existsSync(keyFile)) {
82
83
  const stored = readFileSync(keyFile, 'utf-8').trim();
83
84
  if (stored) return { apiKey: stored };