@cleocode/adapters 2026.9.20 → 2026.9.21
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/dist/cant-context.d.ts +7 -5
- package/dist/cant-context.d.ts.map +1 -1
- package/dist/index.js +501 -328
- package/dist/index.js.map +3 -3
- package/dist/providers/claude-code/adapter.d.ts.map +1 -1
- package/dist/providers/claude-code/spawn.d.ts +2 -2
- package/dist/providers/claude-code/spawn.d.ts.map +1 -1
- package/dist/providers/claude-sdk/spawn.d.ts +1 -1
- package/dist/providers/codex/adapter.d.ts.map +1 -1
- package/dist/providers/codex/spawn.d.ts +1 -1
- package/dist/providers/codex/spawn.d.ts.map +1 -1
- package/dist/providers/gemini-cli/adapter.d.ts.map +1 -1
- package/dist/providers/gemini-cli/spawn.d.ts +1 -1
- package/dist/providers/gemini-cli/spawn.d.ts.map +1 -1
- package/dist/providers/kimi/adapter.d.ts.map +1 -1
- package/dist/providers/kimi/spawn.d.ts.map +1 -1
- package/dist/providers/opencode/adapter.d.ts.map +1 -1
- package/dist/providers/opencode/spawn.d.ts +3 -2
- package/dist/providers/opencode/spawn.d.ts.map +1 -1
- package/dist/providers/pi/adapter.d.ts +1 -1
- package/dist/providers/pi/adapter.d.ts.map +1 -1
- package/dist/providers/pi/spawn.d.ts +3 -3
- package/dist/providers/pi/spawn.d.ts.map +1 -1
- package/dist/providers/shared/agent-spawn-wrapper.d.ts +5 -0
- package/dist/providers/shared/agent-spawn-wrapper.d.ts.map +1 -1
- package/dist/providers/shared/cli-spawn.d.ts +28 -0
- package/dist/providers/shared/cli-spawn.d.ts.map +1 -0
- package/dist/providers/shared/credentials.d.ts +2 -2
- package/dist/providers/shared/credentials.d.ts.map +1 -1
- package/dist/providers/shared/prompt-file.d.ts +32 -0
- package/dist/providers/shared/prompt-file.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/__tests__/agent-spawn-wrapper-win32.test.ts +42 -0
- package/src/__tests__/cant-context.test.ts +89 -37
- package/src/__tests__/cli-spawn-win32.test.ts +105 -0
- package/src/__tests__/spawn-prompt-file.test.ts +37 -0
- package/src/cant-context.ts +11 -15
- package/src/providers/claude-code/adapter.ts +6 -11
- package/src/providers/claude-code/spawn.ts +11 -25
- package/src/providers/claude-sdk/spawn.ts +1 -1
- package/src/providers/codex/adapter.ts +6 -11
- package/src/providers/codex/spawn.ts +17 -31
- package/src/providers/gemini-cli/adapter.ts +6 -11
- package/src/providers/gemini-cli/spawn.ts +13 -18
- package/src/providers/kimi/adapter.ts +6 -11
- package/src/providers/kimi/spawn.ts +3 -11
- package/src/providers/opencode/__tests__/spawn-prompt-delivery.test.ts +123 -0
- package/src/providers/opencode/adapter.ts +6 -11
- package/src/providers/opencode/spawn.ts +23 -25
- package/src/providers/pi/adapter.ts +8 -18
- package/src/providers/pi/spawn.ts +15 -33
- package/src/providers/shared/__tests__/credentials-cleo-home.test.ts +42 -0
- package/src/providers/shared/agent-spawn-wrapper.ts +22 -2
- package/src/providers/shared/cli-spawn.ts +41 -0
- package/src/providers/shared/credentials.ts +6 -5
- 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
|
|
80
|
+
* @returns `true` if `kimi` is found on PATH (PATHEXT-aware on Windows)
|
|
84
81
|
*/
|
|
85
82
|
async function kimiCliBinaryAvailable(): Promise<boolean> {
|
|
86
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
|
141
|
+
* @returns true if `opencode` is found on PATH (PATHEXT-aware on Windows)
|
|
143
142
|
*/
|
|
144
143
|
async canSpawn(): Promise<boolean> {
|
|
145
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 `
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 —
|
|
78
|
+
// Tier 2 — legacy flat-key file (<getCleoHome()>/anthropic-key), the same
|
|
79
|
+
// file core's storeAnthropicApiKey() writes.
|
|
78
80
|
try {
|
|
79
|
-
const
|
|
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 };
|