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.
Files changed (91) hide show
  1. package/channels/handoff.mjs +36 -0
  2. package/channels-discord/index.mjs +76 -0
  3. package/channels-discord/package.json +14 -0
  4. package/channels-email/index.mjs +109 -0
  5. package/channels-email/package.json +14 -0
  6. package/channels-signal/index.mjs +69 -0
  7. package/channels-signal/package.json +9 -0
  8. package/channels-voice/index.mjs +81 -0
  9. package/channels-voice/package.json +9 -0
  10. package/channels-whatsapp/index.mjs +70 -0
  11. package/channels-whatsapp/package.json +13 -0
  12. package/cli.mjs +73 -7399
  13. package/commands/agents.mjs +669 -0
  14. package/commands/auth_nodes.mjs +323 -0
  15. package/commands/automation.mjs +582 -0
  16. package/commands/channels.mjs +255 -0
  17. package/commands/chat.mjs +1217 -0
  18. package/commands/config.mjs +315 -0
  19. package/commands/daemon.mjs +260 -0
  20. package/commands/misc.mjs +128 -0
  21. package/commands/providers.mjs +511 -0
  22. package/commands/sessions.mjs +343 -0
  23. package/commands/setup.mjs +741 -0
  24. package/commands/skills.mjs +218 -0
  25. package/commands/workflow.mjs +661 -0
  26. package/daemon/lib/auth.mjs +58 -0
  27. package/daemon/lib/cost.mjs +30 -0
  28. package/daemon/lib/provider.mjs +69 -0
  29. package/daemon/lib/respond.mjs +83 -0
  30. package/daemon/route_table.mjs +96 -0
  31. package/daemon/routes/_deps.mjs +36 -0
  32. package/daemon/routes/config.mjs +99 -0
  33. package/daemon/routes/conversation.mjs +371 -0
  34. package/daemon/routes/meta.mjs +239 -0
  35. package/daemon/routes/ops.mjs +185 -0
  36. package/daemon/routes/providers.mjs +211 -0
  37. package/daemon/routes/rates.mjs +90 -0
  38. package/daemon/routes/registry.mjs +223 -0
  39. package/daemon/routes/sessions.mjs +213 -0
  40. package/daemon/routes/skills.mjs +260 -0
  41. package/daemon/routes/workflows.mjs +224 -0
  42. package/daemon.mjs +23 -2085
  43. package/dotenv_min.mjs +23 -0
  44. package/first_run.mjs +15 -0
  45. package/goals_cron.mjs +37 -0
  46. package/lib/args.mjs +216 -0
  47. package/lib/config.mjs +113 -0
  48. package/lib/registry_boot.mjs +55 -0
  49. package/mas/agent_turn.mjs +2 -1
  50. package/mas/index_db.mjs +82 -0
  51. package/mas/learning.mjs +17 -1
  52. package/mas/mention_router.mjs +38 -10
  53. package/mas/provider_adapters.mjs +28 -4
  54. package/mas/scrub_env.mjs +34 -0
  55. package/mas/tool_runner.mjs +23 -7
  56. package/mas/tools/bash.mjs +10 -5
  57. package/mas/tools/browser.mjs +18 -0
  58. package/mas/tools/learning.mjs +24 -14
  59. package/mas/tools/recall.mjs +5 -1
  60. package/mas/tools/web.mjs +47 -11
  61. package/mas/trajectory_store.mjs +7 -4
  62. package/package.json +16 -2
  63. package/providers/auth_store.mjs +22 -0
  64. package/providers/claude_cli.mjs +28 -2
  65. package/providers/claude_cli_detect.mjs +46 -0
  66. package/providers/custom_provider.mjs +70 -0
  67. package/providers/model_catalogue.mjs +86 -0
  68. package/providers/orchestrator.mjs +30 -9
  69. package/providers/rates.mjs +12 -2
  70. package/providers/registry.mjs +10 -7
  71. package/sandbox/confiners/landlock.mjs +14 -8
  72. package/sandbox/confiners/seatbelt.mjs +18 -2
  73. package/scripts/loop-worker.mjs +18 -7
  74. package/scripts/migrate-v5.mjs +5 -61
  75. package/secure_write.mjs +46 -0
  76. package/sessions.mjs +0 -0
  77. package/tui/modal_filter.mjs +59 -0
  78. package/tui/modal_picker.mjs +12 -37
  79. package/tui/pickers.mjs +917 -0
  80. package/tui/provider_families.mjs +41 -0
  81. package/tui/repl.mjs +67 -36
  82. package/tui/slash_commands.mjs +2 -1
  83. package/tui/slash_dispatcher.mjs +717 -58
  84. package/tui/splash.mjs +5 -12
  85. package/tui/subcommands.mjs +17 -0
  86. package/tui/terminal_approve.mjs +37 -0
  87. package/web/dashboard.css +275 -0
  88. package/web/dashboard.html +2 -1685
  89. package/web/dashboard.js +1406 -0
  90. package/workflow/persistent.mjs +13 -6
  91. package/mas/tools/skill_view.mjs +0 -43
