lazyclaw 5.4.3 → 6.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/channels/handoff.mjs +36 -0
- package/cli.mjs +73 -7399
- package/daemon.mjs +23 -2085
- 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 +3 -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/sessions.mjs +0 -0
- package/tui/editor.mjs +44 -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 +8 -7
- package/tui/slash_dispatcher.mjs +923 -118
- 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/agent_turn.mjs
CHANGED
|
@@ -94,6 +94,7 @@ export async function runAgentTurn({
|
|
|
94
94
|
maxIterations = DEFAULT_MAX_ITERATIONS,
|
|
95
95
|
signal,
|
|
96
96
|
approve,
|
|
97
|
+
security,
|
|
97
98
|
// v5 (Group A — C3): trajectoryRef is OPT-OUT, not opt-in. A caller
|
|
98
99
|
// that doesn't pass one but DOES pass a configDir gets a
|
|
99
100
|
// default-stamped record so every production agent turn lands in
|
|
@@ -218,7 +219,7 @@ export async function runAgentTurn({
|
|
|
218
219
|
try {
|
|
219
220
|
result = await runTool({
|
|
220
221
|
agent, tool: call.name, args: call.input,
|
|
221
|
-
taskId, configDir, cwd, approve,
|
|
222
|
+
taskId, configDir, cwd, approve, security,
|
|
222
223
|
});
|
|
223
224
|
if (result && result.ok === false) ok = false;
|
|
224
225
|
} catch (err) {
|
package/mas/index_db.mjs
CHANGED
|
@@ -85,6 +85,16 @@ function prepareStatements(db) {
|
|
|
85
85
|
insertMemory: db.prepare(
|
|
86
86
|
`INSERT INTO fts_memories(content, topic, kind)
|
|
87
87
|
VALUES (?, ?, ?)`),
|
|
88
|
+
// Upsert-by-natural-key deletes (skills/trajectories/memories only —
|
|
89
|
+
// these get re-indexed for the same key on every save/put, so a bare
|
|
90
|
+
// INSERT would accumulate duplicate FTS rows that skew bm25 and eat the
|
|
91
|
+
// recall k-budget). Sessions are NOT deduped this way: each turn has a
|
|
92
|
+
// unique (session_id,turn_idx) in normal flow, and a per-turn full-table
|
|
93
|
+
// DELETE would reintroduce O(n^2) on the hot write path; session dedup is
|
|
94
|
+
// handled by reindexAll rebuilding from scratch.
|
|
95
|
+
deleteSkill: db.prepare(`DELETE FROM fts_skills WHERE skill_name = ?`),
|
|
96
|
+
deleteTrajectory: db.prepare(`DELETE FROM fts_trajectories WHERE trajectory_id = ?`),
|
|
97
|
+
deleteMemory: db.prepare(`DELETE FROM fts_memories WHERE topic = ? AND kind = ?`),
|
|
88
98
|
queries: {
|
|
89
99
|
sessions: db.prepare(
|
|
90
100
|
`SELECT 'sessions' AS scope, bm25(fts_sessions) AS bm25,
|
|
@@ -160,6 +170,7 @@ export function indexSessionTurn(row, configDir = defaultConfigDir()) {
|
|
|
160
170
|
export function indexSkill(row, configDir = defaultConfigDir()) {
|
|
161
171
|
try {
|
|
162
172
|
const s = _stmts(configDir);
|
|
173
|
+
s.deleteSkill.run(String(row.skill_name || '')); // upsert by skill_name
|
|
163
174
|
s.insertSkill.run(
|
|
164
175
|
redactSecrets(String(row.content || '')),
|
|
165
176
|
String(row.skill_name || ''), String(row.trained_by || ''),
|
|
@@ -175,6 +186,7 @@ export function indexSkill(row, configDir = defaultConfigDir()) {
|
|
|
175
186
|
export function indexTrajectory(row, configDir = defaultConfigDir()) {
|
|
176
187
|
try {
|
|
177
188
|
const s = _stmts(configDir);
|
|
189
|
+
s.deleteTrajectory.run(String(row.trajectory_id || '')); // upsert by trajectory_id
|
|
178
190
|
s.insertTrajectory.run(
|
|
179
191
|
redactSecrets(String(row.content || '')),
|
|
180
192
|
String(row.trajectory_id || ''), String(row.agent || ''),
|
|
@@ -190,6 +202,7 @@ export function indexTrajectory(row, configDir = defaultConfigDir()) {
|
|
|
190
202
|
export function indexMemory(row, configDir = defaultConfigDir()) {
|
|
191
203
|
try {
|
|
192
204
|
const s = _stmts(configDir);
|
|
205
|
+
s.deleteMemory.run(String(row.topic || ''), String(row.kind || '')); // upsert by (topic,kind)
|
|
193
206
|
s.insertMemory.run(
|
|
194
207
|
redactSecrets(String(row.content || '')),
|
|
195
208
|
String(row.topic || ''), String(row.kind || ''),
|
|
@@ -287,6 +300,9 @@ export function integrityCheck(configDir = defaultConfigDir()) {
|
|
|
287
300
|
return { ok: r === 'ok', result: r };
|
|
288
301
|
}
|
|
289
302
|
|
|
303
|
+
// Destructive primitive: drop the db (+ WAL sidecars) and recreate the empty
|
|
304
|
+
// schema. Callers that want a *populated* index must use reindexAll — a bare
|
|
305
|
+
// rebuild leaves recall returning zero hits.
|
|
290
306
|
export function rebuild(configDir = defaultConfigDir()) {
|
|
291
307
|
closeIndex(configDir);
|
|
292
308
|
const p = dbPath(configDir);
|
|
@@ -300,3 +316,69 @@ export function rebuild(configDir = defaultConfigDir()) {
|
|
|
300
316
|
}
|
|
301
317
|
openIndex(configDir);
|
|
302
318
|
}
|
|
319
|
+
|
|
320
|
+
// Minimal frontmatter splitter (trained_by / group are the only keys reindex
|
|
321
|
+
// needs); avoids importing skills.mjs and risking an import cycle.
|
|
322
|
+
function _miniFrontmatter(raw) {
|
|
323
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(String(raw || ''));
|
|
324
|
+
if (!m) return { meta: {}, body: String(raw || '') };
|
|
325
|
+
const meta = {};
|
|
326
|
+
for (const line of m[1].split('\n')) {
|
|
327
|
+
const i = line.indexOf(':');
|
|
328
|
+
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
329
|
+
}
|
|
330
|
+
return { meta, body: m[2] };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Rebuild AND repopulate the FTS index from the on-disk source of truth
|
|
334
|
+
// (sessions JSONL, flat skill .md, memory core/episodic). This is what
|
|
335
|
+
// `index rebuild` / doctor recovery must call — a bare rebuild() zeroes recall.
|
|
336
|
+
// Shared by scripts/migrate-v5 and the daemon POST /index/rebuild route.
|
|
337
|
+
export function reindexAll(configDir = defaultConfigDir()) {
|
|
338
|
+
rebuild(configDir);
|
|
339
|
+
// Sessions — flat <configDir>/sessions/<id>.jsonl, one turn per line.
|
|
340
|
+
const sessDir = path.join(configDir, 'sessions');
|
|
341
|
+
if (fs.existsSync(sessDir)) {
|
|
342
|
+
for (const f of fs.readdirSync(sessDir)) {
|
|
343
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
344
|
+
const id = f.slice(0, -'.jsonl'.length);
|
|
345
|
+
let idx = 0;
|
|
346
|
+
const raw = fs.readFileSync(path.join(sessDir, f), 'utf8');
|
|
347
|
+
for (const line of raw.split('\n')) {
|
|
348
|
+
if (!line) continue;
|
|
349
|
+
try {
|
|
350
|
+
const o = JSON.parse(line);
|
|
351
|
+
indexSessionTurn({ session_id: id, turn_idx: idx++, role: o.role || 'user', ts: o.ts || 0, content: o.content || '' }, configDir);
|
|
352
|
+
} catch { /* skip malformed line */ }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Skills — canonical flat <configDir>/skills/<name>.md (skip the .archive dir).
|
|
357
|
+
const skillsDir = path.join(configDir, 'skills');
|
|
358
|
+
if (fs.existsSync(skillsDir)) {
|
|
359
|
+
for (const f of fs.readdirSync(skillsDir)) {
|
|
360
|
+
if (!f.endsWith('.md')) continue;
|
|
361
|
+
const name = f.slice(0, -'.md'.length);
|
|
362
|
+
const { meta, body } = _miniFrontmatter(fs.readFileSync(path.join(skillsDir, f), 'utf8'));
|
|
363
|
+
indexSkill({
|
|
364
|
+
skill_name: name,
|
|
365
|
+
trained_by: meta.trained_by || 'legacy',
|
|
366
|
+
group_name: meta.group || (name.includes('-') ? name.split('-')[0] : 'legacy'),
|
|
367
|
+
content: body,
|
|
368
|
+
}, configDir);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Memory — core.md + episodic/*.md.
|
|
372
|
+
const memDir = path.join(configDir, 'memory');
|
|
373
|
+
if (fs.existsSync(memDir)) {
|
|
374
|
+
const core = path.join(memDir, 'core.md');
|
|
375
|
+
if (fs.existsSync(core)) indexMemory({ topic: 'core', kind: 'core', content: fs.readFileSync(core, 'utf8') }, configDir);
|
|
376
|
+
const epi = path.join(memDir, 'episodic');
|
|
377
|
+
if (fs.existsSync(epi)) {
|
|
378
|
+
for (const f of fs.readdirSync(epi)) {
|
|
379
|
+
if (!f.endsWith('.md')) continue;
|
|
380
|
+
indexMemory({ topic: f.slice(0, -'.md'.length), kind: 'episodic', content: fs.readFileSync(path.join(epi, f), 'utf8') }, configDir);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
package/mas/learning.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import * as userModeler from './user_modeler.mjs';
|
|
|
34
34
|
import * as confidence from './confidence.mjs';
|
|
35
35
|
import * as skills from '../skills.mjs';
|
|
36
36
|
import { resolveTrainer } from '../providers/registry.mjs';
|
|
37
|
+
import { hasClaudeCliSession } from '../providers/claude_cli_detect.mjs';
|
|
37
38
|
|
|
38
39
|
export const TRIGGERS = Object.freeze([
|
|
39
40
|
'post-task',
|
|
@@ -330,9 +331,24 @@ export async function _runPeriodicCuration(_ctx, logger) {
|
|
|
330
331
|
|
|
331
332
|
// ── helpers ──────────────────────────────────────────────────────────
|
|
332
333
|
|
|
334
|
+
let _trainerNoticed = false;
|
|
335
|
+
|
|
333
336
|
function _safeResolveTrainer(cfg, agent) {
|
|
334
337
|
try {
|
|
335
|
-
|
|
338
|
+
const c = cfg || { provider: agent?.provider, model: agent?.model };
|
|
339
|
+
// Use real claude-cli session detection in production (the resolver's own
|
|
340
|
+
// default stub only checked an env var that `claude login` never sets).
|
|
341
|
+
const resolved = resolveTrainer(c, { detectClaudeCli: hasClaudeCliSession });
|
|
342
|
+
// Honesty: if the user asked for trainer.provider:'auto' but no claude-cli
|
|
343
|
+
// session was found, the learning loop bills the chat provider — say so once
|
|
344
|
+
// so the "$0 on Claude Pro" promise never fails silently.
|
|
345
|
+
if (!_trainerNoticed && c && c.trainer && c.trainer.provider === 'auto' && resolved.provider !== 'claude-cli') {
|
|
346
|
+
_trainerNoticed = true;
|
|
347
|
+
try {
|
|
348
|
+
process.stderr.write(`[trainer] no claude-cli session detected → learning will use "${resolved.provider || 'the chat provider'}" (billed per token). Run 'claude login', or set trainer.provider explicitly, to control cost.\n`);
|
|
349
|
+
} catch { /* stderr closed */ }
|
|
350
|
+
}
|
|
351
|
+
return resolved;
|
|
336
352
|
} catch {
|
|
337
353
|
return { provider: agent?.provider || '', model: agent?.model || '' };
|
|
338
354
|
}
|
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
|
}
|