lazyclaw 4.2.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.ko.md +44 -0
  2. package/README.md +172 -353
  3. package/agents.mjs +19 -3
  4. package/channels/handoff.mjs +41 -0
  5. package/channels/loader.mjs +124 -0
  6. package/channels/matrix.mjs +417 -0
  7. package/channels/telegram.mjs +362 -0
  8. package/channels/threads.mjs +116 -0
  9. package/cli.mjs +730 -27
  10. package/daemon.mjs +111 -0
  11. package/gateway/device_auth.mjs +664 -0
  12. package/gateway/http_gateway.mjs +304 -0
  13. package/mas/agent_memory.mjs +35 -34
  14. package/mas/agent_turn.mjs +30 -1
  15. package/mas/confidence.mjs +108 -0
  16. package/mas/index_db.mjs +242 -0
  17. package/mas/mention_router.mjs +75 -4
  18. package/mas/nudge.mjs +97 -0
  19. package/mas/prompt_stack.mjs +80 -0
  20. package/mas/provider_adapters.mjs +83 -0
  21. package/mas/redact.mjs +46 -0
  22. package/mas/skill_synth.mjs +331 -0
  23. package/mas/tool_runner.mjs +19 -48
  24. package/mas/tools/browser.mjs +77 -0
  25. package/mas/tools/clarify.mjs +36 -0
  26. package/mas/tools/coding.mjs +109 -0
  27. package/mas/tools/delegation.mjs +53 -0
  28. package/mas/tools/edit.mjs +36 -0
  29. package/mas/tools/git.mjs +110 -0
  30. package/mas/tools/ha.mjs +34 -0
  31. package/mas/tools/learning.mjs +168 -0
  32. package/mas/tools/media.mjs +105 -0
  33. package/mas/tools/os.mjs +152 -0
  34. package/mas/tools/patch.mjs +91 -0
  35. package/mas/tools/recall.mjs +103 -0
  36. package/mas/tools/registry.mjs +93 -0
  37. package/mas/tools/scheduling.mjs +62 -0
  38. package/mas/tools/skill_view.mjs +43 -0
  39. package/mas/tools/web.mjs +137 -0
  40. package/mas/toolsets.mjs +64 -0
  41. package/mas/trajectory_export.mjs +169 -0
  42. package/mas/trajectory_store.mjs +179 -0
  43. package/mas/user_modeler.mjs +108 -0
  44. package/package.json +22 -3
  45. package/providers/codex_cli.mjs +200 -0
  46. package/providers/gemini_cli.mjs +179 -0
  47. package/providers/registry.mjs +61 -1
  48. package/sandbox/base.mjs +82 -0
  49. package/sandbox/confiners/bubblewrap.mjs +21 -0
  50. package/sandbox/confiners/firejail.mjs +16 -0
  51. package/sandbox/confiners/landlock.mjs +14 -0
  52. package/sandbox/confiners/seatbelt.mjs +28 -0
  53. package/sandbox/daytona.mjs +37 -0
  54. package/sandbox/docker.mjs +91 -0
  55. package/sandbox/index.mjs +67 -0
  56. package/sandbox/local.mjs +59 -0
  57. package/sandbox/modal.mjs +53 -0
  58. package/sandbox/singularity.mjs +39 -0
  59. package/sandbox/ssh.mjs +56 -0
  60. package/sandbox.mjs +11 -127
  61. package/scripts/hermes-import.mjs +111 -0
  62. package/scripts/migrate-v5.mjs +342 -0
  63. package/scripts/openclaw-import.mjs +71 -0
  64. package/sessions.mjs +20 -1
  65. package/skills.mjs +101 -8
  66. package/skills_curator.mjs +323 -0
  67. package/workspace.mjs +18 -3
package/daemon.mjs CHANGED
@@ -21,11 +21,14 @@ import { withFallback } from './providers/fallback.mjs';
21
21
  import { withResponseCache } from './providers/cache.mjs';
22
22
  import { costFromUsage, RATE_CARD_SHAPE } from './providers/rates.mjs';
23
23
  import { composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill } from './skills.mjs';
24
+ import { ChallengeRegistry } from './gateway/device_auth.mjs';
25
+ import { createGateway } from './gateway/http_gateway.mjs';
24
26
  import { TokenBucketLimiter } from './ratelimit.mjs';
25
27
  import { createLogger } from './logger.mjs';
