lazyclaw 3.88.0 → 3.99.4
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.md +26 -3
- package/browse.mjs +0 -0
- package/cli.mjs +830 -38
- package/config_features.mjs +241 -0
- package/cron.mjs +359 -0
- package/package.json +7 -1
- package/providers/claude_cli.mjs +221 -0
- package/providers/registry.mjs +72 -15
- package/sandbox.mjs +127 -0
- package/skills_install.mjs +239 -0
- package/workspace.mjs +158 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Claude subscription provider (no API key).
|
|
2
|
+
//
|
|
3
|
+
// Spawns the local `claude` CLI binary that ships with Claude Code and
|
|
4
|
+
// streams the JSON event format it emits with:
|
|
5
|
+
//
|
|
6
|
+
// claude -p "<prompt>" --output-format stream-json
|
|
7
|
+
// --include-partial-messages --verbose [--model opus|sonnet|haiku]
|
|
8
|
+
//
|
|
9
|
+
// The user's authentication is whatever `claude` is already logged in
|
|
10
|
+
// with — i.e. an Anthropic Pro / Max / Team subscription session — so
|
|
11
|
+
// no API key is needed and no key shows up in the lazyclaw config.
|
|
12
|
+
//
|
|
13
|
+
// Why this is a separate provider from ./anthropic.mjs:
|
|
14
|
+
// - anthropic.mjs talks to api.anthropic.com directly and requires
|
|
15
|
+
// `sk-ant-` keys (pay-per-token).
|
|
16
|
+
// - claude_cli.mjs delegates auth + billing entirely to the `claude`
|
|
17
|
+
// CLI's already-established session (Pro/Max subscription quota).
|
|
18
|
+
// Both can coexist; users pick at onboard time.
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { spawnSandboxed } from '../sandbox.mjs';
|
|
22
|
+
|
|
23
|
+
class AbortError extends Error {
|
|
24
|
+
constructor(message = 'aborted') {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'AbortError';
|
|
27
|
+
this.code = 'ABORT';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class CliMissingError extends Error {
|
|
32
|
+
constructor() {
|
|
33
|
+
super('claude CLI not found in PATH — install Claude Code or use the anthropic provider');
|
|
34
|
+
this.name = 'ClaudeCliMissingError';
|
|
35
|
+
this.code = 'CLI_MISSING';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class CliExitError extends Error {
|
|
40
|
+
constructor(code, signal, stderr) {
|
|
41
|
+
super(`claude CLI exited ${code ?? signal}: ${String(stderr).slice(0, 400)}`);
|
|
42
|
+
this.name = 'ClaudeCliExitError';
|
|
43
|
+
this.code = 'CLI_EXIT';
|
|
44
|
+
this.exitCode = code;
|
|
45
|
+
this.signal = signal;
|
|
46
|
+
this.stderr = stderr;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Map canonical Anthropic model ids and friendly aliases to the short
|
|
51
|
+
// form `claude --model` actually accepts. The Python dashboard ran into
|
|
52
|
+
// the same issue (FF1) — passing the full id silently hangs the CLI.
|
|
53
|
+
const _CLI_MODEL_ALIASES = {
|
|
54
|
+
'claude-opus-4-7': 'opus',
|
|
55
|
+
'claude-opus-4-6': 'opus',
|
|
56
|
+
'claude-sonnet-4-6': 'sonnet',
|
|
57
|
+
'claude-sonnet-4-5': 'sonnet',
|
|
58
|
+
'claude-haiku-4-5': 'haiku',
|
|
59
|
+
'claude-haiku-4-5-20251001': 'haiku',
|
|
60
|
+
opus: 'opus',
|
|
61
|
+
sonnet: 'sonnet',
|
|
62
|
+
haiku: 'haiku',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function resolveModelAlias(model) {
|
|
66
|
+
if (!model) return '';
|
|
67
|
+
const lower = String(model).toLowerCase();
|
|
68
|
+
return _CLI_MODEL_ALIASES[lower] ?? '';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Flatten the chat-style messages array into a single -p prompt the
|
|
72
|
+
// CLI accepts. Mirrors how the dashboard formats Claude turns when it
|
|
73
|
+
// has no native multi-turn channel.
|
|
74
|
+
function buildPrompt(messages, system) {
|
|
75
|
+
const parts = [];
|
|
76
|
+
if (system) parts.push(`[System instructions: ${system}]`);
|
|
77
|
+
for (const m of messages) {
|
|
78
|
+
if (!m || !m.content) continue;
|
|
79
|
+
if (m.role === 'system' && !system) parts.push(`[System instructions: ${m.content}]`);
|
|
80
|
+
else if (m.role === 'user') parts.push(`User: ${m.content}`);
|
|
81
|
+
else if (m.role === 'assistant') parts.push(`Assistant: ${m.content}`);
|
|
82
|
+
}
|
|
83
|
+
// Trailing "Assistant:" cue so the CLI continues the conversation.
|
|
84
|
+
return parts.length ? parts.join('\n') + '\n\nAssistant:' : '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Walk the partial-message JSON stream and pull text deltas out. The
|
|
88
|
+
// `claude` CLI emits one JSON object per line; the shapes we care about:
|
|
89
|
+
// { type: 'stream_event', event: { type: 'content_block_delta',
|
|
90
|
+
// delta: { type: 'text_delta', text: '...' } } }
|
|
91
|
+
// { type: 'result', usage: {...}, total_cost_usd: ... }
|
|
92
|
+
function extractTextDelta(obj) {
|
|
93
|
+
if (!obj || typeof obj !== 'object') return '';
|
|
94
|
+
if (obj.type !== 'stream_event') return '';
|
|
95
|
+
const ev = obj.event || {};
|
|
96
|
+
if (ev.type === 'content_block_delta') {
|
|
97
|
+
const d = ev.delta || {};
|
|
98
|
+
if (d.type === 'text_delta' && typeof d.text === 'string') return d.text;
|
|
99
|
+
}
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const claudeCliProvider = {
|
|
104
|
+
name: 'claude-cli',
|
|
105
|
+
/**
|
|
106
|
+
* @param {Array<{role:string,content:string}>} messages
|
|
107
|
+
* @param {{
|
|
108
|
+
* model?: string,
|
|
109
|
+
* system?: string,
|
|
110
|
+
* signal?: AbortSignal,
|
|
111
|
+
* bin?: string, // override the resolved binary (tests)
|
|
112
|
+
* cwd?: string, // working dir for the subprocess
|
|
113
|
+
* onUsage?: (u: object) => void,
|
|
114
|
+
* }} opts
|
|
115
|
+
*/
|
|
116
|
+
async *sendMessage(messages, opts = {}) {
|
|
117
|
+
const bin = opts.bin || 'claude';
|
|
118
|
+
const prompt = buildPrompt(messages, opts.system || messages.find(m => m.role === 'system')?.content);
|
|
119
|
+
if (!prompt) return;
|
|
120
|
+
|
|
121
|
+
const args = [
|
|
122
|
+
'-p', prompt,
|
|
123
|
+
'--output-format', 'stream-json',
|
|
124
|
+
'--include-partial-messages',
|
|
125
|
+
'--verbose',
|
|
126
|
+
];
|
|
127
|
+
const modelAlias = resolveModelAlias(opts.model);
|
|
128
|
+
if (modelAlias) args.push('--model', modelAlias);
|
|
129
|
+
|
|
130
|
+
if (opts.signal?.aborted) throw new AbortError('aborted before spawn');
|
|
131
|
+
|
|
132
|
+
let proc;
|
|
133
|
+
try {
|
|
134
|
+
// opts.sandbox (parsed by parseSandboxSpec) routes the spawn
|
|
135
|
+
// through `docker run` instead of running `claude` on the
|
|
136
|
+
// host. spawnSandboxed is a no-op when sandbox is null, so
|
|
137
|
+
// the un-sandboxed path stays bit-identical to before.
|
|
138
|
+
proc = spawnSandboxed(opts.sandbox || null, bin, args, {
|
|
139
|
+
cwd: opts.cwd || process.cwd(),
|
|
140
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
141
|
+
});
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// ENOENT means the binary isn't on PATH. Surface a clearer error
|
|
144
|
+
// than the raw spawn failure so onboard / doctor can hint at
|
|
145
|
+
// "install Claude Code or pick a different provider".
|
|
146
|
+
if (err && err.code === 'ENOENT') throw new CliMissingError();
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const onAbort = () => {
|
|
151
|
+
try { proc.kill('SIGTERM'); } catch (_) { /* ignore */ }
|
|
152
|
+
};
|
|
153
|
+
if (opts.signal) opts.signal.addEventListener('abort', onAbort);
|
|
154
|
+
|
|
155
|
+
let stderr = '';
|
|
156
|
+
proc.stderr.setEncoding('utf8');
|
|
157
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
158
|
+
|
|
159
|
+
// The stdout protocol is newline-delimited JSON. We buffer partial
|
|
160
|
+
// lines across chunks (shapes can straddle a single read).
|
|
161
|
+
proc.stdout.setEncoding('utf8');
|
|
162
|
+
let buffer = '';
|
|
163
|
+
let exitInfo = null;
|
|
164
|
+
const exitPromise = new Promise((resolve) => {
|
|
165
|
+
proc.on('close', (code, signal) => {
|
|
166
|
+
exitInfo = { code, signal };
|
|
167
|
+
resolve();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
for await (const chunk of proc.stdout) {
|
|
173
|
+
if (opts.signal?.aborted) throw new AbortError('aborted mid-stream');
|
|
174
|
+
buffer += chunk;
|
|
175
|
+
let nl;
|
|
176
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
177
|
+
const line = buffer.slice(0, nl).trim();
|
|
178
|
+
buffer = buffer.slice(nl + 1);
|
|
179
|
+
if (!line) continue;
|
|
180
|
+
let obj;
|
|
181
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
182
|
+
const text = extractTextDelta(obj);
|
|
183
|
+
if (text) yield text;
|
|
184
|
+
if (obj?.type === 'result') {
|
|
185
|
+
// Last event of a successful run carries usage + cost.
|
|
186
|
+
if (typeof opts.onUsage === 'function') {
|
|
187
|
+
try {
|
|
188
|
+
opts.onUsage({
|
|
189
|
+
inputTokens: obj.usage?.input_tokens || 0,
|
|
190
|
+
outputTokens: obj.usage?.output_tokens || 0,
|
|
191
|
+
totalCostUsd: obj.total_cost_usd || 0,
|
|
192
|
+
});
|
|
193
|
+
} catch (_) { /* never fail the stream on usage callback */ }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Drain trailing buffered line.
|
|
199
|
+
if (buffer.trim()) {
|
|
200
|
+
try {
|
|
201
|
+
const obj = JSON.parse(buffer.trim());
|
|
202
|
+
const text = extractTextDelta(obj);
|
|
203
|
+
if (text) yield text;
|
|
204
|
+
} catch (_) { /* incomplete tail — drop */ }
|
|
205
|
+
}
|
|
206
|
+
await exitPromise;
|
|
207
|
+
if (exitInfo && exitInfo.code !== 0 && !opts.signal?.aborted) {
|
|
208
|
+
throw new CliExitError(exitInfo.code, exitInfo.signal, stderr);
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
if (opts.signal) opts.signal.removeEventListener('abort', onAbort);
|
|
212
|
+
// Make sure we don't leave a runaway subprocess if the consumer
|
|
213
|
+
// bailed mid-iteration without explicit abort.
|
|
214
|
+
if (!proc.killed && exitInfo === null) {
|
|
215
|
+
try { proc.kill('SIGTERM'); } catch (_) { /* ignore */ }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export { CliMissingError, CliExitError, AbortError, resolveModelAlias, buildPrompt };
|
package/providers/registry.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { anthropicProvider } from './anthropic.mjs';
|
|
|
11
11
|
import { openaiProvider } from './openai.mjs';
|
|
12
12
|
import { ollamaProvider } from './ollama.mjs';
|
|
13
13
|
import { geminiProvider } from './gemini.mjs';
|
|
14
|
+
import { claudeCliProvider } from './claude_cli.mjs';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* @typedef {{ role: 'user'|'assistant'|'system', content: string }} ChatMessage
|
|
@@ -47,14 +48,17 @@ export const mockProvider = {
|
|
|
47
48
|
},
|
|
48
49
|
};
|
|
49
50
|
|
|
50
|
-
export { anthropicProvider, openaiProvider, ollamaProvider, geminiProvider };
|
|
51
|
+
export { anthropicProvider, openaiProvider, ollamaProvider, geminiProvider, claudeCliProvider };
|
|
51
52
|
|
|
52
53
|
export const PROVIDERS = {
|
|
53
54
|
mock: mockProvider,
|
|
55
|
+
// claude-cli (subscription-backed, no API key) listed before
|
|
56
|
+
// anthropic so first-time onboarding surfaces it as the default.
|
|
57
|
+
'claude-cli': claudeCliProvider,
|
|
54
58
|
anthropic: anthropicProvider,
|
|
55
59
|
openai: openaiProvider,
|
|
56
|
-
ollama: ollamaProvider,
|
|
57
60
|
gemini: geminiProvider,
|
|
61
|
+
ollama: ollamaProvider,
|
|
58
62
|
};
|
|
59
63
|
|
|
60
64
|
// Static metadata for `lazyclaw providers list/info`. Kept next to PROVIDERS
|
|
@@ -67,14 +71,37 @@ export const PROVIDER_INFO = {
|
|
|
67
71
|
defaultModel: null,
|
|
68
72
|
suggestedModels: [],
|
|
69
73
|
},
|
|
74
|
+
'claude-cli': {
|
|
75
|
+
name: 'claude-cli',
|
|
76
|
+
requiresApiKey: false,
|
|
77
|
+
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.',
|
|
78
|
+
endpoint: 'subprocess: claude -p',
|
|
79
|
+
defaultModel: 'claude-opus-4-7',
|
|
80
|
+
suggestedModels: [
|
|
81
|
+
'claude-opus-4-7',
|
|
82
|
+
'claude-sonnet-4-6',
|
|
83
|
+
'claude-haiku-4-5',
|
|
84
|
+
'opus',
|
|
85
|
+
'sonnet',
|
|
86
|
+
'haiku',
|
|
87
|
+
],
|
|
88
|
+
},
|
|
70
89
|
anthropic: {
|
|
71
90
|
name: 'anthropic',
|
|
72
91
|
requiresApiKey: true,
|
|
73
92
|
keyPrefix: 'sk-ant-',
|
|
74
|
-
docs: 'Anthropic Messages API. Supports streaming + extended thinking.',
|
|
93
|
+
docs: 'Anthropic Messages API (pay-per-token, requires sk-ant- key). Supports streaming + extended thinking. For subscription billing, use the `claude-cli` provider instead.',
|
|
75
94
|
endpoint: 'https://api.anthropic.com/v1/messages',
|
|
76
95
|
defaultModel: 'claude-opus-4-7',
|
|
77
|
-
suggestedModels: [
|
|
96
|
+
suggestedModels: [
|
|
97
|
+
'claude-opus-4-7',
|
|
98
|
+
'claude-opus-4-6',
|
|
99
|
+
'claude-sonnet-4-6',
|
|
100
|
+
'claude-sonnet-4-5',
|
|
101
|
+
'claude-haiku-4-5',
|
|
102
|
+
'claude-3-5-sonnet-20241022',
|
|
103
|
+
'claude-3-5-haiku-20241022',
|
|
104
|
+
],
|
|
78
105
|
},
|
|
79
106
|
openai: {
|
|
80
107
|
name: 'openai',
|
|
@@ -83,23 +110,53 @@ export const PROVIDER_INFO = {
|
|
|
83
110
|
docs: 'OpenAI Chat Completions API. Streaming via SSE with [DONE] terminator.',
|
|
84
111
|
endpoint: 'https://api.openai.com/v1/chat/completions',
|
|
85
112
|
defaultModel: 'gpt-4.1',
|
|
86
|
-
suggestedModels: [
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
113
|
+
suggestedModels: [
|
|
114
|
+
'gpt-5',
|
|
115
|
+
'gpt-5-codex',
|
|
116
|
+
'gpt-4.1',
|
|
117
|
+
'gpt-4.1-mini',
|
|
118
|
+
'gpt-4o',
|
|
119
|
+
'gpt-4o-mini',
|
|
120
|
+
'o3-pro',
|
|
121
|
+
'o4-mini',
|
|
122
|
+
'o1',
|
|
123
|
+
'o1-mini',
|
|
124
|
+
],
|
|
95
125
|
},
|
|
96
126
|
gemini: {
|
|
97
127
|
name: 'gemini',
|
|
98
128
|
requiresApiKey: true,
|
|
99
129
|
docs: 'Google Generative Language API (Gemini). SSE streaming via :streamGenerateContent?alt=sse. Auth via ?key= query param.',
|
|
100
130
|
endpoint: 'https://generativelanguage.googleapis.com/v1/models/{model}:streamGenerateContent',
|
|
101
|
-
defaultModel: 'gemini-
|
|
102
|
-
suggestedModels: [
|
|
131
|
+
defaultModel: 'gemini-2.5-pro',
|
|
132
|
+
suggestedModels: [
|
|
133
|
+
'gemini-2.5-pro',
|
|
134
|
+
'gemini-2.5-flash',
|
|
135
|
+
'gemini-2.0-flash',
|
|
136
|
+
'gemini-2.0-flash-thinking-exp',
|
|
137
|
+
'gemini-1.5-pro',
|
|
138
|
+
'gemini-1.5-flash',
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
ollama: {
|
|
142
|
+
name: 'ollama',
|
|
143
|
+
requiresApiKey: false,
|
|
144
|
+
docs: 'Local Ollama daemon. Streams newline-delimited JSON from /api/chat. No auth — defaults to 127.0.0.1:11434, override via OLLAMA_HOST or opts.baseUrl. Available models depend on what you have pulled locally (`ollama list`).',
|
|
145
|
+
endpoint: 'http://127.0.0.1:11434/api/chat',
|
|
146
|
+
defaultModel: 'llama3.1',
|
|
147
|
+
suggestedModels: [
|
|
148
|
+
'llama3.1',
|
|
149
|
+
'llama3.2',
|
|
150
|
+
'llama3.3',
|
|
151
|
+
'qwen2.5-coder',
|
|
152
|
+
'qwen3.5',
|
|
153
|
+
'mistral',
|
|
154
|
+
'mistral-nemo',
|
|
155
|
+
'codellama',
|
|
156
|
+
'deepseek-coder-v2',
|
|
157
|
+
'phi3',
|
|
158
|
+
'gemma2',
|
|
159
|
+
],
|
|
103
160
|
},
|
|
104
161
|
};
|
|
105
162
|
|
package/sandbox.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Sandbox — wrap a child process in a Docker container.
|
|
2
|
+
//
|
|
3
|
+
// `lazyclaw chat --sandbox docker:<image>` (or the equivalent on
|
|
4
|
+
// `agent`) routes the underlying `claude` CLI invocation through
|
|
5
|
+
//
|
|
6
|
+
// docker run --rm -i --network=<net> \
|
|
7
|
+
// -v <cwd>:<cwd> -w <cwd> \
|
|
8
|
+
// -e <pass-through env vars> \
|
|
9
|
+
// <image> claude -p ...
|
|
10
|
+
//
|
|
11
|
+
// instead of running `claude` directly on the host. Two reasons:
|
|
12
|
+
//
|
|
13
|
+
// 1. Filesystem confinement. The default --workdir mount only
|
|
14
|
+
// exposes the current working directory; tools that try to
|
|
15
|
+
// chdir into $HOME or read /etc see an empty container fs.
|
|
16
|
+
// 2. Network policy. By default we set --network=none so the
|
|
17
|
+
// sandboxed agent cannot reach the public internet — useful
|
|
18
|
+
// when handing it untrusted prompts. Pass `--network host` /
|
|
19
|
+
// `bridge` via flags when the workflow needs outbound access
|
|
20
|
+
// (e.g. it has to call an API).
|
|
21
|
+
//
|
|
22
|
+
// Caveats — call out so users aren't surprised:
|
|
23
|
+
//
|
|
24
|
+
// - The user's `claude` login lives in $HOME/.claude/. The sandbox
|
|
25
|
+
// doesn't expose $HOME by default, so the wrapped CLI can't see
|
|
26
|
+
// that auth and will prompt for login. To run sandboxed under
|
|
27
|
+
// the user's existing subscription, mount $HOME/.claude:
|
|
28
|
+
//
|
|
29
|
+
// lazyclaw chat --sandbox docker:node:20 \
|
|
30
|
+
// --sandbox-mount "$HOME/.claude:/root/.claude:ro"
|
|
31
|
+
//
|
|
32
|
+
// - Sandboxing only applies when the picked provider goes through
|
|
33
|
+
// a subprocess (currently `claude-cli`). API providers
|
|
34
|
+
// (anthropic / openai / gemini) hit the network from
|
|
35
|
+
// *lazyclaw's* process, not a child — sandboxing them is a
|
|
36
|
+
// no-op and we surface a warning.
|
|
37
|
+
|
|
38
|
+
import { spawn } from 'node:child_process';
|
|
39
|
+
|
|
40
|
+
class SandboxError extends Error {
|
|
41
|
+
constructor(message, code) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = 'SandboxError';
|
|
44
|
+
this.code = code || 'SANDBOX_ERR';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a `--sandbox` flag. Accepts:
|
|
50
|
+
* docker:<image> — Docker with default policy
|
|
51
|
+
* docker:<image>?<args> — query-string-style overrides
|
|
52
|
+
* off | none | - — explicit "no sandbox"
|
|
53
|
+
*
|
|
54
|
+
* Returns null when sandboxing is off, or
|
|
55
|
+
* { kind: 'docker', image, network, mounts: string[], envPassthrough: string[] }.
|
|
56
|
+
*/
|
|
57
|
+
export function parseSandboxSpec(spec, flags = {}) {
|
|
58
|
+
if (!spec || /^(off|none|-)$/i.test(String(spec))) return null;
|
|
59
|
+
const m = String(spec).match(/^([a-z]+):(.+)$/i);
|
|
60
|
+
if (!m) throw new SandboxError(`bad sandbox spec "${spec}" — expected "docker:<image>"`, 'SANDBOX_BAD_SPEC');
|
|
61
|
+
const [, kind, rest] = m;
|
|
62
|
+
if (kind.toLowerCase() !== 'docker') {
|
|
63
|
+
throw new SandboxError(`unsupported sandbox kind "${kind}" — only "docker" is implemented`, 'SANDBOX_UNSUPPORTED');
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
kind: 'docker',
|
|
67
|
+
image: rest.trim(),
|
|
68
|
+
// Default to --network=none for safety. Override via:
|
|
69
|
+
// --sandbox-network host (or bridge / a named network)
|
|
70
|
+
network: flags['sandbox-network'] || 'none',
|
|
71
|
+
// --sandbox-mount can repeat; cli.parseArgs collects repeats
|
|
72
|
+
// into an array.
|
|
73
|
+
mounts: arrayify(flags['sandbox-mount']),
|
|
74
|
+
envPassthrough: arrayify(flags['sandbox-env']),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function arrayify(v) {
|
|
79
|
+
if (v === undefined || v === null) return [];
|
|
80
|
+
return Array.isArray(v) ? v : [String(v)];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the docker run argv that wraps a child invocation. The
|
|
85
|
+
* caller hands us the original [bin, ...args] they were going to
|
|
86
|
+
* spawn; we return [docker, ...dockerArgs] that puts the same
|
|
87
|
+
* thing inside the container.
|
|
88
|
+
*/
|
|
89
|
+
export function buildDockerArgs(spec, [bin, ...binArgs], opts = {}) {
|
|
90
|
+
if (!spec || spec.kind !== 'docker') {
|
|
91
|
+
throw new SandboxError('buildDockerArgs requires a docker spec', 'SANDBOX_BAD_SPEC');
|
|
92
|
+
}
|
|
93
|
+
const cwd = opts.cwd || process.cwd();
|
|
94
|
+
const args = [
|
|
95
|
+
'run', '--rm', '-i',
|
|
96
|
+
'--network', spec.network || 'none',
|
|
97
|
+
'-v', `${cwd}:${cwd}`,
|
|
98
|
+
'-w', cwd,
|
|
99
|
+
];
|
|
100
|
+
for (const mount of spec.mounts) {
|
|
101
|
+
if (!mount.includes(':')) {
|
|
102
|
+
throw new SandboxError(`bad mount "${mount}" — expected host:container[:mode]`, 'SANDBOX_BAD_MOUNT');
|
|
103
|
+
}
|
|
104
|
+
args.push('-v', mount);
|
|
105
|
+
}
|
|
106
|
+
for (const envName of spec.envPassthrough) {
|
|
107
|
+
args.push('-e', envName);
|
|
108
|
+
}
|
|
109
|
+
args.push(spec.image, bin, ...binArgs);
|
|
110
|
+
return args;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Spawn `bin` with `args` either bare (no sandbox) or under the
|
|
115
|
+
* docker wrapper. Returns the child process; the caller drives
|
|
116
|
+
* stdio and handles exit. Mirrors `child_process.spawn`'s shape.
|
|
117
|
+
*/
|
|
118
|
+
export function spawnSandboxed(spec, bin, args, spawnOpts = {}) {
|
|
119
|
+
if (!spec) return spawn(bin, args, spawnOpts);
|
|
120
|
+
if (spec.kind !== 'docker') {
|
|
121
|
+
throw new SandboxError(`unsupported kind "${spec.kind}"`, 'SANDBOX_UNSUPPORTED');
|
|
122
|
+
}
|
|
123
|
+
const dockerArgs = buildDockerArgs(spec, [bin, ...args], { cwd: spawnOpts.cwd });
|
|
124
|
+
return spawn('docker', dockerArgs, spawnOpts);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { SandboxError };
|