-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathspawn.ts
More file actions
393 lines (374 loc) · 14.2 KB
/
Copy pathspawn.ts
File metadata and controls
393 lines (374 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
/**
* @file Locked-down spawn for AI agent CLIs (Claude / Codex / Gemini /
* OpenCode). Per the CLAUDE.md "Programmatic Claude calls" rule: every
* headless invocation MUST set the four lockdown flags (tools / disallow /
* permissionMode). The helper enforces this at the
* type level (`SpawnAiAgentOptions` requires the relevant fields) AND at the
* spawn site, via the per-agent flag translator. Why a CLI subprocess instead
* of an SDK call: Socket's contract matches what the local user sees when
* invoking the CLI — same auth config, same model availability, same tool
* permissions. SDK calls would diverge on auth handling and force per-agent
* SDK installs. Retry: 3 attempts on overload (HTTP 529 / "Overloaded"), exp.
* backoff (5s / 15s / 45s). Each retry is a fresh subprocess.
*/
import process from 'node:process'
import { errorMessage } from '../errors/message'
import { DateNow } from '../primordials/date'
import { ErrorCtor } from '../primordials/error'
import { ObjectKeys } from '../primordials/object'
import { PromiseCtor } from '../primordials/promise'
import { spawn } from '../process/spawn/child'
import { isSpawnError } from '../process/spawn/errors'
import {
isUnknownCliOption,
OPTIONAL_CLI_FLAGS,
withoutCliFlag,
} from './cli-flags'
import { discoverAiAgents } from './discover'
import { isModelUnavailable, isOverloaded, isQuotaExhausted } from './failures'
import { usableTierCandidates } from './route'
import { runLocalTierSpawn } from './spawn-local'
import type { LocalAgentProvider } from './spawn-local'
import type { RouteContext, TierCandidate } from './route'
import type { AiTier } from './tier'
import type {
AgentSpawnResult,
AiAgentName,
SpawnAiAgentOptions,
} from './types'
const MAX_ATTEMPTS = 3
const BACKOFF_BASE_MS = 5000
export function backoffFor(attempt: number): number {
return BACKOFF_BASE_MS * 3 ** (attempt - 1)
}
/**
* Build CLI arg list for a given agent. The flag names differ across agents but
* the conceptual surface is the same: "here are the allowed tools, here are the
* denied tools, and here is the permission mode." This
* translator is the single source of truth for how each agent's flags map.
*
* When an agent changes its flag surface, update two sites: 1. The relevant
* case below. 2. The agent's docs link, cited inline.
*/
export function buildArgs(
agent: AiAgentName,
options: SpawnAiAgentOptions,
): string[] {
options = { __proto__: null, ...options } as typeof options
const allAllowed = [...options.tools, ...(options.allow ?? [])]
switch (agent) {
case 'claude': {
// https://code.claude.com/docs/en/cli-reference
const args: string[] = [
'--print',
'--permission-mode',
options.permissionMode,
'--add-dir',
options.cwd,
]
for (const dir of options.addDirs ?? []) {
args.push('--add-dir', dir)
}
if (options.model) {
args.push('--model', options.model)
}
// Fable / Mythos are adaptive-thinking-only; the effort dial does not
// apply, so omit `--effort` for them rather than pass a level they ignore.
if (options.effort && !isAdaptiveOnlyModel(options.model ?? '')) {
args.push('--effort', options.effort)
}
if (allAllowed.length > 0) {
args.push('--allowedTools', ...allAllowed)
}
if (options.disallow.length > 0) {
args.push('--disallowedTools', ...options.disallow)
}
if (options.extraArgs) {
args.push(...options.extraArgs)
}
return args
}
case 'codex': {
// Codex CLI uses --tools / --disallow-tools, no --permission-mode
// (it has a separate --read-only flag instead). Plan-mode maps
// to --read-only; acceptEdits and dontAsk both run normally.
const args: string[] = ['--print']
if (options.permissionMode === 'plan') {
args.push('--read-only')
}
if (options.model) {
args.push('--model', options.model)
}
if (options.effort) {
// Codex takes reasoning effort as a `-c` config override, not a
// flag. Its vocab tops out at xhigh (no `max`), so clamp the shared
// AiEffort `max` down to xhigh — codex's ceiling.
const codexEffort = options.effort === 'max' ? 'xhigh' : options.effort
args.push('-c', `model_reasoning_effort=${codexEffort}`)
}
if (allAllowed.length > 0) {
args.push('--tools', allAllowed.join(','))
}
if (options.disallow.length > 0) {
args.push('--disallow-tools', options.disallow.join(','))
}
args.push('--cwd', options.cwd)
if (options.extraArgs) {
args.push(...options.extraArgs)
}
return args
}
case 'gemini': {
// Gemini CLI: --no-interactive for headless, --workspace for cwd.
const args: string[] = ['--no-interactive', '--workspace', options.cwd]
if (options.model) {
args.push('--model', options.model)
}
if (allAllowed.length > 0) {
args.push('--allowed-tools', allAllowed.join(','))
}
if (options.disallow.length > 0) {
args.push('--denied-tools', options.disallow.join(','))
}
if (options.permissionMode === 'plan') {
args.push('--read-only')
}
if (options.extraArgs) {
args.push(...options.extraArgs)
}
return args
}
case 'opencode': {
// OpenCode CLI: --print, --tools, --no-tools.
const args: string[] = ['--print', '--cwd', options.cwd]
if (options.model) {
args.push('--model', options.model)
}
if (allAllowed.length > 0) {
args.push('--tools', allAllowed.join(','))
}
if (options.disallow.length > 0) {
args.push('--no-tools', options.disallow.join(','))
}
if (options.extraArgs) {
args.push(...options.extraArgs)
}
return args
}
}
}
/**
* Fable and Mythos run adaptive thinking only — thinking is always on and there
* is no manual thinking-budget knob. The effort dial does not apply the way it
* does on Opus, so the spawn layer drops `--effort` for these models rather
* than passing a level they should ignore. Matches both alias and full-id
* shapes (`fable`, `claude-fable-5`, `mythos`, `claude-mythos-5`).
*/
export function isAdaptiveOnlyModel(model: string): boolean {
return (
/\b(?:fable|mythos)\b/i.test(model) ||
/claude-(?:fable|mythos)/i.test(model)
)
}
export async function pickAgent(
requested: AiAgentName | undefined,
cwd: string,
): Promise<AiAgentName> {
const discovered = await discoverAiAgents({ repoRoot: cwd })
if (requested) {
if (!(requested in discovered)) {
throw new ErrorCtor(
`spawnAiAgent: requested agent "${requested}" is not on PATH. Install the CLI or pass a different agent. Discovered: ${ObjectKeys(discovered).join(', ') || '(none)'}`,
)
}
return requested
}
// Default to claude when present.
if ('claude' in discovered) {
return 'claude'
}
// Otherwise, fall back to whichever agent is available, in
// preference order: codex → opencode → gemini.
for (const candidate of ['codex', 'opencode', 'gemini'] as const) {
if (candidate in discovered) {
return candidate
}
}
throw new ErrorCtor(
'spawnAiAgent: no AI agent CLI on PATH. Install one of: claude, codex, opencode, gemini.',
)
}
/**
* Spawn an AI agent CLI subprocess with the locked-down flag set.
*
* @example
* ```ts
* import { AI_PROFILE } from '@socketsecurity/lib/ai/profiles'
* import { spawnAiAgent } from '@socketsecurity/lib/ai/spawn'
*
* const result = await spawnAiAgent({
* ...AI_PROFILE.edit,
* prompt: 'Fix the lint findings in src/foo.ts',
* cwd: process.cwd(),
* model: 'claude-sonnet-4-6',
* timeoutMs: 5 * 60 * 1000,
* })
* if (result.exitCode !== 0) { ... }
* ```
*
* Throws when the requested agent isn't on PATH (or, when no agent
* is requested, when none of the known agents are on PATH).
*/
export async function spawnAiAgent(
options: SpawnAiAgentOptions,
): Promise<AgentSpawnResult> {
options = { __proto__: null, ...options } as typeof options
const agent = await pickAgent(options.agent, options.cwd)
let args = buildArgs(agent, options)
let stdout = ''
let stderr = ''
let exitCode = 0
let attempts = 0
const start = DateNow()
while (attempts < MAX_ATTEMPTS) {
attempts += 1
stdout = ''
stderr = ''
exitCode = 0
try {
const child = spawn(agent, args, {
cwd: options.cwd,
// Only override env when the caller supplies one; absent = inherit.
...(options.env ? { env: { ...process.env, ...options.env } } : {}),
stdio: 'pipe',
stdioString: true,
timeout: options.timeoutMs,
})
// `.stdin` is a typed convenience accessor on the Socket
// PromiseSpawnResult (`Promise<…> & { process; stdin }`); `await child`
// resolves the result, so the wrapper is kept rather than destructured.
// oxlint-disable-next-line socket/no-bare-spawn-childproc-access -- stdin accessor
child.stdin?.end(options.prompt)
const result = await child
stdout = String(result.stdout ?? '')
stderr = String(result.stderr ?? '')
exitCode = result.code ?? 0
} catch (e) {
if (isSpawnError(e)) {
stdout = String(e.stdout ?? '')
stderr = String(e.stderr ?? '')
exitCode = e.code ?? 1
} else {
stderr = errorMessage(e)
exitCode = 1
}
}
// An optimization flag the installed CLI does not know fails the spawn
// before any work happens. Drop it and retry: the run proceeds at the
// CLI's default reasoning depth instead of dying on an arg mismatch.
const rejected =
exitCode === 0
? undefined
: OPTIONAL_CLI_FLAGS.find(
flag =>
args.includes(flag) && isUnknownCliOption(stdout, stderr, flag),
)
if (rejected) {
args = withoutCliFlag(args, rejected)
continue
}
if (!isOverloaded(stdout, stderr) || attempts >= MAX_ATTEMPTS) {
break
}
await new PromiseCtor(resolve => setTimeout(resolve, backoffFor(attempts)))
}
// `overloaded` is true only when the LAST attempt was still an overload —
// i.e. retries were exhausted on 529, not a real failure. A run that
// recovered on a retry exits with the recovered result and overloaded=false.
// `unavailable` means the selected MODEL can't serve the request (down /
// no-access) — the caller should fall over to the next agent, not retry here.
return {
attempts,
durationMs: DateNow() - start,
exitCode,
overloaded: isOverloaded(stdout, stderr),
stderr,
stdout,
unavailable: isModelUnavailable(stdout, stderr),
}
}
/**
* Result of a tier spawn that may have fallen over one or more offline models.
* `result` is the spawn that actually ran (the first whose model was not
* `unavailable`, or the last attempt if every candidate was down). `candidate`
* is the engine/model that produced it. `fellOver` lists the candidates that
* reported their model offline before this one — empty on a first-try success.
*/
export interface TierSpawnResult {
readonly candidate: TierCandidate
readonly fellOver: readonly TierCandidate[]
readonly result: AgentSpawnResult
}
/**
* Spawn a tier's work, automatically FALLING OVER to the next agent when a
* model is offline. Walks the tier's usable candidates (Claude → Codex →
* open-weight) in order; if a spawn comes back with `unavailable` (the model is
* down or gated — e.g. "Claude Fable 5 is currently unavailable"), it advances
* to the next candidate instead of failing. This is the runtime complement to
* `route.mts`'s static availability check: that check can't predict an outage,
* so the live spawn result drives the fallback.
*
* Returns the first non-`unavailable` spawn (success OR a genuine work failure
* on a model that WAS reachable — a real failure shouldn't silently retry on a
* weaker model). If every candidate is offline, returns the last attempt with
* its `unavailable` flag set, so the caller can report "all models down".
* Throws only when the tier has no usable candidate at all (nothing installed +
* keyed) — same contract as `resolveTier` returning undefined.
*/
export async function spawnTierWithFallback(
tier: AiTier,
ctx: RouteContext,
options: Omit<SpawnAiAgentOptions, 'agent' | 'effort' | 'model'>,
localProvider?: LocalAgentProvider | undefined,
): Promise<TierSpawnResult> {
const candidates = usableTierCandidates(tier, ctx)
if (candidates.length === 0) {
throw new ErrorCtor(
`spawnTierWithFallback: no usable agent for tier "${tier}". No candidate engine is both installed and keyed (checked: ${candidates.length}). Install/authenticate one of the tier's engines, or pick a different tier.`,
)
}
const fellOver: TierCandidate[] = []
let last: { candidate: TierCandidate; result: AgentSpawnResult } | undefined
for (let i = 0, { length } = candidates; i < length; i += 1) {
const candidate = candidates[i]!
// A `local` candidate drives the keyless on-device seam (returning the same
// AgentSpawnResult) so the fall-over logic below stays uniform across kinds.
const result =
candidate.kind === 'local'
? await runLocalTierSpawn(options, candidate.model, localProvider)
: await spawnAiAgent({
...options,
agent: candidate.engine,
effort: candidate.effort,
model: candidate.model,
} as SpawnAiAgentOptions)
last = { candidate, result }
if (
!result.unavailable &&
!isQuotaExhausted(result.stdout, result.stderr)
) {
// Reached a model that could serve, either with a success or a genuine
// failure. Stop; don't downgrade a real failure onto a weaker model.
return { candidate, fellOver, result }
}
// This model is offline/gated, or its seat/budget is exhausted (429 / quota)
// — record it and try the next candidate (a different provider/account).
fellOver.push(candidate)
}
// Every candidate was down or quota-exhausted — return the last attempt.
return {
candidate: last!.candidate,
fellOver: fellOver.slice(0, -1),
result: last!.result,
}
}