26
28
  import { summarizeState, listSessions as listWorkflowSessions, loadStateFile as loadWorkflowState, aggregateNodeStats } from './workflow/summary.mjs';
27
29
  import { validateConfig } from './config-validate.mjs';
28
30
  import { validateRates } from './rates-validate.mjs';
31
+ import * as nudge from './mas/nudge.mjs';
29
32
 
30
33
  // Resolve the provider for a request. Composes opt-in wrappers in this
31
34
  // order (innermost first):
@@ -321,6 +324,23 @@ export function makeHandler(ctx) {
321
324
  costsByCurrency: /** @type {Record<string, number>} */({}),
322
325
  tokensTotal: { inputTokens: 0, outputTokens: 0 },
323
326
  };
327
+ // Device gateway (Phase 27). The ChallengeRegistry is a per-process
328
+ // singleton: a challenge minted by one request is consumed by a later
329
+ // one, so it must outlive a single call.
330
+ const gwConfigDir = typeof ctx.sessionsDirGetter === 'function' ? ctx.sessionsDirGetter() : undefined;
331
+ const gateway = createGateway({ configDir: gwConfigDir, challengeRegistry: new ChallengeRegistry(), heartbeatMs: 25000 });
332
+ // Phase B nudge loop — scans recent.jsonl every 5 min and pushes
333
+ // nudge.suggest_skill onto the SSE bus so the curator can prompt.
334
+ const _nudgeLoop = nudge.startNudgeLoop({
335
+ configDir: gwConfigDir,
336
+ emit: (event) => {
337
+ try { gateway.broadcast?.('nudge.suggest_skill', event); }
338
+ catch (err) { logger?.warn?.('nudge_emit_failed', { err: err.message }); }
339
+ },
340
+ logger,
341
+ });
342
+ process.on('SIGTERM', () => { _nudgeLoop.stop(); });
343
+ process.on('SIGINT', () => { _nudgeLoop.stop(); });
324
344
  return async function handler(req, res) {
325
345
  // Capture method+path before any handler logic runs; req.url survives
326
346
  // the response but capturing now keeps the log line stable even if a
@@ -361,6 +381,31 @@ export function makeHandler(ctx) {
361
381
  if (!isOriginAllowed(req, allowedOrigins, allowLoopback)) {
362
382
  return writeJson(res, 403, { error: 'forbidden origin' });
363
383
  }
384
+ // Device gateway (Phase 27) — routed BEFORE the shared auth-token
385
+ // gate. Companion-node auth is the gateway's own Ed25519 device-auth
386
+ // (challenge/sign/approve + bearer token); the only unauthenticated
387
+ // route, /gateway/connect/challenge, returns nothing but a random
388
+ // nonce. The bypass decision uses the NORMALIZED pathname (the same
389
+ // one the gateway routes on) so a dot-segment path like
390
+ // `/gateway/../sessions` can't skip the auth-token gate — it
391
+ // normalizes to `/sessions`, fails this prefix test, and falls
392
+ // through to the protected handler. When the limiter is enabled,
393
+ // gateway traffic uses its own key namespace so an unauthenticated
394
+ // flood can't drain the authenticated user's per-IP budget.
395
+ let gwPath = '';
396
+ try { gwPath = new URL(req.url || '/', 'http://localhost').pathname; } catch { gwPath = ''; }
397
+ if (gwPath.startsWith('/gateway/')) {
398
+ if (limiter) {
399
+ const key = 'gw:' + (req.socket?.remoteAddress || 'no-socket');
400
+ const verdict = limiter.consume(key);
401
+ if (!verdict.allowed) {
402
+ metrics.rateLimitDenied += 1;
403
+ const retrySeconds = Math.max(1, Math.ceil(verdict.retryAfterMs / 1000));
404
+ return writeJson(res, 429, { error: 'rate limit exceeded', retryAfterMs: verdict.retryAfterMs }, { 'retry-after': String(retrySeconds) });
405
+ }
406
+ }
407
+ return await gateway.handle(req, res, { readBody: readTextBody });
408
+ }
364
409
  // Authentication gate — when authToken is set, every request must
365
410
  // present `Authorization: Bearer <token>`. This is opt-in because
366
411
  // the default deployment is loopback-only single-user; the token
@@ -430,6 +475,26 @@ export function makeHandler(ctx) {
430
475
  }
431
476
  case route === 'GET /version':
432
477
  return writeJson(res, 200, { version: ctx.version(), nodeVersion: process.version, platform: `${process.platform}-${process.arch}` });
478
+ case route === 'POST /exec/request': {
479
+ // Remote exec-approval bridge. This route is AUTH-TOKEN-GATED
480
+ // (above), so only the trusted local operator/CLI can REQUEST an
481
+ // approval; a paired mobile device RESOLVES it over the gateway
482
+ // (POST /gateway/exec/resolve). The route long-polls: it awaits
483
+ // the device's decision (or the approval's timeout) and returns
484
+ // { approved, by, reason }.
485
+ let body;
486
+ try { body = await readJson(req); }
487
+ catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
488
+ if (!body || typeof body.tool !== 'string' || !body.tool) {
489
+ return writeJson(res, 400, { error: 'tool is required' });
490
+ }
491
+ const { promise } = gateway.requestApproval(
492
+ { tool: body.tool, args: body.args, agentId: body.agentId, summary: body.summary },
493
+ { timeoutMs: Number.isFinite(+body.timeoutMs) ? +body.timeoutMs : undefined },
494
+ );
495
+ const result = await promise;
496
+ return writeJson(res, 200, result);
497
+ }
433
498
  case route === 'GET /health':
434
499
  // Conventional liveness check — always 200 if the process
435
500
  // is alive enough to hit the route. No config inspection
@@ -1486,6 +1551,52 @@ export function makeHandler(ctx) {
1486
1551
  }, m.headers || {});
1487
1552
  }