@@ -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: owned ? slack : null, channel: task.slackChannel };
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
- finally { await slack.stop().catch(() => {}); }
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
- throw new ProviderAdapterError(
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
+ }
@@ -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
- if (typeof approve === 'function' && impl.sensitive) {
48
- let verdict;
49
- try { verdict = await approve({ tool, args, agent: agent.name }); }
50
- catch (err) { verdict = { approved: false, reason: `approval error: ${err?.message || err}` }; }
51
- if (!verdict || !verdict.approved) {
52
- const result = { ok: false, error: `tool "${tool}" denied by operator${verdict?.reason ? `: ${verdict.reason}` : ''}`, code: 'TOOL_DENIED_APPROVAL' };
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
  }
@@ -1,14 +1,19 @@
1
1
  // Bash tool — runs a shell command, captures stdout/stderr/exit.
2
2
  //
3
- // Workspace is constrained to the lazyclaw process cwd (spec §5.2). We
4
- // don't sandbox further (per §10 #6 destructive-pattern confirmation
5
- // is OFF by default); the audit log captures every invocation so post-hoc
6
- // forensics work.
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;
@@ -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
  },
@@ -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 file = path.join(skillsDir(ctx), args.name, 'SKILL.md');
23
- if (!fs.existsSync(file)) return { ok: false, error: `skill_view: ${args.name} not installed` };
24
- return { ok: true, name: args.name, content: fs.readFileSync(file, 'utf8') };
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>/SKILL.md. Fails if already exists.',
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 dir = path.join(skillsDir(ctx), args.name);
43
- const file = path.join(dir, 'SKILL.md');
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
- fs.writeFileSync(file, fm + args.body + (args.body.endsWith('\n') ? '' : '\n'));
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
- const file = path.join(skillsDir(ctx), args.name, 'SKILL.md');
72
- if (!fs.existsSync(file)) return { ok: false, error: `skill_edit: ${args.name} not installed` };
73
- const src = fs.readFileSync(file, 'utf8');
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
- fs.writeFileSync(file, `---\n${fm}\n---\n${args.body}${args.body.endsWith('\n') ? '' : '\n'}`);
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
  };
@@ -97,7 +97,11 @@ export async function exec(args, { configDir } = {}) {
97
97
  }
98
98
  } else {
99
99
  try {
100
- openIndex(configDir);
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
- async function isSafeUrl(url) {
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
- if (PRIVATE_V4.some(re => re.test(host))) return { ok: false, error: 'private address blocked (SSRF)' };
21
- if (host.includes(':')) return { ok: false, error: 'IPv6 disabled' };
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 (PRIVATE_V4.some(re => re.test(a.address))) return { ok: false, error: 'resolves to private address (SSRF)' };
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
- const res = await fetch(args.url, {
55
- method: args.method || 'GET',
56
- headers: args.headers || {},
57
- body: args.body,
58
- redirect: 'follow',
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 {
@@ -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
- const raw = fs.readFileSync(file, 'utf8').trim();
151
- const rec = JSON.parse(raw);
152
- cachePush(id, rec);
153
- return rec;
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": "5.4.4",
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/phaseC-*.test.mjs && playwright test",
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
+ }
@@ -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
- return _CLI_MODEL_ALIASES[lower] ?? '';
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
- await exitPromise;
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
  }