lazyclaw 4.3.0 → 5.0.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 +44 -0
- package/README.md +172 -508
- package/channels/handoff.mjs +41 -0
- package/channels/loader.mjs +124 -0
- package/channels/threads.mjs +116 -0
- package/cli.mjs +398 -6
- package/daemon.mjs +13 -0
- package/mas/agent_turn.mjs +28 -0
- package/mas/confidence.mjs +108 -0
- package/mas/index_db.mjs +242 -0
- package/mas/nudge.mjs +97 -0
- package/mas/prompt_stack.mjs +80 -0
- package/mas/skill_synth.mjs +124 -25
- package/mas/tool_runner.mjs +10 -61
- package/mas/tools/browser.mjs +77 -0
- package/mas/tools/clarify.mjs +36 -0
- package/mas/tools/coding.mjs +109 -0
- package/mas/tools/delegation.mjs +53 -0
- package/mas/tools/edit.mjs +36 -0
- package/mas/tools/git.mjs +110 -0
- package/mas/tools/ha.mjs +34 -0
- package/mas/tools/learning.mjs +168 -0
- package/mas/tools/media.mjs +105 -0
- package/mas/tools/os.mjs +152 -0
- package/mas/tools/patch.mjs +91 -0
- package/mas/tools/recall.mjs +103 -0
- package/mas/tools/registry.mjs +93 -0
- package/mas/tools/scheduling.mjs +62 -0
- package/mas/tools/web.mjs +137 -0
- package/mas/toolsets.mjs +64 -0
- package/mas/trajectory_export.mjs +169 -0
- package/mas/trajectory_store.mjs +179 -0
- package/mas/user_modeler.mjs +108 -0
- package/package.json +20 -3
- package/providers/codex_cli.mjs +200 -0
- package/providers/gemini_cli.mjs +179 -0
- package/providers/registry.mjs +61 -1
- package/sandbox/base.mjs +82 -0
- package/sandbox/confiners/bubblewrap.mjs +21 -0
- package/sandbox/confiners/firejail.mjs +16 -0
- package/sandbox/confiners/landlock.mjs +14 -0
- package/sandbox/confiners/seatbelt.mjs +28 -0
- package/sandbox/daytona.mjs +37 -0
- package/sandbox/docker.mjs +91 -0
- package/sandbox/index.mjs +67 -0
- package/sandbox/local.mjs +59 -0
- package/sandbox/modal.mjs +53 -0
- package/sandbox/singularity.mjs +39 -0
- package/sandbox/ssh.mjs +56 -0
- package/sandbox.mjs +11 -127
- package/scripts/hermes-import.mjs +111 -0
- package/scripts/migrate-v5.mjs +342 -0
- package/scripts/openclaw-import.mjs +71 -0
- package/sessions.mjs +20 -1
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Gemini subscription/CLI provider (no API key).
|
|
2
|
+
//
|
|
3
|
+
// Spawns the local `gemini` CLI binary (the Google Gemini CLI shipped
|
|
4
|
+
// from npm: @google/gemini-cli) in non-interactive mode:
|
|
5
|
+
//
|
|
6
|
+
// gemini --skip-trust -p "<prompt>" -o json [-m model]
|
|
7
|
+
//
|
|
8
|
+
// Auth is whatever `gemini` is already signed into on the host —
|
|
9
|
+
// Google account / Vertex / AI Studio session — so no GEMINI_API_KEY
|
|
10
|
+
// is required and no key lands in the lazyclaw config. This is the
|
|
11
|
+
// CLI counterpart to providers/gemini.mjs (which talks to the
|
|
12
|
+
// Generative Language API and needs an API key).
|
|
13
|
+
//
|
|
14
|
+
// Output shape (one JSON object on stdout):
|
|
15
|
+
// { session_id, response: "<text>", stats: { models: {...} } }
|
|
16
|
+
//
|
|
17
|
+
// We capture stdout in full, parse the JSON once, and yield the
|
|
18
|
+
// `response` string in a single chunk. The orchestrator and chat
|
|
19
|
+
// REPL both treat AsyncIterable<string> as their only contract, so
|
|
20
|
+
// non-streaming is transparent to callers.
|
|
21
|
+
//
|
|
22
|
+
// Why `--skip-trust` is hard-coded: lazyclaw orchestrator / workflow
|
|
23
|
+
// subprocesses commonly run from /tmp or scratch dirs which gemini's
|
|
24
|
+
// trusted-folder policy rejects. We are spawning gemini ourselves with
|
|
25
|
+
// our own prompt, so the policy adds friction without security value.
|
|
26
|
+
|
|
27
|
+
import { spawnSandboxed } from '../sandbox.mjs';
|
|
28
|
+
|
|
29
|
+
class AbortError extends Error {
|
|
30
|
+
constructor(message = 'aborted') {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'AbortError';
|
|
33
|
+
this.code = 'ABORT';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class CliMissingError extends Error {
|
|
38
|
+
constructor() {
|
|
39
|
+
super('gemini CLI not found in PATH — install @google/gemini-cli or use the gemini API provider');
|
|
40
|
+
this.name = 'GeminiCliMissingError';
|
|
41
|
+
this.code = 'CLI_MISSING';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class CliExitError extends Error {
|
|
46
|
+
constructor(code, signal, stderr) {
|
|
47
|
+
super(`gemini CLI exited ${code ?? signal}: ${String(stderr).slice(0, 400)}`);
|
|
48
|
+
this.name = 'GeminiCliExitError';
|
|
49
|
+
this.code = 'CLI_EXIT';
|
|
50
|
+
this.exitCode = code;
|
|
51
|
+
this.signal = signal;
|
|
52
|
+
this.stderr = stderr;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// `gemini -m` accepts the marketing model id directly (e.g. gemini-2.5-pro),
|
|
57
|
+
// so unlike claude_cli no alias map is needed. We still normalize a few
|
|
58
|
+
// shorthand inputs that users have typed in chat config.
|
|
59
|
+
const _ALIASES = {
|
|
60
|
+
pro: 'gemini-2.5-pro',
|
|
61
|
+
flash: 'gemini-2.5-flash',
|
|
62
|
+
'gemini-pro': 'gemini-2.5-pro',
|
|
63
|
+
'gemini-flash': 'gemini-2.5-flash',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Drop cross-vendor model ids (claude-*, gpt-*, o1/o3/o4-*) silently so the
|
|
67
|
+
// CLI falls back to its own default. The orchestrator workflow happily
|
|
68
|
+
// dispatches the same cfg.model to every worker, and `providers test`
|
|
69
|
+
// forwards cfg.model verbatim — both would otherwise crash here with
|
|
70
|
+
// "ModelNotFoundError" the moment cfg.model is anything Anthropic/OpenAI.
|
|
71
|
+
function resolveModel(model) {
|
|
72
|
+
if (!model) return '';
|
|
73
|
+
const lower = String(model).toLowerCase();
|
|
74
|
+
if (_ALIASES[lower]) return _ALIASES[lower];
|
|
75
|
+
if (lower.startsWith('gemini') || lower.startsWith('gemma')) return String(model);
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildPrompt(messages, system) {
|
|
80
|
+
const parts = [];
|
|
81
|
+
if (system) parts.push(`[System instructions: ${system}]`);
|
|
82
|
+
for (const m of messages) {
|
|
83
|
+
if (!m || !m.content) continue;
|
|
84
|
+
if (m.role === 'system' && !system) parts.push(`[System instructions: ${m.content}]`);
|
|
85
|
+
else if (m.role === 'user') parts.push(`User: ${m.content}`);
|
|
86
|
+
else if (m.role === 'assistant') parts.push(`Assistant: ${m.content}`);
|
|
87
|
+
}
|
|
88
|
+
return parts.length ? parts.join('\n') + '\n\nAssistant:' : '';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Pull usage totals out of the gemini stats block. The shape is
|
|
92
|
+
// `stats.models["<model-id>"].api` + `.tokens`. We sum across all
|
|
93
|
+
// model entries since a single response can route through multiple
|
|
94
|
+
// (e.g. flash for reasoning + pro for the final answer).
|
|
95
|
+
function extractUsage(stats) {
|
|
96
|
+
if (!stats || typeof stats !== 'object') return null;
|
|
97
|
+
const models = stats.models || {};
|
|
98
|
+
let input = 0, output = 0;
|
|
99
|
+
for (const m of Object.values(models)) {
|
|
100
|
+
const t = m?.tokens || {};
|
|
101
|
+
input += (t.prompt ?? t.input ?? 0);
|
|
102
|
+
output += (t.candidates ?? t.output ?? 0);
|
|
103
|
+
}
|
|
104
|
+
if (!input && !output) return null;
|
|
105
|
+
return { inputTokens: input, outputTokens: output, totalCostUsd: 0 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const geminiCliProvider = {
|
|
109
|
+
name: 'gemini-cli',
|
|
110
|
+
async *sendMessage(messages, opts = {}) {
|
|
111
|
+
const bin = opts.bin || 'gemini';
|
|
112
|
+
const prompt = buildPrompt(messages, opts.system || messages.find(m => m.role === 'system')?.content);
|
|
113
|
+
if (!prompt) return;
|
|
114
|
+
|
|
115
|
+
const args = ['--skip-trust', '-p', prompt, '-o', 'json'];
|
|
116
|
+
const model = resolveModel(opts.model);
|
|
117
|
+
if (model) args.push('-m', model);
|
|
118
|
+
|
|
119
|
+
if (opts.signal?.aborted) throw new AbortError('aborted before spawn');
|
|
120
|
+
|
|
121
|
+
let proc;
|
|
122
|
+
try {
|
|
123
|
+
proc = spawnSandboxed(opts.sandbox || null, bin, args, {
|
|
124
|
+
cwd: opts.cwd || process.cwd(),
|
|
125
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
126
|
+
env: { ...process.env, GEMINI_CLI_TRUST_WORKSPACE: 'true' },
|
|
127
|
+
});
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err && err.code === 'ENOENT') throw new CliMissingError();
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const onAbort = () => { try { proc.kill('SIGTERM'); } catch (_) { /* ignore */ } };
|
|
134
|
+
if (opts.signal) opts.signal.addEventListener('abort', onAbort);
|
|
135
|
+
|
|
136
|
+
let stdout = '';
|
|
137
|
+
let stderr = '';
|
|
138
|
+
proc.stdout.setEncoding('utf8');
|
|
139
|
+
proc.stderr.setEncoding('utf8');
|
|
140
|
+
proc.stdout.on('data', (c) => { stdout += c; });
|
|
141
|
+
proc.stderr.on('data', (c) => { stderr += c; });
|
|
142
|
+
|
|
143
|
+
const exitInfo = await new Promise((resolve) => {
|
|
144
|
+
proc.on('close', (code, signal) => resolve({ code, signal }));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
if (opts.signal?.aborted) throw new AbortError('aborted mid-run');
|
|
149
|
+
if (exitInfo.code !== 0) {
|
|
150
|
+
throw new CliExitError(exitInfo.code, exitInfo.signal, stderr || stdout);
|
|
151
|
+
}
|
|
152
|
+
// Gemini emits one JSON document; some installations prefix it
|
|
153
|
+
// with a "Ripgrep is not available. Falling back to GrepTool."
|
|
154
|
+
// line on stderr (harmless) and never touch stdout. Defensive
|
|
155
|
+
// parse: find the first `{` to skip any pre-banner noise.
|
|
156
|
+
const first = stdout.indexOf('{');
|
|
157
|
+
const payload = first >= 0 ? stdout.slice(first) : stdout;
|
|
158
|
+
let parsed;
|
|
159
|
+
try {
|
|
160
|
+
parsed = JSON.parse(payload);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
throw new Error(`gemini CLI returned non-JSON stdout: ${err.message} :: ${payload.slice(0, 200)}`);
|
|
163
|
+
}
|
|
164
|
+
const text = typeof parsed.response === 'string' ? parsed.response : '';
|
|
165
|
+
if (text) yield text;
|
|
166
|
+
const usage = extractUsage(parsed.stats);
|
|
167
|
+
if (usage && typeof opts.onUsage === 'function') {
|
|
168
|
+
try { opts.onUsage(usage); } catch (_) { /* never break stream on usage callback */ }
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
if (opts.signal) opts.signal.removeEventListener('abort', onAbort);
|
|
172
|
+
if (!proc.killed && exitInfo === null) {
|
|
173
|
+
try { proc.kill('SIGTERM'); } catch (_) { /* ignore */ }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export { CliMissingError, CliExitError, AbortError, resolveModel, buildPrompt, extractUsage };
|
package/providers/registry.mjs
CHANGED
|
@@ -61,6 +61,61 @@ export { anthropicProvider, openaiProvider, ollamaProvider, geminiProvider, clau
|
|
|
61
61
|
export { makeOpenAICompatProvider, fetchOpenAICompatModels };
|
|
62
62
|
export { makeOrchestratorProvider };
|
|
63
63
|
|
|
64
|
+
// ─── Phase A: trainer resolver (spec §2.3, §2.4, canonical C3/C9) ───
|
|
65
|
+
//
|
|
66
|
+
// resolveTrainer(cfg, opts) returns { provider, model } for synthesis /
|
|
67
|
+
// reflection calls — split from the chat provider so users can route
|
|
68
|
+
// learning to a cheap model or to a CLI-subscription worker.
|
|
69
|
+
//
|
|
70
|
+
// Rules:
|
|
71
|
+
// - cfg.trainer omitted → mirror chat (v4 compat).
|
|
72
|
+
// - cfg.trainer.provider === 'auto' → claude-cli when Pro/Max
|
|
73
|
+
// session detected, else mirror chat (canonical decision C9).
|
|
74
|
+
// - opts.useFallback → parse cfg.trainer.fallback ('provider:model')
|
|
75
|
+
// and use it; missing pieces inherit from chat.
|
|
76
|
+
// - All identifiers MUST be kebab-case in user-facing config (C3).
|
|
77
|
+
|
|
78
|
+
export function parseProviderModel(spec) {
|
|
79
|
+
const s = String(spec || '');
|
|
80
|
+
const i = s.indexOf(':');
|
|
81
|
+
if (i < 0) return { provider: s || null, model: null };
|
|
82
|
+
return { provider: s.slice(0, i) || null, model: s.slice(i + 1) || null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function _defaultDetectClaudeCli() {
|
|
86
|
+
// Phase A stub: real Pro/Max session detection arrives in Phase B
|
|
87
|
+
// (it requires reading the claude-cli OAuth token cache). Until
|
|
88
|
+
// then, treat presence of CLAUDE_CODE_OAUTH_TOKEN as a positive
|
|
89
|
+
// signal so users can opt-in explicitly.
|
|
90
|
+
return Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function resolveTrainer(cfg, opts = {}) {
|
|
94
|
+
const chatProvider = cfg && cfg.provider;
|
|
95
|
+
const chatModel = cfg && cfg.model;
|
|
96
|
+
const t = cfg && cfg.trainer;
|
|
97
|
+
|
|
98
|
+
if (!t || !t.provider) {
|
|
99
|
+
return { provider: chatProvider, model: chatModel };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (opts.useFallback && t.fallback) {
|
|
103
|
+
const { provider, model } = parseProviderModel(t.fallback);
|
|
104
|
+
return {
|
|
105
|
+
provider: provider || chatProvider,
|
|
106
|
+
model: model || chatModel,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (t.provider === 'auto') {
|
|
111
|
+
const detect = opts.detectClaudeCli || _defaultDetectClaudeCli;
|
|
112
|
+
if (detect()) return { provider: 'claude-cli', model: t.model || chatModel };
|
|
113
|
+
return { provider: chatProvider, model: t.model || chatModel };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { provider: t.provider, model: t.model || chatModel };
|
|
117
|
+
}
|
|
118
|
+
|
|
64
119
|
// Built-in OpenAI-compatible vendors. Same wire format → one factory call
|
|
65
120
|
// each. The picker treats these like first-class providers so users don't
|
|
66
121
|
// have to walk through "+ Add a custom endpoint" for the popular ones.
|
|
@@ -431,10 +486,15 @@ export function resolveBuiltinEnvKey(provider) {
|
|
|
431
486
|
* Split a unified "provider/model" string (OpenClaw style:
|
|
432
487
|
* "anthropic/claude-opus-4-7"). Also accepts a bare model id and returns
|
|
433
488
|
* provider=null so callers can fall back to a separately-stored provider.
|
|
489
|
+
*
|
|
490
|
+
* Renamed from `parseProviderModel` in v5.0 to free that name for the
|
|
491
|
+
* trainer fallback parser (which uses ':' as the separator — see spec
|
|
492
|
+
* §2.4). Callers that still need the slash form should import this.
|
|
493
|
+
*
|
|
434
494
|
* @param {string} s
|
|
435
495
|
* @returns {{ provider: string|null, model: string }}
|
|
436
496
|
*/
|
|
437
|
-
export function
|
|
497
|
+
export function parseSlashProviderModel(s) {
|
|
438
498
|
if (!s || typeof s !== 'string') return { provider: null, model: '' };
|
|
439
499
|
const slash = s.indexOf('/');
|
|
440
500
|
if (slash > 0) {
|
package/sandbox/base.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// sandbox/base.mjs — Sandbox + SandboxSession contracts.
|
|
2
|
+
//
|
|
3
|
+
// Spec ref: §0.1 C8 (6-enum), §6 (backend contract).
|
|
4
|
+
// Every backend module (docker/local/ssh/singularity/modal/daytona)
|
|
5
|
+
// exports a class extending Sandbox and returns SandboxSession from
|
|
6
|
+
// open(). The session is the only object that holds resources
|
|
7
|
+
// (sockets, child PIDs, remote workspace ids) — caller MUST call
|
|
8
|
+
// close() in a finally block.
|
|
9
|
+
|
|
10
|
+
export const SANDBOX_KINDS = Object.freeze([
|
|
11
|
+
'local', 'docker', 'ssh', 'singularity', 'modal', 'daytona',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export class SandboxError extends Error {
|
|
15
|
+
constructor(message, code) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'SandboxError';
|
|
18
|
+
this.code = code || 'SANDBOX_ERR';
|
|
19
|
+
}
|
|
20
|
+
toString() {
|
|
21
|
+
return `${this.name}[${this.code}]: ${this.message}`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class Sandbox {
|
|
26
|
+
/**
|
|
27
|
+
* @param {{kind: string} & Record<string, unknown>} spec
|
|
28
|
+
* @param {{_skipAbstract?: boolean}} [opts]
|
|
29
|
+
*/
|
|
30
|
+
constructor(spec, opts = {}) {
|
|
31
|
+
if (new.target === Sandbox && !opts._skipAbstract) {
|
|
32
|
+
throw new SandboxError(
|
|
33
|
+
'Sandbox is abstract — instantiate a backend subclass',
|
|
34
|
+
'SANDBOX_ABSTRACT',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (!spec || !SANDBOX_KINDS.includes(spec.kind)) {
|
|
38
|
+
throw new SandboxError(
|
|
39
|
+
`unknown sandbox kind "${spec && spec.kind}" — expected one of ${SANDBOX_KINDS.join(', ')}`,
|
|
40
|
+
'SANDBOX_BAD_KIND',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
this.spec = spec;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Open a session. Subclasses MUST override.
|
|
48
|
+
* @returns {Promise<SandboxSession>}
|
|
49
|
+
*/
|
|
50
|
+
async open() {
|
|
51
|
+
throw new SandboxError(`${this.constructor.name}.open() not implemented`, 'SANDBOX_NOT_IMPL');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Short human label for `lazyclaw sandbox list`. */
|
|
55
|
+
describe() { return `${this.spec.kind}`; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class SandboxSession {
|
|
59
|
+
/**
|
|
60
|
+
* Run an argv inside the sandbox.
|
|
61
|
+
* @param {string[]} argv
|
|
62
|
+
* @param {{cwd?: string, env?: Record<string,string>, stdio?: 'pipe'|'inherit', input?: string}} [opts]
|
|
63
|
+
* @returns {Promise<{code: number, stdout: string, stderr: string}>}
|
|
64
|
+
*/
|
|
65
|
+
async exec(_argv, _opts) {
|
|
66
|
+
throw new SandboxError(`${this.constructor.name}.exec() not implemented`, 'SANDBOX_NOT_IMPL');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Spawn a long-running child within the sandbox. Returns a
|
|
71
|
+
* node:child_process-shaped object with stdin/stdout/stderr.
|
|
72
|
+
* Default: synthesise from exec() via streaming if backend allows.
|
|
73
|
+
*/
|
|
74
|
+
async spawn(_argv, _opts) {
|
|
75
|
+
throw new SandboxError(`${this.constructor.name}.spawn() not implemented`, 'SANDBOX_NOT_IMPL');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Release resources. Idempotent. */
|
|
79
|
+
async close() {
|
|
80
|
+
throw new SandboxError(`${this.constructor.name}.close() not implemented`, 'SANDBOX_NOT_IMPL');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// sandbox/confiners/bubblewrap.mjs — Linux bwrap wrapper.
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
export function available() {
|
|
6
|
+
if (process.platform !== 'linux') return false;
|
|
7
|
+
try { execFileSync('bwrap', ['--version'], { stdio: 'ignore' }); return true; }
|
|
8
|
+
catch { return false; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildArgv(argv, opts = {}) {
|
|
12
|
+
const readOnly = opts.readOnly || ['/usr', '/lib', '/lib64', '/bin', '/etc'];
|
|
13
|
+
const readWrite = opts.readWrite || [process.cwd()];
|
|
14
|
+
const allowNet = opts.allowNet === true;
|
|
15
|
+
const out = ['bwrap', '--die-with-parent', '--proc', '/proc', '--dev', '/dev'];
|
|
16
|
+
for (const p of readOnly) out.push('--ro-bind', p, p);
|
|
17
|
+
for (const p of readWrite) out.push('--bind', p, p);
|
|
18
|
+
if (!allowNet) out.push('--unshare-net');
|
|
19
|
+
out.push('--unshare-pid', '--unshare-ipc', '--unshare-uts');
|
|
20
|
+
return [...out, ...argv];
|
|
21
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// sandbox/confiners/firejail.mjs — firejail wrapper.
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
export function available() {
|
|
6
|
+
if (process.platform !== 'linux') return false;
|
|
7
|
+
try { execFileSync('firejail', ['--version'], { stdio: 'ignore' }); return true; }
|
|
8
|
+
catch { return false; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildArgv(argv, opts = {}) {
|
|
12
|
+
const allowNet = opts.allowNet === true;
|
|
13
|
+
const out = ['firejail', '--quiet', '--private', '--caps.drop=all'];
|
|
14
|
+
out.push(allowNet ? '--net=eth0' : '--net=none');
|
|
15
|
+
return [...out, ...argv];
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// sandbox/confiners/landlock.mjs — Linux Landlock helper.
|
|
2
|
+
//
|
|
3
|
+
// Landlock is enforced from *inside* the process via the
|
|
4
|
+
// landlock_create_ruleset() syscall. With no native bindings available
|
|
5
|
+
// in plain Node, we currently emit the argv unchanged and let
|
|
6
|
+
// downstream tooling (e.g. a future `lazyclaw-landlock-shim` binary)
|
|
7
|
+
// install the ruleset. Returns argv unchanged on non-Linux.
|
|
8
|
+
|
|
9
|
+
export function available() { return process.platform === 'linux'; }
|
|
10
|
+
|
|
11
|
+
export function buildArgv(argv, _opts = {}) {
|
|
12
|
+
// Pass-through. Spec §0.1 C8 leaves room for a preloader binary.
|
|
13
|
+
return [...argv];
|
|
14
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// sandbox/confiners/seatbelt.mjs — macOS sandbox-exec wrapper.
|
|
2
|
+
// Spec §0.1 C8: local.confiner sub-option.
|
|
3
|
+
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export function available() {
|
|
7
|
+
if (process.platform !== 'darwin') return false;
|
|
8
|
+
try { execFileSync('sandbox-exec', ['-h'], { stdio: 'ignore' }); return true; }
|
|
9
|
+
catch { return false; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function buildArgv(argv, opts = {}) {
|
|
13
|
+
const readOnly = opts.readOnly || [];
|
|
14
|
+
const readWrite = opts.readWrite || [process.cwd()];
|
|
15
|
+
const allowNet = opts.allowNet === true;
|
|
16
|
+
const profile = [
|
|
17
|
+
'(version 1)',
|
|
18
|
+
'(deny default)',
|
|
19
|
+
'(allow process-fork)',
|
|
20
|
+
'(allow process-exec)',
|
|
21
|
+
'(allow signal)',
|
|
22
|
+
'(allow sysctl-read)',
|
|
23
|
+
allowNet ? '(allow network*)' : '(deny network*)',
|
|
24
|
+
...readOnly.map(p => `(allow file-read* (subpath "${p}"))`),
|
|
25
|
+
...readWrite.map(p => `(allow file-read* file-write* (subpath "${p}"))`),
|
|
26
|
+
].join('\n');
|
|
27
|
+
return ['sandbox-exec', '-p', profile, ...argv];
|
|
28
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// sandbox/daytona.mjs — Daytona workspace wrapper.
|
|
2
|
+
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { Sandbox, SandboxSession, SandboxError } from './base.mjs';
|
|
5
|
+
|
|
6
|
+
export function buildDaytonaArgv(spec, argv) {
|
|
7
|
+
if (!spec || !spec.workspace) {
|
|
8
|
+
throw new SandboxError('daytona sandbox requires workspace', 'SANDBOX_BAD_SPEC');
|
|
9
|
+
}
|
|
10
|
+
const out = ['daytona', 'ssh', spec.workspace];
|
|
11
|
+
if (spec.persistent === false) out.push('--auto-stop=true');
|
|
12
|
+
out.push('--', ...argv);
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class DaytonaSession extends SandboxSession {
|
|
17
|
+
constructor(spec) { super(); this.spec = spec; }
|
|
18
|
+
async exec(argv, opts = {}) {
|
|
19
|
+
const a = buildDaytonaArgv(this.spec, argv);
|
|
20
|
+
const r = spawnSync(a[0], a.slice(1), {
|
|
21
|
+
input: opts.input, env: { ...process.env, ...(opts.env || {}) },
|
|
22
|
+
stdio: opts.stdio || 'pipe', encoding: 'utf8',
|
|
23
|
+
});
|
|
24
|
+
return { code: r.status ?? -1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
25
|
+
}
|
|
26
|
+
async spawn(argv, opts = {}) {
|
|
27
|
+
const a = buildDaytonaArgv(this.spec, argv);
|
|
28
|
+
return spawn(a[0], a.slice(1), opts);
|
|
29
|
+
}
|
|
30
|
+
async close() {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class DaytonaSandbox extends Sandbox {
|
|
34
|
+
constructor(spec) { super(spec); }
|
|
35
|
+
async open() { return new DaytonaSession(this.spec); }
|
|
36
|
+
describe() { return `daytona · ${this.spec.workspace}`; }
|
|
37
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// sandbox/docker.mjs — Docker backend.
|
|
2
|
+
//
|
|
3
|
+
// Ported from the original single-file sandbox.mjs (v4.3). Behaviour
|
|
4
|
+
// is byte-identical for parseSandboxSpec / buildDockerArgs /
|
|
5
|
+
// spawnSandboxed; the new piece is the DockerSandbox class that
|
|
6
|
+
// implements the §6 Sandbox interface.
|
|
7
|
+
|
|
8
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
9
|
+
import { Sandbox, SandboxSession, SandboxError } from './base.mjs';
|
|
10
|
+
|
|
11
|
+
function arrayify(v) {
|
|
12
|
+
if (v === undefined || v === null) return [];
|
|
13
|
+
return Array.isArray(v) ? v : [String(v)];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseSandboxSpec(spec, flags = {}) {
|
|
17
|
+
if (!spec || /^(off|none|-)$/i.test(String(spec))) return null;
|
|
18
|
+
const m = String(spec).match(/^([a-z]+):(.+)$/i);
|
|
19
|
+
if (!m) throw new SandboxError(`bad sandbox spec "${spec}" — expected "docker:<image>"`, 'SANDBOX_BAD_SPEC');
|
|
20
|
+
const [, kind, rest] = m;
|
|
21
|
+
if (kind.toLowerCase() !== 'docker') {
|
|
22
|
+
throw new SandboxError(`unsupported sandbox kind "${kind}" — only "docker" parses via this shim`, 'SANDBOX_UNSUPPORTED');
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
kind: 'docker',
|
|
26
|
+
image: rest.trim(),
|
|
27
|
+
network: flags['sandbox-network'] || 'none',
|
|
28
|
+
mounts: arrayify(flags['sandbox-mount']),
|
|
29
|
+
envPassthrough: arrayify(flags['sandbox-env']),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildDockerArgs(spec, [bin, ...binArgs], opts = {}) {
|
|
34
|
+
if (!spec || spec.kind !== 'docker') {
|
|
35
|
+
throw new SandboxError('buildDockerArgs requires a docker spec', 'SANDBOX_BAD_SPEC');
|
|
36
|
+
}
|
|
37
|
+
const cwd = opts.cwd || process.cwd();
|
|
38
|
+
const args = [
|
|
39
|
+
'run', '--rm', '-i',
|
|
40
|
+
'--network', spec.network || 'none',
|
|
41
|
+
'-v', `${cwd}:${cwd}`,
|
|
42
|
+
'-w', cwd,
|
|
43
|
+
];
|
|
44
|
+
for (const mount of spec.mounts || []) {
|
|
45
|
+
if (!mount.includes(':')) {
|
|
46
|
+
throw new SandboxError(`bad mount "${mount}" — expected host:container[:mode]`, 'SANDBOX_BAD_MOUNT');
|
|
47
|
+
}
|
|
48
|
+
args.push('-v', mount);
|
|
49
|
+
}
|
|
50
|
+
for (const envName of spec.envPassthrough || []) {
|
|
51
|
+
args.push('-e', envName);
|
|
52
|
+
}
|
|
53
|
+
args.push(spec.image, bin, ...binArgs);
|
|
54
|
+
return args;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function spawnSandboxed(spec, bin, args, spawnOpts = {}) {
|
|
58
|
+
if (!spec) return spawn(bin, args, spawnOpts);
|
|
59
|
+
if (spec.kind !== 'docker') {
|
|
60
|
+
throw new SandboxError(`spawnSandboxed shim handles docker only; got "${spec.kind}"`, 'SANDBOX_UNSUPPORTED');
|
|
61
|
+
}
|
|
62
|
+
const dockerArgs = buildDockerArgs(spec, [bin, ...args], { cwd: spawnOpts.cwd });
|
|
63
|
+
return spawn('docker', dockerArgs, spawnOpts);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class DockerSession extends SandboxSession {
|
|
67
|
+
constructor(spec) { super(); this.spec = spec; this._closed = false; }
|
|
68
|
+
|
|
69
|
+
async exec(argv, opts = {}) {
|
|
70
|
+
const dockerArgv = buildDockerArgs(this.spec, argv, { cwd: opts.cwd });
|
|
71
|
+
const r = spawnSync('docker', dockerArgv, {
|
|
72
|
+
input: opts.input,
|
|
73
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
74
|
+
stdio: opts.stdio || 'pipe',
|
|
75
|
+
encoding: 'utf8',
|
|
76
|
+
});
|
|
77
|
+
return { code: r.status ?? -1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async spawn(argv, opts = {}) {
|
|
81
|
+
return spawnSandboxed(this.spec, argv[0], argv.slice(1), opts);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async close() { this._closed = true; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class DockerSandbox extends Sandbox {
|
|
88
|
+
constructor(spec) { super(spec); }
|
|
89
|
+
async open() { return new DockerSession(this.spec); }
|
|
90
|
+
describe() { return `docker · ${this.spec.image} · net=${this.spec.network}`; }
|
|
91
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// sandbox/index.mjs — config-driven backend resolver.
|
|
2
|
+
//
|
|
3
|
+
// Spec §0.1 C8 (6-enum), §1.7 (cfg.sandbox additive).
|
|
4
|
+
//
|
|
5
|
+
// Config schema (additive, all optional):
|
|
6
|
+
//
|
|
7
|
+
// sandbox: {
|
|
8
|
+
// default: 'local' | 'docker' | 'ssh' | 'singularity' | 'modal' | 'daytona',
|
|
9
|
+
// local: { confiner: 'none'|'seatbelt'|'bubblewrap'|'firejail'|'landlock',
|
|
10
|
+
// readOnly?: string[], readWrite?: string[], allowNet?: boolean },
|
|
11
|
+
// docker: { image, network?, mounts?: string[], envPassthrough?: string[] },
|
|
12
|
+
// ssh: { host, user?, port?, identityFile? },
|
|
13
|
+
// singularity: { image, bind?: string[], net?: boolean, useApptainer?: boolean },
|
|
14
|
+
// modal: { app, region?, token?, idleWake?: boolean, host? },
|
|
15
|
+
// daytona: { workspace, persistent?: boolean },
|
|
16
|
+
// bindings: { '<workerName>': '<kind>' | { kind, ...overrides } },
|
|
17
|
+
// }
|
|
18
|
+
|
|
19
|
+
import { SANDBOX_KINDS, SandboxError } from './base.mjs';
|
|
20
|
+
import { LocalSandbox } from './local.mjs';
|
|
21
|
+
import { DockerSandbox, parseSandboxSpec } from './docker.mjs';
|
|
22
|
+
import { SshSandbox } from './ssh.mjs';
|
|
23
|
+
import { SingularitySandbox } from './singularity.mjs';
|
|
24
|
+
import { ModalSandbox } from './modal.mjs';
|
|
25
|
+
import { DaytonaSandbox } from './daytona.mjs';
|
|
26
|
+
|
|
27
|
+
const CTORS = {
|
|
28
|
+
local: LocalSandbox,
|
|
29
|
+
docker: DockerSandbox,
|
|
30
|
+
ssh: SshSandbox,
|
|
31
|
+
singularity: SingularitySandbox,
|
|
32
|
+
modal: ModalSandbox,
|
|
33
|
+
daytona: DaytonaSandbox,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function listBackends() { return [...SANDBOX_KINDS]; }
|
|
37
|
+
|
|
38
|
+
export { parseSandboxSpec };
|
|
39
|
+
|
|
40
|
+
export function resolveSandbox(cfg, workerName) {
|
|
41
|
+
const sb = (cfg && cfg.sandbox) || {};
|
|
42
|
+
let kind = sb.default || 'local';
|
|
43
|
+
let overrides = {};
|
|
44
|
+
|
|
45
|
+
const binding = workerName && sb.bindings && sb.bindings[workerName];
|
|
46
|
+
if (binding) {
|
|
47
|
+
if (typeof binding === 'string') kind = binding;
|
|
48
|
+
else { kind = binding.kind || kind; overrides = binding; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!CTORS[kind]) {
|
|
52
|
+
throw new SandboxError(
|
|
53
|
+
`unknown sandbox kind "${kind}" — expected one of ${SANDBOX_KINDS.join(', ')}`,
|
|
54
|
+
'SANDBOX_BAD_KIND',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sectionDefaults = sb[kind] || {};
|
|
59
|
+
const spec = {
|
|
60
|
+
kind,
|
|
61
|
+
...(kind === 'local' ? { confiner: 'none' } : {}),
|
|
62
|
+
...sectionDefaults,
|
|
63
|
+
...overrides,
|
|
64
|
+
kind,
|
|
65
|
+
};
|
|
66
|
+
return new CTORS[kind](spec);
|
|
67
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// sandbox/local.mjs — Local backend with pluggable confiner.
|
|
2
|
+
// Spec §0.1 C8: confiner ∈ {none, seatbelt, bubblewrap, firejail, landlock}.
|
|
3
|
+
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { Sandbox, SandboxSession, SandboxError } from './base.mjs';
|
|
6
|
+
import * as seatbelt from './confiners/seatbelt.mjs';
|
|
7
|
+
import * as bubblewrap from './confiners/bubblewrap.mjs';
|
|
8
|
+
import * as firejail from './confiners/firejail.mjs';
|
|
9
|
+
import * as landlock from './confiners/landlock.mjs';
|
|
10
|
+
|
|
11
|
+
const CONFINERS = { seatbelt, bubblewrap, firejail, landlock };
|
|
12
|
+
|
|
13
|
+
class LocalSession extends SandboxSession {
|
|
14
|
+
constructor(spec, confinerMod) {
|
|
15
|
+
super();
|
|
16
|
+
this.spec = spec;
|
|
17
|
+
this.confiner = confinerMod;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_wrap(argv) {
|
|
21
|
+
if (!this.confiner) return [...argv];
|
|
22
|
+
return this.confiner.buildArgv(argv, this.spec);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async exec(argv, opts = {}) {
|
|
26
|
+
const wrapped = this._wrap(argv);
|
|
27
|
+
const r = spawnSync(wrapped[0], wrapped.slice(1), {
|
|
28
|
+
input: opts.input,
|
|
29
|
+
cwd: opts.cwd,
|
|
30
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
31
|
+
stdio: opts.stdio || 'pipe',
|
|
32
|
+
encoding: 'utf8',
|
|
33
|
+
});
|
|
34
|
+
return { code: r.status ?? -1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async spawn(argv, opts = {}) {
|
|
38
|
+
const wrapped = this._wrap(argv);
|
|
39
|
+
return spawn(wrapped[0], wrapped.slice(1), opts);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async close() { /* no resources */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class LocalSandbox extends Sandbox {
|
|
46
|
+
constructor(spec) {
|
|
47
|
+
super(spec);
|
|
48
|
+
const key = spec.confiner || 'none';
|
|
49
|
+
if (key !== 'none' && !(key in CONFINERS)) {
|
|
50
|
+
throw new SandboxError(`unknown confiner "${key}"`, 'SANDBOX_BAD_CONFINER');
|
|
51
|
+
}
|
|
52
|
+
this.confiner = key === 'none' ? null : CONFINERS[key];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async open() { return new LocalSession(this.spec, this.confiner); }
|
|
56
|
+
describe() {
|
|
57
|
+
return `local · confiner=${this.spec.confiner || 'none'}`;
|
|
58
|
+
}
|
|
59
|
+
}
|