1488
1553
  }
1554
+ case route === 'POST /inbound': {
1555
+ // Generic inbound bridge — a stable, channel-agnostic relay
1556
+ // target so ANY platform (a Discord/WhatsApp/etc. bot the user
1557
+ // runs elsewhere) can forward a message in and get one reply,
1558
+ // without lazyclaw shipping that platform's SDK. Auth-token
1559
+ // gated like every non-gateway route; additionally pairing-gated
1560
+ // on senderId when a pairing allowlist is configured.
1561
+ const breach = checkCostCap(metrics, costCap);
1562
+ if (breach) return writeJson(res, 402, { error: 'cost cap exceeded', currency: breach.currency, spent: breach.spent, cap: breach.cap });
1563
+ let body;
1564
+ try { body = await readJson(req); }
1565
+ catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
1566
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
1567
+ if (!text) return writeJson(res, 400, { error: 'text is required' });
1568
+ const cfg = ctx.readConfig();
1569
+ // Pairing gate: when the operator has paired any senders, the
1570
+ // relay must identify an allowlisted senderId.
1571
+ const allow = Array.isArray(cfg.pairing) ? cfg.pairing.map((p) => String(p && p.id)) : [];
1572
+ if (allow.length > 0) {
1573
+ const sender = String(body.senderId || '');
1574
+ if (!sender || !allow.includes(sender)) return writeJson(res, 403, { error: 'sender not paired' });
1575
+ }
1576
+ const provName = body.provider || cfg.provider || 'mock';
1577
+ const resolved = resolveProvider(body, provName, cachedByName, logger);
1578
+ if (resolved.error) return writeJson(res, 400, { error: resolved.error });
1579
+ let acc = '';
1580
+ let inboundUsage = null;
1581
+ try {
1582
+ for await (const chunk of resolved.provider.sendMessage(
1583
+ [{ role: 'user', content: text }],
1584
+ { apiKey: cfg['api-key'], model: body.model || cfg.model, onUsage: (u) => { inboundUsage = u; } },
1585
+ )) acc += chunk;
1586
+ } catch (err) {
1587
+ const m = statusForProviderError(err);
1588
+ return writeJson(res, m.status, { error: err?.message || String(err), code: err?.code || null }, m.headers || {});
1589
+ }
1590
+ // Feed the running spend total so the cost cap can actually trip
1591
+ // on /inbound traffic (mirrors POST /agent / POST /chat).
1592
+ if (inboundUsage && cfg.rates) {
1593
+ try {
1594
+ const c = costFromUsage({ provider: provName, model: body.model || cfg.model, usage: inboundUsage }, cfg.rates);
1595
+ if (c) accumulateMetricsFromCost(metrics, inboundUsage, c);
1596
+ } catch { /* cost is best-effort; never block a reply on it */ }
1597
+ }
1598
+ return writeJson(res, 200, { reply: acc, threadId: body.threadId || null });
1599
+ }
1489
1600
  case route === 'POST /agent': {
1490
1601
  const breach = checkCostCap(metrics, costCap);
1491
1602
  if (breach) {