lazyclaw 6.3.1 → 6.5.0
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/README.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +7 -193
- package/commands/agents_registry.mjs +205 -0
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +28 -94
- package/commands/automation_loops.mjs +94 -0
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +127 -704
- package/commands/chat_hardening.mjs +23 -0
- package/commands/chat_legacy_slash.mjs +716 -0
- package/commands/config.mjs +36 -2
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/help_text.mjs +78 -0
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -80
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +41 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +27 -3
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +68 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +183 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +70 -12
- package/providers/gemini.mjs +104 -3
- package/providers/gemini_cli.mjs +78 -17
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +29 -2
- package/providers/tool_use/openai.mjs +35 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/banner.mjs +72 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +0 -0
- package/tui/editor_anchor.mjs +46 -0
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/orchestrator_setup.mjs +135 -0
- package/tui/pickers.mjs +218 -248
- package/tui/repl.mjs +118 -209
- package/tui/repl_altbuffer.mjs +63 -0
- package/tui/repl_reducers.mjs +114 -0
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_channels.mjs +208 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dashboard.mjs +220 -0
- package/tui/slash_dispatcher.mjs +339 -774
- package/tui/slash_helpers.mjs +68 -0
- package/tui/slash_popup.mjs +5 -1
- package/tui/slash_trainer.mjs +173 -0
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- package/workflow/yaml_min.mjs +97 -0
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
// "gemini:gemini-2.5-pro"
|
|
28
28
|
// ],
|
|
29
29
|
// "maxSubtasks": 5, // optional, default 5
|
|
30
|
-
// "concurrency": 0 // optional
|
|
30
|
+
// "concurrency": 0 // optional; default = min(3, workers), parallel.
|
|
31
|
+
// // 0 or 1 = sequential (visible live streaming)
|
|
31
32
|
// }
|
|
32
33
|
// }
|
|
33
34
|
//
|
|
@@ -86,6 +87,24 @@ function _bestPlanArray(text) {
|
|
|
86
87
|
return null;
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
// Default worker-pool concurrency when cfg.orchestrator.concurrency is unset.
|
|
91
|
+
// Parallel is the point of a worker pool; an unconfigured fleet should not run
|
|
92
|
+
// subtasks one at a time. Clamped to the worker count at the call site, and an
|
|
93
|
+
// explicit 0/1 still selects the sequential live-streaming path.
|
|
94
|
+
const DEFAULT_CONCURRENCY = 3;
|
|
95
|
+
|
|
96
|
+
// Opt-in agentic workers (cfg.orchestrator.agenticWorkers): an EXECUTE worker
|
|
97
|
+
// runs through runAgentTurn so it can actually DO work with its tools (shell,
|
|
98
|
+
// file read, recall) instead of only streaming text. Default OFF — the
|
|
99
|
+
// text-streaming path is byte-stable. Tool calls are confined by the sandbox
|
|
100
|
+
// the caller passes; the loop is bounded by workerMaxIterations.
|
|
101
|
+
const DEFAULT_WORKER_TOOLS = ['bash', 'read', 'grep', 'recall'];
|
|
102
|
+
const DEFAULT_WORKER_MAX_ITERATIONS = 8;
|
|
103
|
+
const AGENTIC_WORKER_ROLE =
|
|
104
|
+
'You are an orchestrator worker. Complete ONLY the assigned subtask. Use your ' +
|
|
105
|
+
'tools (shell, file read, grep, recall) to do real work when useful, then report ' +
|
|
106
|
+
'the result concisely. Do not ask questions — act, then summarise what you found.';
|
|
107
|
+
|
|
89
108
|
const PLANNER_SYSTEM = `You are an orchestrator that decomposes a user request into independent subtasks for parallel worker agents.
|
|
90
109
|
|
|
91
110
|
Rules:
|
|
@@ -262,9 +281,47 @@ export function makeOrchestratorProvider(opts = {}) {
|
|
|
262
281
|
// regardless of which worker finished first.
|
|
263
282
|
// The number is clamped to [1, workers.length] so a runaway value
|
|
264
283
|
// can't accidentally over-subscribe a single worker.
|
|
265
|
-
const rawConcurrency = Number.isFinite(o.concurrency) ? Math.floor(o.concurrency) :
|
|
284
|
+
const rawConcurrency = Number.isFinite(o.concurrency) ? Math.floor(o.concurrency) : DEFAULT_CONCURRENCY;
|
|
266
285
|
const concurrency = Math.max(1, Math.min(rawConcurrency, workers.length));
|
|
267
286
|
yield `### 2. Executing ${trimmed.length} subtask${trimmed.length === 1 ? '' : 's'}${concurrency > 1 ? ` (concurrency=${concurrency}, parallel)` : ''}\n\n`;
|
|
287
|
+
|
|
288
|
+
// Run one worker on one subtask through the agentic tool loop. Confined by
|
|
289
|
+
// the caller's sandbox; bounded by workerMaxIterations; abort-propagating.
|
|
290
|
+
const _runAgenticWorker = async (worker, sub) => {
|
|
291
|
+
const { runAgentTurn } = await import('../mas/agent_turn.mjs');
|
|
292
|
+
const r = await runAgentTurn({
|
|
293
|
+
agent: {
|
|
294
|
+
name: worker.name, provider: worker.name, model: worker.model,
|
|
295
|
+
role: AGENTIC_WORKER_ROLE,
|
|
296
|
+
tools: Array.isArray(o.workerTools) ? o.workerTools : DEFAULT_WORKER_TOOLS,
|
|
297
|
+
},
|
|
298
|
+
userMessage: sub.task,
|
|
299
|
+
configDir: callerOpts.configDir,
|
|
300
|
+
cwd: callerOpts.cwd,
|
|
301
|
+
apiKey: keyResolver(cfg, worker.name),
|
|
302
|
+
fetchImpl: callerOpts.fetchImpl,
|
|
303
|
+
sandbox: callerOpts.sandbox,
|
|
304
|
+
signal: callerOpts.signal,
|
|
305
|
+
maxIterations: Number.isFinite(o.workerMaxIterations) ? o.workerMaxIterations : DEFAULT_WORKER_MAX_ITERATIONS,
|
|
306
|
+
});
|
|
307
|
+
return r.text || '';
|
|
308
|
+
};
|
|
309
|
+
// Worker output as a chunk stream. agenticWorkers OFF (default) → the
|
|
310
|
+
// provider's live token stream, byte-stable. ON → one buffered chunk with
|
|
311
|
+
// the agent turn's final answer (tool work happened inside).
|
|
312
|
+
const _workerChunks = async function* (worker, sub) {
|
|
313
|
+
if (o.agenticWorkers) {
|
|
314
|
+
const text = await _runAgenticWorker(worker, sub);
|
|
315
|
+
if (text) yield text;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
yield* worker.prov.sendMessage([{ role: 'user', content: sub.task }], {
|
|
319
|
+
apiKey: keyResolver(cfg, worker.name),
|
|
320
|
+
model: worker.model || undefined,
|
|
321
|
+
signal: callerOpts.signal,
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
|
|
268
325
|
const results = [];
|
|
269
326
|
if (concurrency <= 1) {
|
|
270
327
|
// Sequential streaming path — historical default, preserved.
|
|
@@ -274,11 +331,7 @@ export function makeOrchestratorProvider(opts = {}) {
|
|
|
274
331
|
yield `**Subtask ${sub.id}** \`${worker.name}${worker.model ? ':' + worker.model : ''}\` — ${sub.task}\n\n`;
|
|
275
332
|
let res = '';
|
|
276
333
|
try {
|
|
277
|
-
for await (const chunk of worker
|
|
278
|
-
apiKey: keyResolver(cfg, worker.name),
|
|
279
|
-
model: worker.model || undefined,
|
|
280
|
-
signal: callerOpts.signal,
|
|
281
|
-
})) {
|
|
334
|
+
for await (const chunk of _workerChunks(worker, sub)) {
|
|
282
335
|
const s = String(chunk);
|
|
283
336
|
res += s;
|
|
284
337
|
yield s;
|
|
@@ -301,11 +354,7 @@ export function makeOrchestratorProvider(opts = {}) {
|
|
|
301
354
|
const chunks = [];
|
|
302
355
|
let error = null;
|
|
303
356
|
try {
|
|
304
|
-
for await (const chunk of worker
|
|
305
|
-
apiKey: keyResolver(cfg, worker.name),
|
|
306
|
-
model: worker.model || undefined,
|
|
307
|
-
signal: callerOpts.signal,
|
|
308
|
-
})) {
|
|
357
|
+
for await (const chunk of _workerChunks(worker, sub)) {
|
|
309
358
|
chunks.push(String(chunk));
|
|
310
359
|
}
|
|
311
360
|
} catch (e) {
|
|
@@ -393,3 +442,54 @@ export function makeOrchestratorProvider(opts = {}) {
|
|
|
393
442
|
},
|
|
394
443
|
};
|
|
395
444
|
}
|
|
445
|
+
|
|
446
|
+
// Minimal one-shot worker dispatch for the `delegate` agent tool.
|
|
447
|
+
//
|
|
448
|
+
// `delegate` hands a single subtask to ONE provider — no plan/synthesis.
|
|
449
|
+
// It reuses the same spec-resolution + key-resolution infra the
|
|
450
|
+
// orchestrator's EXECUTE phase uses (a "<provider>[:<model>]" spec routed
|
|
451
|
+
// through PROVIDERS/PROVIDER_INFO, key via lib/config::_resolveAuthKey),
|
|
452
|
+
// but as a standalone call so the `delegate` tool actually runs.
|
|
453
|
+
//
|
|
454
|
+
// Imports are lazy so this module keeps its leaf static-dep graph (it must
|
|
455
|
+
// NOT statically import ./registry.mjs — that re-forms the registry↔orch
|
|
456
|
+
// cycle; see the header note above).
|
|
457
|
+
//
|
|
458
|
+
// job = { worker: '<provider>[:<model>]', prompt, model? }
|
|
459
|
+
// returns { ok:true, text } | { ok:false, error }
|
|
460
|
+
export async function dispatchWorker(job = {}) {
|
|
461
|
+
const workerSpec = String(job?.worker || '');
|
|
462
|
+
const prompt = String(job?.prompt || '');
|
|
463
|
+
if (!workerSpec || !prompt) return { ok: false, error: 'delegate: worker + prompt required' };
|
|
464
|
+
|
|
465
|
+
let registry, config;
|
|
466
|
+
try {
|
|
467
|
+
registry = await import('./registry.mjs');
|
|
468
|
+
config = await import('../lib/config.mjs');
|
|
469
|
+
} catch (e) {
|
|
470
|
+
return { ok: false, error: `delegate: failed to load provider infra: ${e?.message || String(e)}` };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const lookup = (p) => ({ prov: registry.PROVIDERS[p], info: registry.PROVIDER_INFO[p] });
|
|
474
|
+
const worker = _lookupProvider(workerSpec, lookup);
|
|
475
|
+
if (!worker) return { ok: false, error: `delegate: unknown worker provider "${_parseSpec(workerSpec).provider}"` };
|
|
476
|
+
if (worker.name === 'orchestrator') return { ok: false, error: 'delegate: worker cannot be "orchestrator"' };
|
|
477
|
+
// Explicit job.model overrides the model parsed from the spec.
|
|
478
|
+
const model = String(job?.model || '') || worker.model || '';
|
|
479
|
+
|
|
480
|
+
const cfg = config.readConfig() || {};
|
|
481
|
+
const apiKey = config._resolveAuthKey(cfg, worker.name);
|
|
482
|
+
|
|
483
|
+
let text = '';
|
|
484
|
+
try {
|
|
485
|
+
for await (const chunk of worker.prov.sendMessage([{ role: 'user', content: prompt }], {
|
|
486
|
+
apiKey,
|
|
487
|
+
model: model || undefined,
|
|
488
|
+
})) {
|
|
489
|
+
text += String(chunk);
|
|
490
|
+
}
|
|
491
|
+
} catch (e) {
|
|
492
|
+
return { ok: false, error: `delegate: ${worker.name} error: ${e?.message || String(e)}` };
|
|
493
|
+
}
|
|
494
|
+
return { ok: true, text };
|
|
495
|
+
}
|
package/providers/registry.mjs
CHANGED
|
@@ -191,9 +191,8 @@ export const PROVIDER_INFO = {
|
|
|
191
191
|
requiresApiKey: false,
|
|
192
192
|
docs: 'Anthropic via the local `claude` CLI (Pro / Max subscription). No API key — auth flows through whatever account `claude` is logged in with. Requires Claude Code installed.',
|
|
193
193
|
endpoint: 'subprocess: claude -p',
|
|
194
|
-
defaultModel: 'claude-opus-4-
|
|
194
|
+
defaultModel: 'claude-opus-4-8',
|
|
195
195
|
suggestedModels: [
|
|
196
|
-
'claude-fable-5',
|
|
197
196
|
'claude-opus-4-8',
|
|
198
197
|
'claude-opus-4-7',
|
|
199
198
|
'claude-opus-4-6',
|
|
@@ -207,18 +206,26 @@ export const PROVIDER_INFO = {
|
|
|
207
206
|
'gemini-cli': {
|
|
208
207
|
name: 'gemini-cli',
|
|
209
208
|
requiresApiKey: false,
|
|
210
|
-
docs: 'Google Gemini via the local `gemini` CLI (free Google-account login). No API key — auth flows through whatever account `gemini` is logged in with. Requires @google/gemini-cli installed.',
|
|
209
|
+
docs: 'Google Gemini via the local `gemini` CLI (free Google-account login). No API key — auth flows through whatever account `gemini` is logged in with. Requires @google/gemini-cli installed. defaultModel is null on purpose: the set of models a given Google login may use is decided server-side, so by default lazyclaw passes no `-m` and lets the CLI pick the account-appropriate model. The suggestedModels below are optional power-user overrides.',
|
|
211
210
|
endpoint: 'subprocess: gemini -p',
|
|
212
|
-
defaultModel:
|
|
211
|
+
defaultModel: null,
|
|
213
212
|
suggestedModels: ['gemini-2.5-pro', 'gemini-2.5-flash', 'pro', 'flash'],
|
|
214
213
|
},
|
|
215
214
|
'codex-cli': {
|
|
216
215
|
name: 'codex-cli',
|
|
217
216
|
requiresApiKey: false,
|
|
218
|
-
|
|
217
|
+
// defaultModel is null by design. A ChatGPT-account codex login only
|
|
218
|
+
// accepts the models that plan is entitled to (read from ~/.codex/
|
|
219
|
+
// config.toml, e.g. "gpt-5.5"); forcing a marketing id like the old
|
|
220
|
+
// default "gpt-5-codex" makes the API reject the turn with HTTP 400
|
|
221
|
+
// "The '<model>' model is not supported when using Codex with a ChatGPT
|
|
222
|
+
// account." So by default we pass NO `-m` and let codex use its own
|
|
223
|
+
// account default. suggestedModels is a single best-effort hint; the
|
|
224
|
+
// reliable path is the picker's "use the provider's own default" entry.
|
|
225
|
+
docs: 'OpenAI via the local `codex` CLI (ChatGPT Plus/Pro subscription). No API key — auth flows through `codex` login. Requires the codex CLI installed. By default no model is forced: codex uses the account default from ~/.codex/config.toml (a ChatGPT-plan login rejects models it is not entitled to). Override only with a model your plan allows.',
|
|
219
226
|
endpoint: 'subprocess: codex exec',
|
|
220
|
-
defaultModel:
|
|
221
|
-
suggestedModels: ['gpt-5
|
|
227
|
+
defaultModel: null,
|
|
228
|
+
suggestedModels: ['gpt-5.5'],
|
|
222
229
|
},
|
|
223
230
|
anthropic: {
|
|
224
231
|
name: 'anthropic',
|
|
@@ -226,9 +233,8 @@ export const PROVIDER_INFO = {
|
|
|
226
233
|
keyPrefix: 'sk-ant-',
|
|
227
234
|
docs: 'Anthropic Messages API (pay-per-token, requires sk-ant- key). Supports streaming + extended thinking. For subscription billing, use the `claude-cli` provider instead.',
|
|
228
235
|
endpoint: 'https://api.anthropic.com/v1/messages',
|
|
229
|
-
defaultModel: 'claude-opus-4-
|
|
236
|
+
defaultModel: 'claude-opus-4-8',
|
|
230
237
|
suggestedModels: [
|
|
231
|
-
'claude-fable-5',
|
|
232
238
|
'claude-opus-4-8',
|
|
233
239
|
'claude-opus-4-7',
|
|
234
240
|
'claude-opus-4-6',
|
package/providers/retry.mjs
CHANGED
|
@@ -24,6 +24,17 @@ const DEFAULT_ATTEMPTS = 3;
|
|
|
24
24
|
const DEFAULT_MAX_BACKOFF_MS = 60_000;
|
|
25
25
|
const ABSOLUTE_MAX_BACKOFF_MS = 5 * 60_000; // hard ceiling, ignores caller
|
|
26
26
|
|
|
27
|
+
// Errors safe to retry before the first chunk: rate limits AND transient
|
|
28
|
+
// server-overload / 5xx. Anthropic surfaces overload as HTTP 529
|
|
29
|
+
// (overloaded_error); generic upstreams use 5xx. 4xx other than 429 are
|
|
30
|
+
// caller-fault and never retried.
|
|
31
|
+
function isRetriableError(err) {
|
|
32
|
+
const code = err?.code;
|
|
33
|
+
if (code === 'RATE_LIMIT' || code === 'OVERLOADED' || code === 'SERVER_ERROR') return true;
|
|
34
|
+
const status = err?.status;
|
|
35
|
+
return Number.isFinite(status) && status >= 500 && status < 600;
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
function clampBackoff(retryAfterMs, max) {
|
|
28
39
|
const ceiling = Math.min(max, ABSOLUTE_MAX_BACKOFF_MS);
|
|
29
40
|
if (!Number.isFinite(retryAfterMs) || retryAfterMs < 0) return ceiling;
|
|
@@ -85,8 +96,9 @@ export function withRateLimitRetry(provider, retryOpts = {}) {
|
|
|
85
96
|
lastErr = err;
|
|
86
97
|
// Mid-stream errors cannot be retried: we'd produce duplicate text.
|
|
87
98
|
if (yieldedAny) throw err;
|
|
88
|
-
//
|
|
89
|
-
|
|
99
|
+
// Retry rate-limit AND transient server-overload/5xx, only while
|
|
100
|
+
// attempts remain. 4xx other than 429 bubble unchanged.
|
|
101
|
+
if (!isRetriableError(err) || attempt >= attempts) throw err;
|
|
90
102
|
const wait = clampBackoff(err.retryAfterMs, maxBackoffMs);
|
|
91
103
|
if (typeof onRetry === 'function') {
|
|
92
104
|
try { onRetry({ attempt: attempt + 1, retryAfterMs: wait, err }); } catch { /* swallow */ }
|
|
@@ -68,7 +68,7 @@ export async function callOnce({
|
|
|
68
68
|
const url = `${(baseUrl || DEFAULT_BASE).replace(/\/$/, '')}/messages`;
|
|
69
69
|
const fetchFn = fetchImpl || globalThis.fetch;
|
|
70
70
|
const body = {
|
|
71
|
-
model: model || 'claude-opus-4-
|
|
71
|
+
model: model || 'claude-opus-4-8',
|
|
72
72
|
max_tokens: maxTokens,
|
|
73
73
|
messages,
|
|
74
74
|
};
|
|
@@ -137,10 +137,24 @@ export function parseResponse(json) {
|
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
const text = textParts.join('');
|
|
140
|
+
// The model hit the output token ceiling mid-turn: the text or tool_use
|
|
141
|
+
// block is partial/garbage. Flag it so the runner stops instead of acting on
|
|
142
|
+
// a cut-off final answer or an incomplete tool call.
|
|
143
|
+
const truncated = json?.stop_reason === 'max_tokens';
|
|
144
|
+
// Normalize token usage so agent_turn can accumulate spend across the loop
|
|
145
|
+
// (and team turns can feed the cost cap). null when the response omits it.
|
|
146
|
+
const usage = json?.usage
|
|
147
|
+
? {
|
|
148
|
+
inputTokens: json.usage.input_tokens || 0,
|
|
149
|
+
outputTokens: json.usage.output_tokens || 0,
|
|
150
|
+
cacheCreationInputTokens: json.usage.cache_creation_input_tokens || 0,
|
|
151
|
+
cacheReadInputTokens: json.usage.cache_read_input_tokens || 0,
|
|
152
|
+
}
|
|
153
|
+
: null;
|
|
140
154
|
if (calls.length === 0) {
|
|
141
|
-
return { kind: 'final', text, raw: json };
|
|
155
|
+
return { kind: 'final', text, truncated, usage, raw: json };
|
|
142
156
|
}
|
|
143
|
-
return { kind: 'tool_calls', text, calls, assistantContent: content, raw: json };
|
|
157
|
+
return { kind: 'tool_calls', text, calls, truncated, usage, assistantContent: content, raw: json };
|
|
144
158
|
}
|
|
145
159
|
|
|
146
160
|
// Anthropic accepts the agent_turn-native {role, content} shape
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// point at a deterministic shim script.
|
|
26
26
|
|
|
27
27
|
import { spawn } from 'node:child_process';
|
|
28
|
+
import { classifyCliExit } from '../cli_error.mjs';
|
|
28
29
|
|
|
29
30
|
const DEFAULT_BIN = 'claude';
|
|
30
31
|
const LAZYCLAW_TO_CLAUDE_TOOL = {
|
|
@@ -107,6 +108,11 @@ async function readUntilDone(proc) {
|
|
|
107
108
|
let acc = '';
|
|
108
109
|
let assistantFallback = '';
|
|
109
110
|
let resultText = '';
|
|
111
|
+
// Per-turn usage rides the `assistant` event (the streaming `result` event
|
|
112
|
+
// reports zero tokens); cost rides the result event. Accumulate so the cost
|
|
113
|
+
// cap can account for this default subscription path.
|
|
114
|
+
const u = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, cost: 0, saw: false };
|
|
115
|
+
let truncated = false; // a --max-turns cap hit (result subtype error_max_turns)
|
|
110
116
|
proc.stdout.setEncoding('utf8');
|
|
111
117
|
proc.stderr.setEncoding('utf8');
|
|
112
118
|
proc.stdout.on('data', (chunk) => {
|
|
@@ -131,22 +137,80 @@ async function readUntilDone(proc) {
|
|
|
131
137
|
if (obj?.type === 'result' && typeof obj.result === 'string') {
|
|
132
138
|
resultText = obj.result;
|
|
133
139
|
}
|
|
140
|
+
if (obj?.type === 'assistant' && obj?.message?.usage) {
|
|
141
|
+
const mu = obj.message.usage;
|
|
142
|
+
u.input += mu.input_tokens || 0;
|
|
143
|
+
u.output += mu.output_tokens || 0;
|
|
144
|
+
u.cacheCreate += mu.cache_creation_input_tokens || 0;
|
|
145
|
+
u.cacheRead += mu.cache_read_input_tokens || 0;
|
|
146
|
+
u.saw = true;
|
|
147
|
+
}
|
|
148
|
+
if (obj?.type === 'result' && Number.isFinite(obj.total_cost_usd)) u.cost = obj.total_cost_usd;
|
|
149
|
+
if (obj?.type === 'result' && obj.is_error && /max_turns/.test(String(obj.subtype || ''))) truncated = true;
|
|
134
150
|
}
|
|
135
151
|
});
|
|
136
152
|
proc.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
137
153
|
proc.on('error', reject);
|
|
154
|
+
const buildUsage = () => ((u.saw || u.cost)
|
|
155
|
+
? {
|
|
156
|
+
inputTokens: u.input, outputTokens: u.output,
|
|
157
|
+
cacheCreationInputTokens: u.cacheCreate, cacheReadInputTokens: u.cacheRead,
|
|
158
|
+
totalCostUsd: u.cost,
|
|
159
|
+
}
|
|
160
|
+
: null);
|
|
138
161
|
proc.on('close', (code) => {
|
|
139
|
-
|
|
140
|
-
|
|
162
|
+
// A --max-turns cap hit exits non-zero but is NOT a failure to surface as
|
|
163
|
+
// an error — return it as truncated so agent_turn stops cleanly with
|
|
164
|
+
// stoppedBy:'truncated' ("raise maxTokens") rather than a CLI exit error.
|
|
165
|
+
if (code !== 0 && !truncated) {
|
|
166
|
+
// Transient upstream throttle → retriable RATE_LIMIT; otherwise keep
|
|
167
|
+
// the non-retriable CLAUDE_CLI_EXIT code.
|
|
168
|
+
const cls = classifyCliExit(stderr);
|
|
169
|
+
const exitCode = cls.code === 'RATE_LIMIT' ? 'RATE_LIMIT' : 'CLAUDE_CLI_EXIT';
|
|
170
|
+
const err = new ClaudeCliToolUseError(`claude CLI exit ${code}: ${stderr.slice(0, 300)}`, exitCode, stderr);
|
|
171
|
+
if (cls.retryAfterMs !== undefined) err.retryAfterMs = cls.retryAfterMs;
|
|
172
|
+
return reject(err);
|
|
141
173
|
}
|
|
142
174
|
// Prefer accumulated stream deltas; fall back to the assistant
|
|
143
175
|
// record or the final result text when streaming was disabled.
|
|
144
176
|
const text = acc || assistantFallback || resultText || '';
|
|
145
|
-
resolve(text);
|
|
177
|
+
resolve({ text, usage: buildUsage(), truncated });
|
|
146
178
|
});
|
|
147
179
|
});
|
|
148
180
|
}
|
|
149
181
|
|
|
182
|
+
// Build the `claude` argv for the tool-use path. Runs LEAN by default —
|
|
183
|
+
// single-sourced with providers/claude_cli.mjs's policy — so this path (agentic
|
|
184
|
+
// chat / every mention-router team turn / the per-turn trainer calls) does NOT
|
|
185
|
+
// re-load the user's CLAUDE.md/skills/hooks/MCP (~180k tokens/spawn, measured).
|
|
186
|
+
// It previously omitted the lean flags, which is why the streaming provider's
|
|
187
|
+
// lean fix didn't help these paths. Pass lean:false to restore the full env.
|
|
188
|
+
export function buildToolUseArgs({ prompt, model, system, tools = [], permissionMode = 'bypassPermissions', lean, maxTurns } = {}) {
|
|
189
|
+
const args = [
|
|
190
|
+
'-p', prompt,
|
|
191
|
+
'--output-format', 'stream-json',
|
|
192
|
+
'--include-partial-messages',
|
|
193
|
+
'--verbose',
|
|
194
|
+
'--permission-mode', permissionMode,
|
|
195
|
+
];
|
|
196
|
+
if (lean !== false) {
|
|
197
|
+
args.push('--setting-sources', '', '--strict-mcp-config');
|
|
198
|
+
}
|
|
199
|
+
// Bound Claude Code's internal autonomous loop. This adapter DOES let the
|
|
200
|
+
// agent use tools (--tools whitelist below), but the internal loop was
|
|
201
|
+
// otherwise uncapped (lazyclaw's own maxIterations is a no-op for claude-cli).
|
|
202
|
+
const cap = maxTurns == null ? 16 : maxTurns;
|
|
203
|
+
if (cap > 0) args.push('--max-turns', String(cap));
|
|
204
|
+
if (model) args.push('--model', model);
|
|
205
|
+
if (system && String(system).trim()) {
|
|
206
|
+
args.push('--system-prompt', String(system));
|
|
207
|
+
}
|
|
208
|
+
// Phase 19: pass the lazyclaw whitelist through to claude's --tools even when
|
|
209
|
+
// empty (an empty string explicitly disables every tool).
|
|
210
|
+
args.push('--tools', toClaudeTools(tools));
|
|
211
|
+
return args;
|
|
212
|
+
}
|
|
213
|
+
|
|
150
214
|
export async function callOnce({
|
|
151
215
|
messages,
|
|
152
216
|
tools = [],
|
|
@@ -158,6 +222,8 @@ export async function callOnce({
|
|
|
158
222
|
signal,
|
|
159
223
|
bin,
|
|
160
224
|
cwd,
|
|
225
|
+
lean,
|
|
226
|
+
maxTurns,
|
|
161
227
|
permissionMode = 'bypassPermissions',
|
|
162
228
|
} = {}) {
|
|
163
229
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
@@ -167,21 +233,7 @@ export async function callOnce({
|
|
|
167
233
|
if (!prompt) {
|
|
168
234
|
throw new ClaudeCliToolUseError('messages produced an empty prompt', 'NO_PROMPT');
|
|
169
235
|
}
|
|
170
|
-
const args =
|
|
171
|
-
'-p', prompt,
|
|
172
|
-
'--output-format', 'stream-json',
|
|
173
|
-
'--include-partial-messages',
|
|
174
|
-
'--verbose',
|
|
175
|
-
'--permission-mode', permissionMode,
|
|
176
|
-
];
|
|
177
|
-
if (model) args.push('--model', model);
|
|
178
|
-
if (system && String(system).trim()) {
|
|
179
|
-
args.push('--system-prompt', String(system));
|
|
180
|
-
}
|
|
181
|
-
// Phase 19: pass the lazyclaw whitelist through to claude's --tools
|
|
182
|
-
// even when empty (an empty string explicitly disables every tool).
|
|
183
|
-
const toolsArg = toClaudeTools(tools);
|
|
184
|
-
args.push('--tools', toolsArg);
|
|
236
|
+
const args = buildToolUseArgs({ prompt, model, system, tools, permissionMode, lean, maxTurns });
|
|
185
237
|
|
|
186
238
|
const binPath = bin || process.env.LAZYCLAW_CLAUDE_BIN || DEFAULT_BIN;
|
|
187
239
|
let proc;
|
|
@@ -200,8 +252,8 @@ export async function callOnce({
|
|
|
200
252
|
const onAbort = () => { try { proc.kill('SIGTERM'); } catch { /* gone */ } };
|
|
201
253
|
if (signal) signal.addEventListener('abort', onAbort);
|
|
202
254
|
try {
|
|
203
|
-
const text = await readUntilDone(proc);
|
|
204
|
-
return { kind: 'final', text, raw: null };
|
|
255
|
+
const { text, usage, truncated } = await readUntilDone(proc);
|
|
256
|
+
return { kind: 'final', text, usage, truncated, raw: null };
|
|
205
257
|
} finally {
|
|
206
258
|
if (signal) signal.removeEventListener('abort', onAbort);
|
|
207
259
|
}
|
|
@@ -78,6 +78,7 @@ export async function callOnce({
|
|
|
78
78
|
model,
|
|
79
79
|
apiKey,
|
|
80
80
|
system,
|
|
81
|
+
maxTokens,
|
|
81
82
|
baseUrl,
|
|
82
83
|
fetchImpl,
|
|
83
84
|
signal,
|
|
@@ -88,7 +89,7 @@ export async function callOnce({
|
|
|
88
89
|
if (!apiKey) {
|
|
89
90
|
throw new GeminiToolUseError('apiKey is required', 'NO_API_KEY');
|
|
90
91
|
}
|
|
91
|
-
const m = model || 'gemini-2.5-
|
|
92
|
+
const m = model || 'gemini-2.5-pro';
|
|
92
93
|
const url = `${(baseUrl || DEFAULT_BASE).replace(/\/$/, '')}/models/${encodeURIComponent(m)}:generateContent`;
|
|
93
94
|
const fetchFn = fetchImpl || globalThis.fetch;
|
|
94
95
|
|
|
@@ -97,6 +98,13 @@ export async function callOnce({
|
|
|
97
98
|
if (system && String(system).trim()) {
|
|
98
99
|
body.system_instruction = { parts: [{ text: String(system) }] };
|
|
99
100
|
}
|
|
101
|
+
// Cap the output like the anthropic/openai adapters do. Gemini carries the
|
|
102
|
+
// ceiling on generationConfig.maxOutputTokens; merge so a caller that already
|
|
103
|
+
// populated generationConfig keeps their other fields. Only a finite,
|
|
104
|
+
// positive maxTokens applies — omitting it leaves generationConfig untouched.
|
|
105
|
+
if (Number.isFinite(maxTokens) && maxTokens > 0) {
|
|
106
|
+
body.generationConfig = { ...(body.generationConfig || {}), maxOutputTokens: maxTokens };
|
|
107
|
+
}
|
|
100
108
|
|
|
101
109
|
const res = await fetchFn(url, {
|
|
102
110
|
method: 'POST',
|
|
@@ -137,15 +145,34 @@ export function parseResponse(json) {
|
|
|
137
145
|
}
|
|
138
146
|
}
|
|
139
147
|
const text = textParts.join('');
|
|
148
|
+
// finishReason MAX_TOKENS = the turn was cut at the output ceiling; the text
|
|
149
|
+
// or functionCall args are partial. Flag it so the runner stops.
|
|
150
|
+
const fr = candidate?.finishReason;
|
|
151
|
+
const truncated = fr === 'MAX_TOKENS' || fr === 'SAFETY' || fr === 'RECITATION';
|
|
152
|
+
// Normalize token usage so agent_turn can accumulate spend across the loop
|
|
153
|
+
// (and team turns can feed the cost cap). null when the response omits it.
|
|
154
|
+
const um = json?.usageMetadata;
|
|
155
|
+
const usage = um
|
|
156
|
+
? {
|
|
157
|
+
// promptTokenCount is cache-INCLUSIVE; report NET (minus cached) to
|
|
158
|
+
// match Anthropic's convention so the cost cap doesn't bill the cached
|
|
159
|
+
// tokens at BOTH the input rate and the cache-read rate.
|
|
160
|
+
inputTokens: (um.promptTokenCount || 0) - (um.cachedContentTokenCount || 0),
|
|
161
|
+
outputTokens: um.candidatesTokenCount || 0,
|
|
162
|
+
cacheReadInputTokens: um.cachedContentTokenCount || 0,
|
|
163
|
+
}
|
|
164
|
+
: null;
|
|
140
165
|
if (calls.length === 0) {
|
|
141
|
-
return { kind: 'final', text, raw: json };
|
|
166
|
+
return { kind: 'final', text, truncated, usage, raw: json };
|
|
142
167
|
}
|
|
143
168
|
// assistantContent is the entire model turn so the runner can echo it
|
|
144
169
|
// back into `contents` for the next request.
|
|
145
170
|
return {
|
|
146
171
|
kind: 'tool_calls',
|
|
147
172
|
text,
|
|
173
|
+
truncated,
|
|
148
174
|
calls,
|
|
175
|
+
usage,
|
|
149
176
|
assistantContent: candidate?.content || { role: 'model', parts },
|
|
150
177
|
raw: json,
|
|
151
178
|
};
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
const DEFAULT_BASE = 'https://api.openai.com/v1';
|
|
19
19
|
const DEFAULT_MAX_TOKENS = 4096;
|
|
20
20
|
|
|
21
|
+
// OpenAI o-series reasoning models (o1/o3/o4...) reject `max_tokens` with
|
|
22
|
+
// HTTP 400 'Unsupported parameter: max_tokens, use max_completion_tokens'.
|
|
23
|
+
function isReasoningModel(model) {
|
|
24
|
+
return /^o\d/i.test(String(model || ''));
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
export class OpenAIToolUseError extends Error {
|
|
22
28
|
constructor(message, code, body) {
|
|
23
29
|
super(message);
|
|
@@ -71,8 +77,11 @@ export async function callOnce({
|
|
|
71
77
|
const body = {
|
|
72
78
|
model: model || 'gpt-4.1',
|
|
73
79
|
messages: fullMessages,
|
|
74
|
-
max_tokens: maxTokens,
|
|
75
80
|
};
|
|
81
|
+
// Reasoning models take max_completion_tokens; everything else takes
|
|
82
|
+
// max_tokens. Never emit both — the o-series rejects max_tokens.
|
|
83
|
+
if (isReasoningModel(body.model)) body.max_completion_tokens = maxTokens;
|
|
84
|
+
else body.max_tokens = maxTokens;
|
|
76
85
|
if (tools && tools.length) body.tools = tools;
|
|
77
86
|
|
|
78
87
|
const res = await fetchFn(url, {
|
|
@@ -98,20 +107,41 @@ export function parseResponse(json) {
|
|
|
98
107
|
const msg = choice?.message || {};
|
|
99
108
|
const rawToolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
|
100
109
|
const text = typeof msg.content === 'string' ? msg.content : '';
|
|
110
|
+
// finish_reason 'length' = the response was cut at the token ceiling, so the
|
|
111
|
+
// content or a tool_call's arguments JSON is partial. Flag it so the runner
|
|
112
|
+
// stops instead of acting on truncated output / empty-parsed args.
|
|
113
|
+
const truncated = choice?.finish_reason === 'length';
|
|
114
|
+
// Normalize token usage so agent_turn can accumulate spend across the loop
|
|
115
|
+
// (and team turns can feed the cost cap). null when the response omits it.
|
|
116
|
+
const usage = json?.usage
|
|
117
|
+
? { inputTokens: json.usage.prompt_tokens || 0, outputTokens: json.usage.completion_tokens || 0 }
|
|
118
|
+
: null;
|
|
101
119
|
if (rawToolCalls.length === 0) {
|
|
102
|
-
return { kind: 'final', text, raw: json };
|
|
120
|
+
return { kind: 'final', text, truncated, usage, raw: json };
|
|
103
121
|
}
|
|
104
122
|
const calls = rawToolCalls.map((tc) => {
|
|
105
123
|
let input = {};
|
|
124
|
+
let parseError = null;
|
|
106
125
|
const a = tc?.function?.arguments;
|
|
107
126
|
if (typeof a === 'string') {
|
|
108
|
-
|
|
127
|
+
// Empty-string args mean "no arguments" → {}. A non-empty string that
|
|
128
|
+
// fails to parse is malformed: record the error so agent_turn surfaces a
|
|
129
|
+
// tool failure instead of silently running the tool with {}.
|
|
130
|
+
if (a.trim() === '') input = {};
|
|
131
|
+
else { try { input = JSON.parse(a); } catch (e) { input = {}; parseError = `malformed tool arguments: ${e.message}`; } }
|
|
109
132
|
} else if (a && typeof a === 'object') {
|
|
110
133
|
input = a;
|
|
134
|
+
} else if (a !== undefined && a !== null) {
|
|
135
|
+
// arguments is present but neither a string nor an object (e.g. a number
|
|
136
|
+
// or boolean) — an unexpected wire shape. Surface it as a tool failure
|
|
137
|
+
// instead of silently running the tool with {} (mirrors the malformed
|
|
138
|
+
// JSON-string branch above).
|
|
139
|
+
input = {};
|
|
140
|
+
parseError = `unexpected tool arguments type: ${typeof a}`;
|
|
111
141
|
}
|
|
112
|
-
return { id: tc.id, name: tc?.function?.name, input };
|
|
142
|
+
return { id: tc.id, name: tc?.function?.name, input, ...(parseError ? { parseError } : {}) };
|
|
113
143
|
});
|
|
114
|
-
return { kind: 'tool_calls', text, calls, assistantContent: msg, raw: json };
|
|
144
|
+
return { kind: 'tool_calls', text, calls, truncated, usage, assistantContent: msg, raw: json };
|
|
115
145
|
}
|
|
116
146
|
|
|
117
147
|
// OpenAI accepts the agent_turn-native {role, content} shape directly.
|
|
@@ -3,12 +3,23 @@
|
|
|
3
3
|
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
// Probe whether a usable `sandbox-exec` is present. The argument object is a
|
|
7
|
+
// test seam: `platform` overrides the OS gate and `probe` overrides the real
|
|
8
|
+
// invocation so the try/catch semantics are verifiable without a host sandbox.
|
|
9
|
+
export function available({ platform = process.platform, probe = _realProbe } = {}) {
|
|
10
|
+
if (platform !== 'darwin') return false;
|
|
11
|
+
try { probe(); return true; }
|
|
9
12
|
catch { return false; }
|
|
10
13
|
}
|
|
11
14
|
|
|
15
|
+
// Run a no-op (`/usr/bin/true`) under a permissive profile. This actually
|
|
16
|
+
// exercises the sandboxing path. NOTE: `-h` is NOT a help flag on macOS — it is
|
|
17
|
+
// an illegal option that exits non-zero, so the previous `sandbox-exec -h`
|
|
18
|
+
// probe reported seatbelt unavailable on every mac.
|
|
19
|
+
function _realProbe() {
|
|
20
|
+
execFileSync('sandbox-exec', ['-p', '(version 1)(allow default)', '/usr/bin/true'], { stdio: 'ignore' });
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
// Escape a path for an SBPL double-quoted string literal. Without this a path
|
|
13
24
|
// containing `"` (or a backslash) could close the string and inject arbitrary
|
|
14
25
|
// SBPL directives — e.g. re-enabling `(allow network*)` or widening file
|
|
@@ -25,20 +36,34 @@ function sbplPath(p) {
|
|
|
25
36
|
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
26
37
|
}
|
|
27
38
|
|
|
39
|
+
// Temp dirs are always writable — interpreters and tools need scratch space, and
|
|
40
|
+
// confining writes to the workspace alone breaks too much (e.g. node/npm caches).
|
|
41
|
+
const TMP_WRITABLE = ['/tmp', '/private/tmp', '/private/var/folders'];
|
|
42
|
+
|
|
43
|
+
// Build a FILESYSTEM-CONFINEMENT profile, not a strict deny-default one. A
|
|
44
|
+
// `(deny default)` profile silently kills dynamically-linked binaries (python3,
|
|
45
|
+
// node, git) at the dyld/mach bootstrap stage on modern macOS — confinement that
|
|
46
|
+
// breaks every real interpreter is worse than none. Instead we start from
|
|
47
|
+
// `(allow default)` and carve out the high-value protections: writes are denied
|
|
48
|
+
// everywhere except the workspace + temp, and named secret dirs stay unreadable
|
|
49
|
+
// even though reads are otherwise allowed. Network is allowed unless opts.allowNet
|
|
50
|
+
// is explicitly false.
|
|
51
|
+
//
|
|
52
|
+
// opts.readWrite : string[] workspace roots that may be written (default [cwd])
|
|
53
|
+
// opts.denyRead : string[] dirs whose reads are blocked (e.g. ~/.ssh, ~/.aws)
|
|
54
|
+
// opts.allowNet : boolean default false → adds (deny network*)
|
|
28
55
|
export function buildArgv(argv, opts = {}) {
|
|
29
|
-
const readOnly = opts.readOnly || [];
|
|
30
56
|
const readWrite = opts.readWrite || [process.cwd()];
|
|
57
|
+
const denyRead = opts.denyRead || [];
|
|
31
58
|
const allowNet = opts.allowNet === true;
|
|
59
|
+
const writable = [...TMP_WRITABLE, ...readWrite];
|
|
32
60
|
const profile = [
|
|
33
61
|
'(version 1)',
|
|
34
|
-
'(
|
|
35
|
-
'(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
'(
|
|
39
|
-
allowNet ? '(allow network*)' : '(deny network*)',
|
|
40
|
-
...readOnly.map(p => `(allow file-read* (subpath "${sbplPath(p)}"))`),
|
|
41
|
-
...readWrite.map(p => `(allow file-read* file-write* (subpath "${sbplPath(p)}"))`),
|
|
62
|
+
'(allow default)',
|
|
63
|
+
'(deny file-write*)',
|
|
64
|
+
`(allow file-write* ${writable.map(p => `(subpath "${sbplPath(p)}")`).join(' ')})`,
|
|
65
|
+
...denyRead.map(p => `(deny file-read* (subpath "${sbplPath(p)}"))`),
|
|
66
|
+
...(allowNet ? [] : ['(deny network*)']),
|
|
42
67
|
].join('\n');
|
|
43
68
|
return ['sandbox-exec', '-p', profile, ...argv];
|
|
44
69
|
}
|