lazyclaw 5.4.4 → 6.0.1
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/channels/handoff.mjs +36 -0
- package/channels-discord/index.mjs +76 -0
- package/channels-discord/package.json +14 -0
- package/channels-email/index.mjs +109 -0
- package/channels-email/package.json +14 -0
- package/channels-signal/index.mjs +69 -0
- package/channels-signal/package.json +9 -0
- package/channels-voice/index.mjs +81 -0
- package/channels-voice/package.json +9 -0
- package/channels-whatsapp/index.mjs +70 -0
- package/channels-whatsapp/package.json +13 -0
- package/cli.mjs +73 -7399
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +255 -0
- package/commands/chat.mjs +1217 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +260 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +511 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +741 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/provider.mjs +69 -0
- package/daemon/lib/respond.mjs +83 -0
- package/daemon/route_table.mjs +96 -0
- package/daemon/routes/_deps.mjs +36 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +371 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +185 -0
- package/daemon/routes/providers.mjs +211 -0
- package/daemon/routes/rates.mjs +90 -0
- package/daemon/routes/registry.mjs +223 -0
- package/daemon/routes/sessions.mjs +213 -0
- package/daemon/routes/skills.mjs +260 -0
- package/daemon/routes/workflows.mjs +224 -0
- package/daemon.mjs +23 -2085
- package/dotenv_min.mjs +23 -0
- package/first_run.mjs +15 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/registry_boot.mjs +55 -0
- package/mas/agent_turn.mjs +2 -1
- package/mas/index_db.mjs +82 -0
- package/mas/learning.mjs +17 -1
- package/mas/mention_router.mjs +38 -10
- package/mas/provider_adapters.mjs +28 -4
- package/mas/scrub_env.mjs +34 -0
- package/mas/tool_runner.mjs +23 -7
- package/mas/tools/bash.mjs +10 -5
- package/mas/tools/browser.mjs +18 -0
- package/mas/tools/learning.mjs +24 -14
- package/mas/tools/recall.mjs +5 -1
- package/mas/tools/web.mjs +47 -11
- package/mas/trajectory_store.mjs +7 -4
- package/package.json +16 -2
- package/providers/auth_store.mjs +22 -0
- package/providers/claude_cli.mjs +28 -2
- package/providers/claude_cli_detect.mjs +46 -0
- package/providers/custom_provider.mjs +70 -0
- package/providers/model_catalogue.mjs +86 -0
- package/providers/orchestrator.mjs +30 -9
- package/providers/rates.mjs +12 -2
- package/providers/registry.mjs +10 -7
- package/sandbox/confiners/landlock.mjs +14 -8
- package/sandbox/confiners/seatbelt.mjs +18 -2
- package/scripts/loop-worker.mjs +18 -7
- package/scripts/migrate-v5.mjs +5 -61
- package/secure_write.mjs +46 -0
- package/sessions.mjs +0 -0
- package/tui/modal_filter.mjs +59 -0
- package/tui/modal_picker.mjs +12 -37
- package/tui/pickers.mjs +917 -0
- package/tui/provider_families.mjs +41 -0
- package/tui/repl.mjs +67 -36
- package/tui/slash_commands.mjs +2 -1
- package/tui/slash_dispatcher.mjs +717 -58
- package/tui/splash.mjs +5 -12
- package/tui/subcommands.mjs +17 -0
- package/tui/terminal_approve.mjs +37 -0
- package/web/dashboard.css +275 -0
- package/web/dashboard.html +2 -1685
- package/web/dashboard.js +1406 -0
- package/workflow/persistent.mjs +13 -6
- package/mas/tools/skill_view.mjs +0 -43
package/mas/mention_router.mjs
CHANGED
|
@@ -280,11 +280,11 @@ async function postTypingPlaceholder({ task, agentRecord, logger = () => {}, sen
|
|
|
280
280
|
if (agentRecord?.iconEmoji) sendOpts.icon_emoji = agentRecord.iconEmoji;
|
|
281
281
|
try {
|
|
282
282
|
const res = await slack.send(threadId, `_:hourglass_flowing_sand: thinking…_`, sendOpts);
|
|
283
|
-
return { ts: res?.ts || null, sender:
|
|
283
|
+
return { ts: res?.ts || null, sender: slack, owned, channel: task.slackChannel };
|
|
284
284
|
} catch (err) {
|
|
285
285
|
logger(`[router] slack typing post failed: ${err?.message || err}\n`);
|
|
286
286
|
if (owned) await slack.stop().catch(() => {});
|
|
287
|
-
return { ts: null, sender: null };
|
|
287
|
+
return { ts: null, sender: null, owned: false };
|
|
288
288
|
}
|
|
289
289
|
}
|
|
290
290
|
|
|
@@ -360,12 +360,12 @@ async function autoSynthSkills({ task, agentsById, apiKey, baseUrl, fetchImpl, c
|
|
|
360
360
|
}
|
|
361
361
|
|
|
362
362
|
async function clearTypingPlaceholder(placeholder, logger) {
|
|
363
|
-
if (!placeholder?.ts || !placeholder?.channel) return;
|
|
363
|
+
if (!placeholder?.ts || !placeholder?.channel || !placeholder?.sender) return;
|
|
364
364
|
const slack = placeholder.sender;
|
|
365
|
-
if (!slack) return; // sender wasn't owned by us; skip
|
|
366
365
|
try { await slack.deleteMessage(placeholder.channel, placeholder.ts); }
|
|
367
366
|
catch (err) { logger(`[router] slack typing delete failed: ${err?.message || err}\n`); }
|
|
368
|
-
|
|
367
|
+
// Only stop a client we created here; a shared sender is closed once by run().
|
|
368
|
+
finally { if (placeholder.owned) await slack.stop().catch(() => {}); }
|
|
369
369
|
}
|
|
370
370
|
|
|
371
371
|
// Run agents in this team until the queue empties or budget runs out.
|
|
@@ -388,6 +388,11 @@ export async function runTaskTurn({
|
|
|
388
388
|
maxAgentTurns = DEFAULT_MAX_AGENT_TURNS,
|
|
389
389
|
signal,
|
|
390
390
|
approve,
|
|
391
|
+
security,
|
|
392
|
+
// E3 — a long-lived caller (e.g. the daemon) can pass a pre-started
|
|
393
|
+
// SlackChannel to reuse across many task turns; run() then neither
|
|
394
|
+
// creates nor stops it. When omitted, run() opens + closes its own.
|
|
395
|
+
slackSender: providedSender,
|
|
391
396
|
} = {}) {
|
|
392
397
|
if (!task || !team || !agentsById) {
|
|
393
398
|
throw new MentionRouterError('task, team, agentsById are required', 'ROUTER_BAD_INPUT');
|
|
@@ -410,11 +415,30 @@ export async function runTaskTurn({
|
|
|
410
415
|
current = tasksMod.patchTask(current.id, { status: 'running' }, configDir);
|
|
411
416
|
}
|
|
412
417
|
|
|
418
|
+
// E3 — open ONE Slack client for the whole run and reuse it for every
|
|
419
|
+
// thread post + typing placeholder, instead of constructing + starting +
|
|
420
|
+
// stopping a fresh SlackChannel (a network auth handshake) on each of the
|
|
421
|
+
// ~3-4 posts per agent turn. Posts fall back to a per-call owned client
|
|
422
|
+
// when no shared sender is available (no thread, or start failure).
|
|
423
|
+
let slackSender = providedSender || null;
|
|
424
|
+
let ownSlackSender = false;
|
|
425
|
+
if (!slackSender && current.slackChannel && current.slackThreadTs) {
|
|
426
|
+
try {
|
|
427
|
+
const { SlackChannel } = await import('../channels/slack.mjs');
|
|
428
|
+
slackSender = new SlackChannel({ requireInbound: false });
|
|
429
|
+
await slackSender.start(async () => '', {});
|
|
430
|
+
ownSlackSender = true;
|
|
431
|
+
} catch (err) {
|
|
432
|
+
logger(`[router] slack start failed, falling back to per-post clients: ${err?.message || err}\n`);
|
|
433
|
+
slackSender = null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
413
437
|
// Seed: append the user message if provided, and (also) push it to
|
|
414
438
|
// Slack so anyone reading the thread sees the prompt.
|
|
415
439
|
if (userMessage && String(userMessage).trim()) {
|
|
416
440
|
current = tasksMod.appendTurn(current.id, { agent: 'user', text: String(userMessage), ts: new Date().toISOString() }, configDir);
|
|
417
|
-
await postToThread({ task: current, agentRecord: null, text: `*User*: ${userMessage}`, logger });
|
|
441
|
+
await postToThread({ task: current, agentRecord: null, text: `*User*: ${userMessage}`, logger, sender: slackSender });
|
|
418
442
|
}
|
|
419
443
|
|
|
420
444
|
const queue = [team.lead];
|
|
@@ -437,7 +461,7 @@ export async function runTaskTurn({
|
|
|
437
461
|
// up the turn before the LLM finishes. Cleared right after the
|
|
438
462
|
// real reply lands so we never leave a stale placeholder in the
|
|
439
463
|
// thread.
|
|
440
|
-
const typing = await postTypingPlaceholder({ task: current, agentRecord, logger });
|
|
464
|
+
const typing = await postTypingPlaceholder({ task: current, agentRecord, logger, sender: slackSender });
|
|
441
465
|
|
|
442
466
|
let result;
|
|
443
467
|
try {
|
|
@@ -450,7 +474,7 @@ export async function runTaskTurn({
|
|
|
450
474
|
userMessage: '',
|
|
451
475
|
history: ctx.history,
|
|
452
476
|
taskId: current.id,
|
|
453
|
-
configDir, cwd, apiKey, fetchImpl, baseUrl, signal, approve,
|
|
477
|
+
configDir, cwd, apiKey, fetchImpl, baseUrl, signal, approve, security,
|
|
454
478
|
// C9 — enable Anthropic prompt caching for the static system
|
|
455
479
|
// prefix + tool definitions. Non-anthropic adapters ignore
|
|
456
480
|
// the flag (it's a no-op for OpenAI/Gemini/claude-cli).
|
|
@@ -470,11 +494,11 @@ export async function runTaskTurn({
|
|
|
470
494
|
|
|
471
495
|
// Slack mirror — only the user-visible text, with the agent name
|
|
472
496
|
// prefixed so a human reader can follow who said what.
|
|
473
|
-
if (replyText) await postToThread({ task: current, agentRecord, text: replyText, logger });
|
|
497
|
+
if (replyText) await postToThread({ task: current, agentRecord, text: replyText, logger, sender: slackSender });
|
|
474
498
|
|
|
475
499
|
if (replyText.includes(DONE_MARKER)) {
|
|
476
500
|
current = tasksMod.patchTask(current.id, { status: 'done' }, configDir);
|
|
477
|
-
await postToThread({ task: current, agentRecord: null, text: `:white_check_mark: ${DONE_MARKER} — task closed by *${agentRecord.displayName || speaker}*.`, logger });
|
|
501
|
+
await postToThread({ task: current, agentRecord: null, text: `:white_check_mark: ${DONE_MARKER} — task closed by *${agentRecord.displayName || speaker}*.`, logger, sender: slackSender });
|
|
478
502
|
stoppedBy = 'done';
|
|
479
503
|
// Phase 18: fire one reflection LLM call per participating agent
|
|
480
504
|
// whose memoryWrite is 'auto'. We pick "participating" off the
|
|
@@ -508,5 +532,9 @@ export async function runTaskTurn({
|
|
|
508
532
|
}
|
|
509
533
|
|
|
510
534
|
if (iterations >= maxAgentTurns) stoppedBy = 'budget';
|
|
535
|
+
// Close the Slack client once for the whole run — but only if WE opened
|
|
536
|
+
// it. A caller-provided sender is left running for the caller to reuse.
|
|
537
|
+
// All exit paths (done/budget/abort/idle) fall through to this return.
|
|
538
|
+
if (ownSlackSender && slackSender) await slackSender.stop().catch(() => {});
|
|
511
539
|
return { task: current, iterations, stoppedBy };
|
|
512
540
|
}
|
|
@@ -35,13 +35,37 @@ export async function resolveToolUseAdapter(provider) {
|
|
|
35
35
|
case 'gemini': return await import('../providers/tool_use/gemini.mjs');
|
|
36
36
|
case 'claude-cli': return await import('../providers/tool_use/claude_cli.mjs');
|
|
37
37
|
default:
|
|
38
|
-
|
|
39
|
-
`provider "${provider}" does not support text completion`,
|
|
40
|
-
'PROVIDER_ADAPTER_UNKNOWN',
|
|
41
|
-
);
|
|
38
|
+
return await _openAICompatAdapter(provider);
|
|
42
39
|
}
|
|
43
40
|
}
|
|
44
41
|
|
|
42
|
+
// Any OpenAI-wire-compatible provider — the built-in compat vendors
|
|
43
|
+
// (nim/openrouter/groq/together/xai/deepseek/mistral/fireworks) and custom
|
|
44
|
+
// providers — can drive tool-use through the OpenAI adapter, just at a
|
|
45
|
+
// different base URL. They advertise as first-class providers, so agents,
|
|
46
|
+
// teams, and the trainer must work for them too (previously they threw
|
|
47
|
+
// "does not support text completion"). We bind the provider's baseUrl so the
|
|
48
|
+
// caller doesn't have to know it; an explicit baseUrl in the call still wins.
|
|
49
|
+
async function _openAICompatAdapter(provider) {
|
|
50
|
+
let info;
|
|
51
|
+
try {
|
|
52
|
+
const reg = await import('../providers/registry.mjs');
|
|
53
|
+
info = reg.PROVIDER_INFO && reg.PROVIDER_INFO[provider];
|
|
54
|
+
} catch { info = null; }
|
|
55
|
+
if (!info || !(info.builtinOpenAICompat || info.custom || info.baseUrl)) {
|
|
56
|
+
throw new ProviderAdapterError(
|
|
57
|
+
`provider "${provider}" does not support text completion`,
|
|
58
|
+
'PROVIDER_ADAPTER_UNKNOWN',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const base = await import('../providers/tool_use/openai.mjs');
|
|
62
|
+
if (!info.baseUrl) return base; // custom without a stored baseUrl — caller supplies it
|
|
63
|
+
return {
|
|
64
|
+
...base,
|
|
65
|
+
callOnce: (opts = {}) => base.callOnce({ baseUrl: info.baseUrl, ...opts }),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
45
69
|
// Run one no-tools text completion through the provider's tool-use
|
|
46
70
|
// adapter and return the model's text (or '' when it produced none).
|
|
47
71
|
//
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// scrub_env.mjs — strip secret-bearing variables from an environment copy.
|
|
2
|
+
//
|
|
3
|
+
// The bash tool inherits the parent process environment so commands behave
|
|
4
|
+
// like a normal shell (PATH, HOME, locale, proxy settings, …). But the parent
|
|
5
|
+
// env also carries provider API keys and channel tokens — including anything
|
|
6
|
+
// loaded from <configDir>/.env by dotenv_min — and an agent's shell command is
|
|
7
|
+
// model-controlled (and steerable via prompt injection). Passing the raw env
|
|
8
|
+
// to a child lets a single `env | curl …` exfiltrate every credential.
|
|
9
|
+
//
|
|
10
|
+
// scrubEnv returns a COPY of `env` with keys that look like secrets removed,
|
|
11
|
+
// while keeping the operational variables a command legitimately needs. An
|
|
12
|
+
// explicit `allow` list opts specific keys back in for the rare command that
|
|
13
|
+
// genuinely needs one.
|
|
14
|
+
|
|
15
|
+
// Matches keys whose final _-segment is a secret-ish noun: FOO_API_KEY,
|
|
16
|
+
// ANTHROPIC_API_KEY, *_TOKEN (incl. CLAUDE_CODE_OAUTH_TOKEN, GITHUB_TOKEN),
|
|
17
|
+
// *_SECRET, *_PASSWORD/PASSWD, *_CREDENTIAL(S), *_PRIVATE_KEY, *_ACCESS_KEY.
|
|
18
|
+
const SECRET_KEY_RE =
|
|
19
|
+
/(^|_)(API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|CREDENTIALS|PRIVATE_KEY|ACCESS_KEY|AUTH_TOKEN)$/i;
|
|
20
|
+
|
|
21
|
+
export function isSecretKey(name) {
|
|
22
|
+
return SECRET_KEY_RE.test(String(name || ''));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function scrubEnv(env = process.env, { allow = [] } = {}) {
|
|
26
|
+
const allowSet = new Set(allow);
|
|
27
|
+
const out = {};
|
|
28
|
+
for (const [k, v] of Object.entries(env || {})) {
|
|
29
|
+
if (allowSet.has(k)) { out[k] = v; continue; }
|
|
30
|
+
if (isSecretKey(k)) continue; // drop secrets
|
|
31
|
+
out[k] = v;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
package/mas/tool_runner.mjs
CHANGED
|
@@ -35,7 +35,7 @@ export function listToolSchemas(names) {
|
|
|
35
35
|
export function isImplemented(name) { return registry.lookup(name) !== null; }
|
|
36
36
|
export function knownTool(name) { return registry.lookup(name) !== null; }
|
|
37
37
|
|
|
38
|
-
export async function runTool({ agent, tool, args, taskId, configDir, cwd, approve } = {}) {
|
|
38
|
+
export async function runTool({ agent, tool, args, taskId, configDir, cwd, approve, security } = {}) {
|
|
39
39
|
if (!agent || !Array.isArray(agent.tools)) {
|
|
40
40
|
throw new ToolError('agent record with .tools[] is required', 'TOOL_BAD_AGENT');
|
|
41
41
|
}
|
|
@@ -44,12 +44,28 @@ export async function runTool({ agent, tool, args, taskId, configDir, cwd, appro
|
|
|
44
44
|
if (!agent.tools.includes(tool)) {
|
|
45
45
|
throw new ToolError(`agent "${agent.name}" is not allowed to call tool "${tool}" (whitelist=[${agent.tools.join(', ')}])`, 'TOOL_DENIED');
|
|
46
46
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
// Sensitive tools (shell exec, file writes, network egress, delegation)
|
|
48
|
+
// are fail-closed: they run only behind an approval hook, OR when the
|
|
49
|
+
// operator has explicitly opted into unattended execution. A missing
|
|
50
|
+
// approve hook used to mean "ungated" — that made a fresh interactive
|
|
51
|
+
// install run bash/write with no confirmation, i.e. remote-prompt-
|
|
52
|
+
// injection-to-RCE. The default is now deny.
|
|
53
|
+
if (impl.sensitive) {
|
|
54
|
+
if (typeof approve === 'function') {
|
|
55
|
+
let verdict;
|
|
56
|
+
try { verdict = await approve({ tool, args, agent: agent.name }); }
|
|
57
|
+
catch (err) { verdict = { approved: false, reason: `approval error: ${err?.message || err}` }; }
|
|
58
|
+
if (!verdict || !verdict.approved) {
|
|
59
|
+
const result = { ok: false, error: `tool "${tool}" denied by operator${verdict?.reason ? `: ${verdict.reason}` : ''}`, code: 'TOOL_DENIED_APPROVAL' };
|
|
60
|
+
audit.append({ taskId, agent: agent.name, tool, args, result, ok: false, configDir });
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
} else if (security && security.allowUnattendedSensitive === true) {
|
|
64
|
+
// Explicit, persisted opt-in to unattended sensitive execution.
|
|
65
|
+
// Never silent — record that the gate was bypassed by config.
|
|
66
|
+
audit.append({ taskId, agent: agent.name, tool, args, result: { ok: true, note: 'sensitive tool ran unattended (security.allowUnattendedSensitive)' }, ok: true, configDir });
|
|
67
|
+
} else {
|
|
68
|
+
const result = { ok: false, error: `tool "${tool}" is sensitive and requires operator approval, but no approval channel is configured. Run interactively, pass --approve-url, or set security.allowUnattendedSensitive=true to allow unattended use.`, code: 'TOOL_DENIED_NO_APPROVER' };
|
|
53
69
|
audit.append({ taskId, agent: agent.name, tool, args, result, ok: false, configDir });
|
|
54
70
|
return result;
|
|
55
71
|
}
|
package/mas/tools/bash.mjs
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
// Bash tool — runs a shell command, captures stdout/stderr/exit.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// The command runs with cwd defaulted to the lazyclaw process cwd, but this
|
|
4
|
+
// is NOT an OS sandbox — absolute paths and `cd` escape it. Two real
|
|
5
|
+
// protections apply instead: (1) bash is a `sensitive` tool, so the
|
|
6
|
+
// fail-closed approval gate in tool_runner requires operator confirmation
|
|
7
|
+
// (or an explicit security.allowUnattendedSensitive opt-in) before it runs;
|
|
8
|
+
// (2) the child env is scrubbed of secrets (scrubEnv) so a command cannot
|
|
9
|
+
// exfiltrate API keys / channel tokens inherited from the parent or
|
|
10
|
+
// <configDir>/.env. Every invocation is still audit-logged for forensics.
|
|
7
11
|
//
|
|
8
12
|
// Timeout defaults to 30s so a runaway command can't stall the whole
|
|
9
13
|
// agent turn. Override via args.timeoutMs (capped at 5 minutes).
|
|
10
14
|
|
|
11
15
|
import { spawn } from 'node:child_process';
|
|
16
|
+
import { scrubEnv } from '../scrub_env.mjs';
|
|
12
17
|
|
|
13
18
|
export const NAME = 'bash';
|
|
14
19
|
export const DESCRIPTION = 'Run a shell command in the agent\'s workspace. Returns {stdout, stderr, exitCode}. Timeout 30s by default.';
|
|
@@ -34,7 +39,7 @@ export async function exec(args, { cwd = process.cwd() } = {}) {
|
|
|
34
39
|
MAX_TIMEOUT_MS
|
|
35
40
|
);
|
|
36
41
|
return new Promise((resolve) => {
|
|
37
|
-
const child = spawn('sh', ['-c', args.command], { cwd, env: process.env });
|
|
42
|
+
const child = spawn('sh', ['-c', args.command], { cwd, env: scrubEnv(process.env) });
|
|
38
43
|
let stdout = '', stderr = '';
|
|
39
44
|
let outBytes = 0, errBytes = 0;
|
|
40
45
|
let truncated = false;
|
package/mas/tools/browser.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// return a structured error if it is missing at runtime. A persistent
|
|
4
4
|
// headless Chromium context is reused across calls in the same process.
|
|
5
5
|
|
|
6
|
+
import { isSafeUrl, isPrivateAddr } from './web.mjs';
|
|
7
|
+
|
|
6
8
|
let _backend = null;
|
|
7
9
|
export function __setBrowserBackend(b) { _backend = b; }
|
|
8
10
|
|
|
@@ -15,6 +17,16 @@ async function ensureCtx() {
|
|
|
15
17
|
catch { throw new Error('browser: playwright not installed (npm i playwright)'); }
|
|
16
18
|
const browser = await pw.chromium.launch({ headless: true });
|
|
17
19
|
const context = await browser.newContext();
|
|
20
|
+
// Block in-page requests (redirects, subresources, JS-driven navigations)
|
|
21
|
+
// to loopback / private / link-local / metadata IPs — goto-time validation
|
|
22
|
+
// alone misses those. Cheap literal-IP check, no per-request DNS.
|
|
23
|
+
await context.route('**/*', (route) => {
|
|
24
|
+
try {
|
|
25
|
+
const h = new URL(route.request().url()).hostname.replace(/^\[|\]$/g, '');
|
|
26
|
+
if (h === 'localhost' || h === '0.0.0.0' || isPrivateAddr(h)) return route.abort('blockedbyclient');
|
|
27
|
+
} catch { /* unparseable → let Chromium handle it */ }
|
|
28
|
+
return route.continue();
|
|
29
|
+
});
|
|
18
30
|
const page = await context.newPage();
|
|
19
31
|
_ctx = {
|
|
20
32
|
navigate: async (url) => { await page.goto(url, { waitUntil: 'domcontentloaded' }); return { url: page.url(), title: await page.title() }; },
|
|
@@ -39,6 +51,12 @@ const browser_navigate = {
|
|
|
39
51
|
parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] },
|
|
40
52
|
async exec(args) {
|
|
41
53
|
if (!safeHttp(args.url)) return { ok: false, error: 'browser_navigate: http(s) only' };
|
|
54
|
+
// Same DNS-resolving SSRF guard as web_fetch before driving Chromium at
|
|
55
|
+
// the URL — otherwise an agent could navigate to the dashboard, cloud
|
|
56
|
+
// metadata (169.254.169.254), or internal services and read them back
|
|
57
|
+
// via title / DOM / screenshot.
|
|
58
|
+
const safe = await isSafeUrl(args.url);
|
|
59
|
+
if (!safe.ok) return { ok: false, error: `browser_navigate: ${safe.error}` };
|
|
42
60
|
try { const ctx = await ensureCtx(); return { ok: true, ...(await ctx.navigate(args.url)) }; }
|
|
43
61
|
catch (e) { return { ok: false, error: `browser_navigate: ${e.message}` }; }
|
|
44
62
|
},
|
package/mas/tools/learning.mjs
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
+
import * as skills from '../../skills.mjs';
|
|
7
8
|
|
|
8
9
|
function resolveConfigDir(ctx) {
|
|
9
10
|
return ctx?.configDir || process.env.LAZYCLAW_CONFIG_DIR || path.join(process.env.HOME || '.', '.lazyclaw');
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
function skillsDir(ctx) { return path.join(resolveConfigDir(ctx), 'skills'); }
|
|
13
13
|
function memoryDir(ctx) { return path.join(resolveConfigDir(ctx), 'memory'); }
|
|
14
14
|
function userMdPath(ctx) { return path.join(memoryDir(ctx), 'USER.md'); }
|
|
15
15
|
|
|
@@ -19,15 +19,26 @@ const skill_view = {
|
|
|
19
19
|
parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
20
20
|
async exec(args, ctx) {
|
|
21
21
|
if (!args?.name) return { ok: false, error: 'skill_view: name required' };
|
|
22
|
-
const
|
|
23
|
-
if (!
|
|
24
|
-
|
|
22
|
+
const configDir = resolveConfigDir(ctx);
|
|
23
|
+
if (!skills.skillExists(args.name, configDir)) return { ok: false, error: `skill_view: ${args.name} not installed` };
|
|
24
|
+
try {
|
|
25
|
+
const content = skills.loadSkill(args.name, configDir);
|
|
26
|
+
// Record the recall so the curator can age out never-used skills.
|
|
27
|
+
// Best-effort: a usage-write hiccup must never fail the tool call.
|
|
28
|
+
try {
|
|
29
|
+
const curator = await import('../../skills_curator.mjs');
|
|
30
|
+
curator.recordUsage(args.name, configDir, Date.now());
|
|
31
|
+
} catch { /* non-fatal */ }
|
|
32
|
+
return { ok: true, name: args.name, content };
|
|
33
|
+
} catch (err) {
|
|
34
|
+
return { ok: false, error: `skill_view: ${err?.message || err}` };
|
|
35
|
+
}
|
|
25
36
|
},
|
|
26
37
|
};
|
|
27
38
|
|
|
28
39
|
const skill_create = {
|
|
29
40
|
name: 'skill_create', category: 'learning', sensitive: true,
|
|
30
|
-
description: 'Create a new skill at <configDir>/skills/<name
|
|
41
|
+
description: 'Create a new skill at <configDir>/skills/<name>.md. Fails if already exists.',
|
|
31
42
|
parameters: {
|
|
32
43
|
type: 'object',
|
|
33
44
|
properties: {
|
|
@@ -39,10 +50,8 @@ const skill_create = {
|
|
|
39
50
|
async exec(args, ctx) {
|
|
40
51
|
if (!args?.name || !args?.body) return { ok: false, error: 'skill_create: name + body required' };
|
|
41
52
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(args.name)) return { ok: false, error: 'skill_create: kebab-case name only' };
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
if (fs.existsSync(file)) return { ok: false, error: `skill_create: ${args.name} already exists; use skill_edit` };
|
|
45
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
53
|
+
const configDir = resolveConfigDir(ctx);
|
|
54
|
+
if (skills.skillExists(args.name, configDir)) return { ok: false, error: `skill_create: ${args.name} already exists; use skill_edit` };
|
|
46
55
|
const fm = [
|
|
47
56
|
'---',
|
|
48
57
|
`name: ${args.name}`,
|
|
@@ -54,7 +63,7 @@ const skill_create = {
|
|
|
54
63
|
'---',
|
|
55
64
|
'',
|
|
56
65
|
].join('\n');
|
|
57
|
-
|
|
66
|
+
const file = skills.installSkill(args.name, fm + args.body + (args.body.endsWith('\n') ? '' : '\n'), configDir);
|
|
58
67
|
return { ok: true, name: args.name, file };
|
|
59
68
|
},
|
|
60
69
|
};
|
|
@@ -68,14 +77,15 @@ const skill_edit = {
|
|
|
68
77
|
required: ['name', 'body'],
|
|
69
78
|
},
|
|
70
79
|
async exec(args, ctx) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
if (!args?.name || !args?.body) return { ok: false, error: 'skill_edit: name + body required' };
|
|
81
|
+
const configDir = resolveConfigDir(ctx);
|
|
82
|
+
if (!skills.skillExists(args.name, configDir)) return { ok: false, error: `skill_edit: ${args.name} not installed` };
|
|
83
|
+
const src = skills.loadSkill(args.name, configDir);
|
|
74
84
|
const m = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(src);
|
|
75
85
|
if (!m) return { ok: false, error: 'skill_edit: missing frontmatter' };
|
|
76
86
|
let fm = m[1];
|
|
77
87
|
fm = fm.replace(/version:\s*(\d+)/, (_, v) => `version: ${Number(v) + 1}`);
|
|
78
|
-
|
|
88
|
+
skills.installSkill(args.name, `---\n${fm}\n---\n${args.body}${args.body.endsWith('\n') ? '' : '\n'}`, configDir);
|
|
79
89
|
return { ok: true, name: args.name };
|
|
80
90
|
},
|
|
81
91
|
};
|
package/mas/tools/recall.mjs
CHANGED
|
@@ -97,7 +97,11 @@ export async function exec(args, { configDir } = {}) {
|
|
|
97
97
|
}
|
|
98
98
|
} else {
|
|
99
99
|
try {
|
|
100
|
-
|
|
100
|
+
// Skip the full-b-tree integrity_check on the read hot path. recall is
|
|
101
|
+
// the most frequent reader and every CLI subcommand / agent worker is a
|
|
102
|
+
// fresh process, so the check (which scales with index size) would be
|
|
103
|
+
// paid on essentially every recall. It belongs in `doctor`, not here.
|
|
104
|
+
openIndex(configDir, { runIntegrityCheck: false });
|
|
101
105
|
} catch (err) {
|
|
102
106
|
return { ok: false, error: `recall: openIndex failed — ${err?.message || err}` };
|
|
103
107
|
}
|
package/mas/tools/web.mjs
CHANGED
|
@@ -11,20 +11,40 @@ const PRIVATE_V4 = [
|
|
|
11
11
|
/^127\./, /^169\.254\./, /^0\./, /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
|
12
12
|
];
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// True for an address (or literal-IP host) that must never be reached from a
|
|
15
|
+
// tool: RFC1918 / loopback / link-local v4, IPv6 loopback (::1), link-local
|
|
16
|
+
// (fe80::/10), unique-local (fc00::/7), unspecified (::), and IPv4-mapped
|
|
17
|
+
// private v6 (::ffff:a.b.c.d). Pure/synchronous — no DNS.
|
|
18
|
+
export function isPrivateAddr(addr) {
|
|
19
|
+
const a = String(addr || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
20
|
+
if (PRIVATE_V4.some((re) => re.test(a))) return true;
|
|
21
|
+
if (a === '::1' || a === '::' || a === '0:0:0:0:0:0:0:1') return true;
|
|
22
|
+
if (/^fe[89ab][0-9a-f]:/.test(a)) return true; // fe80::/10 link-local
|
|
23
|
+
if (/^f[cd][0-9a-f]{2}:/.test(a)) return true; // fc00::/7 unique-local
|
|
24
|
+
const mapped = a.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
25
|
+
if (mapped && PRIVATE_V4.some((re) => re.test(mapped[1]))) return true;
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function isSafeUrl(url) {
|
|
15
30
|
let u;
|
|
16
31
|
try { u = new URL(url); } catch { return { ok: false, error: 'bad URL' }; }
|
|
17
32
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, error: `scheme ${u.protocol} blocked` };
|
|
18
33
|
const host = u.hostname.replace(/^\[|\]$/g, '');
|
|
19
34
|
if (host === 'localhost' || host === '0.0.0.0') return { ok: false, error: 'loopback blocked (SSRF)' };
|
|
20
|
-
|
|
21
|
-
if (host.includes(':'))
|
|
35
|
+
// Literal IP host (v4 or v6): block private/loopback directly, no DNS.
|
|
36
|
+
if (host.includes(':')) {
|
|
37
|
+
if (isPrivateAddr(host)) return { ok: false, error: 'private address blocked (SSRF)' };
|
|
38
|
+
return { ok: true }; // public IPv6 literal
|
|
39
|
+
}
|
|
40
|
+
if (PRIVATE_V4.some((re) => re.test(host))) return { ok: false, error: 'private address blocked (SSRF)' };
|
|
22
41
|
if (!/^[a-z0-9.-]+$/i.test(host)) return { ok: false, error: 'bad host' };
|
|
42
|
+
// Resolve and reject any A/AAAA that lands on a private/loopback address
|
|
43
|
+
// (anti-rebinding for the entry URL).
|
|
23
44
|
try {
|
|
24
45
|
const addrs = await dns.lookup(host, { all: true });
|
|
25
46
|
for (const a of addrs) {
|
|
26
|
-
if (
|
|
27
|
-
if (a.address === '127.0.0.1' || a.address === '::1') return { ok: false, error: 'resolves to loopback (SSRF)' };
|
|
47
|
+
if (isPrivateAddr(a.address)) return { ok: false, error: 'resolves to private address (SSRF)' };
|
|
28
48
|
}
|
|
29
49
|
} catch (e) {
|
|
30
50
|
return { ok: false, error: `dns: ${e.message}` };
|
|
@@ -51,12 +71,28 @@ const web_fetch = {
|
|
|
51
71
|
if (!safe.ok) return { ok: false, error: `web_fetch: ${safe.error}` };
|
|
52
72
|
const maxBytes = Math.min(args.maxBytes || 2_000_000, 5_000_000);
|
|
53
73
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
74
|
+
// Follow redirects manually, re-validating every hop. undici's
|
|
75
|
+
// redirect:'follow' would chase a 30x into a private IP (or a
|
|
76
|
+
// DNS-rebind) without re-checking — the classic SSRF-via-redirect.
|
|
77
|
+
let current = args.url;
|
|
78
|
+
let res;
|
|
79
|
+
for (let hop = 0; hop <= 5; hop++) {
|
|
80
|
+
res = await fetch(current, {
|
|
81
|
+
method: args.method || 'GET',
|
|
82
|
+
headers: args.headers || {},
|
|
83
|
+
body: args.body,
|
|
84
|
+
redirect: 'manual',
|
|
85
|
+
});
|
|
86
|
+
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
|
|
87
|
+
if (hop === 5) return { ok: false, error: 'web_fetch: too many redirects' };
|
|
88
|
+
const next = new URL(res.headers.get('location'), current).toString();
|
|
89
|
+
const ns = await isSafeUrl(next);
|
|
90
|
+
if (!ns.ok) return { ok: false, error: `web_fetch: redirect ${ns.error}` };
|
|
91
|
+
current = next;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
60
96
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
61
97
|
const truncated = buf.length > maxBytes;
|
|
62
98
|
return {
|
package/mas/trajectory_store.mjs
CHANGED
|
@@ -147,10 +147,13 @@ export async function get(id, opts = {}) {
|
|
|
147
147
|
for (const bucket of fs.readdirSync(root)) {
|
|
148
148
|
const file = recordPath(configDir, bucket, id);
|
|
149
149
|
if (fs.existsSync(file)) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
// Guard the parse like the sibling listByTaskId does — a truncated record
|
|
151
|
+
// (crash during _trajPut) must return null, not throw an uncaught error.
|
|
152
|
+
try {
|
|
153
|
+
const rec = JSON.parse(fs.readFileSync(file, 'utf8').trim());
|
|
154
|
+
cachePush(id, rec);
|
|
155
|
+
return rec;
|
|
156
|
+
} catch { return null; }
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
159
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.1",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
"lazyclaw": "cli.mjs"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"test": "node --test tests
|
|
34
|
+
"test": "node --test tests/*.test.mjs && playwright test",
|
|
35
|
+
"lint:size": "node scripts/lint-file-size.mjs",
|
|
36
|
+
"lint:pack": "node scripts/check-pack.mjs",
|
|
35
37
|
"test:bench": "node scripts/bench-providers.mjs",
|
|
36
38
|
"test:bench:index": "node --test tests/index_store.bench.mjs",
|
|
37
39
|
"test:perf": "node --test tests/phaseH-perf.test.mjs tests/index_store.bench.mjs",
|
|
@@ -41,6 +43,18 @@
|
|
|
41
43
|
"files": [
|
|
42
44
|
"cli.mjs",
|
|
43
45
|
"daemon.mjs",
|
|
46
|
+
"daemon/",
|
|
47
|
+
"lib/",
|
|
48
|
+
"commands/",
|
|
49
|
+
"first_run.mjs",
|
|
50
|
+
"dotenv_min.mjs",
|
|
51
|
+
"goals_cron.mjs",
|
|
52
|
+
"secure_write.mjs",
|
|
53
|
+
"channels-discord/",
|
|
54
|
+
"channels-email/",
|
|
55
|
+
"channels-signal/",
|
|
56
|
+
"channels-voice/",
|
|
57
|
+
"channels-whatsapp/",
|
|
44
58
|
"sessions.mjs",
|
|
45
59
|
"skills.mjs",
|
|
46
60
|
"logger.mjs",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// providers/auth_store.mjs — persist an api key for a provider into the
|
|
2
|
+
// authProfiles config shape that _resolveAuthKey reads (cfg.authProfiles
|
|
3
|
+
// [provider] = [{ label, key }], cfg.authActiveProfile[provider] = label).
|
|
4
|
+
//
|
|
5
|
+
// Extracted as a tiny DI'd helper so the Ink /provider key-entry flow can
|
|
6
|
+
// store a key without re-implementing the config shape. No disk — readConfig/
|
|
7
|
+
// writeConfig are injected.
|
|
8
|
+
|
|
9
|
+
export function setAuthKey({ readConfig, writeConfig, provider, key, label = 'default' }) {
|
|
10
|
+
const cfg = readConfig();
|
|
11
|
+
cfg.authProfiles = cfg.authProfiles && typeof cfg.authProfiles === 'object' ? cfg.authProfiles : {};
|
|
12
|
+
cfg.authActiveProfile = cfg.authActiveProfile && typeof cfg.authActiveProfile === 'object' ? cfg.authActiveProfile : {};
|
|
13
|
+
const arr = Array.isArray(cfg.authProfiles[provider]) ? cfg.authProfiles[provider].slice() : [];
|
|
14
|
+
const idx = arr.findIndex((p) => p && p.label === label);
|
|
15
|
+
const entry = { label, key: String(key) };
|
|
16
|
+
if (idx >= 0) arr[idx] = { ...arr[idx], ...entry };
|
|
17
|
+
else arr.push(entry);
|
|
18
|
+
cfg.authProfiles[provider] = arr;
|
|
19
|
+
cfg.authActiveProfile[provider] = label;
|
|
20
|
+
writeConfig(cfg);
|
|
21
|
+
return cfg;
|
|
22
|
+
}
|
package/providers/claude_cli.mjs
CHANGED
|
@@ -65,7 +65,14 @@ const _CLI_MODEL_ALIASES = {
|
|
|
65
65
|
function resolveModelAlias(model) {
|
|
66
66
|
if (!model) return '';
|
|
67
67
|
const lower = String(model).toLowerCase();
|
|
68
|
-
|
|
68
|
+
if (_CLI_MODEL_ALIASES[lower]) return _CLI_MODEL_ALIASES[lower];
|
|
69
|
+
// Unknown but already a bare short alias (e.g. a new tier the table doesn't
|
|
70
|
+
// enumerate yet, like a future "opusplus") → pass it through rather than
|
|
71
|
+
// dropping to '' (which makes the CLI silently ignore the user's model
|
|
72
|
+
// choice). Full canonical ids (with digits/dashes) stay mapped-or-dropped,
|
|
73
|
+
// because passing a full id to `claude --model` hangs the CLI (FF1).
|
|
74
|
+
if (/^[a-z]+$/.test(lower)) return lower;
|
|
75
|
+
return '';
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
// Flatten the chat-style messages array into a single -p prompt the
|
|
@@ -152,6 +159,19 @@ export const claudeCliProvider = {
|
|
|
152
159
|
};
|
|
153
160
|
if (opts.signal) opts.signal.addEventListener('abort', onAbort);
|
|
154
161
|
|
|
162
|
+
// child_process reports spawn failures (ENOENT when `claude` isn't on
|
|
163
|
+
// PATH, EACCES, ...) ASYNCHRONOUSLY via an 'error' event — the
|
|
164
|
+
// synchronous try/catch around spawn above never sees them. Without a
|
|
165
|
+
// listener Node escalates the unhandled 'error' event to an
|
|
166
|
+
// uncaughtException and crashes the whole process; on a box with no
|
|
167
|
+
// claude binary (e.g. CI) this took down `providers test` for the CLI
|
|
168
|
+
// and the daemon entirely. Capture it and surface it through the stream
|
|
169
|
+
// as a normal, catchable error so callers report it per-provider.
|
|
170
|
+
let spawnError = null;
|
|
171
|
+
const spawnErrorPromise = new Promise((resolve) => {
|
|
172
|
+
proc.once('error', (err) => { spawnError = err; resolve(); });
|
|
173
|
+
});
|
|
174
|
+
|
|
155
175
|
let stderr = '';
|
|
156
176
|
proc.stderr.setEncoding('utf8');
|
|
157
177
|
proc.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
@@ -203,7 +223,13 @@ export const claudeCliProvider = {
|
|
|
203
223
|
if (text) yield text;
|
|
204
224
|
} catch (_) { /* incomplete tail — drop */ }
|
|
205
225
|
}
|
|
206
|
-
|
|
226
|
+
// Wait for either a clean exit or an async spawn error. On ENOENT
|
|
227
|
+
// the process never starts, so 'close' never fires — racing against
|
|
228
|
+
// spawnErrorPromise keeps this from hanging forever.
|
|
229
|
+
await Promise.race([exitPromise, spawnErrorPromise]);
|
|
230
|
+
if (spawnError) {
|
|
231
|
+
throw spawnError.code === 'ENOENT' ? new CliMissingError() : spawnError;
|
|
232
|
+
}
|
|
207
233
|
if (exitInfo && exitInfo.code !== 0 && !opts.signal?.aborted) {
|
|
208
234
|
throw new CliExitError(exitInfo.code, exitInfo.signal, stderr);
|
|
209
235
|
}
|