lazyclaw 4.2.1 → 4.3.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/daemon.mjs CHANGED
@@ -21,6 +21,8 @@ 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';
@@ -321,6 +323,11 @@ export function makeHandler(ctx) {
321
323
  costsByCurrency: /** @type {Record<string, number>} */({}),
322
324
  tokensTotal: { inputTokens: 0, outputTokens: 0 },
323
325
  };
326
+ // Device gateway (Phase 27). The ChallengeRegistry is a per-process
327
+ // singleton: a challenge minted by one request is consumed by a later
328
+ // one, so it must outlive a single call.
329
+ const gwConfigDir = typeof ctx.sessionsDirGetter === 'function' ? ctx.sessionsDirGetter() : undefined;
330
+ const gateway = createGateway({ configDir: gwConfigDir, challengeRegistry: new ChallengeRegistry(), heartbeatMs: 25000 });
324
331
  return async function handler(req, res) {
325
332
  // Capture method+path before any handler logic runs; req.url survives
326
333
  // the response but capturing now keeps the log line stable even if a
@@ -361,6 +368,31 @@ export function makeHandler(ctx) {
361
368
  if (!isOriginAllowed(req, allowedOrigins, allowLoopback)) {
362
369
  return writeJson(res, 403, { error: 'forbidden origin' });
363
370
  }
371
+ // Device gateway (Phase 27) — routed BEFORE the shared auth-token
372
+ // gate. Companion-node auth is the gateway's own Ed25519 device-auth
373
+ // (challenge/sign/approve + bearer token); the only unauthenticated
374
+ // route, /gateway/connect/challenge, returns nothing but a random
375
+ // nonce. The bypass decision uses the NORMALIZED pathname (the same
376
+ // one the gateway routes on) so a dot-segment path like
377
+ // `/gateway/../sessions` can't skip the auth-token gate — it
378
+ // normalizes to `/sessions`, fails this prefix test, and falls
379
+ // through to the protected handler. When the limiter is enabled,
380
+ // gateway traffic uses its own key namespace so an unauthenticated
381
+ // flood can't drain the authenticated user's per-IP budget.
382
+ let gwPath = '';
383
+ try { gwPath = new URL(req.url || '/', 'http://localhost').pathname; } catch { gwPath = ''; }
384
+ if (gwPath.startsWith('/gateway/')) {
385
+ if (limiter) {
386
+ const key = 'gw:' + (req.socket?.remoteAddress || 'no-socket');
387
+ const verdict = limiter.consume(key);
388
+ if (!verdict.allowed) {
389
+ metrics.rateLimitDenied += 1;
390
+ const retrySeconds = Math.max(1, Math.ceil(verdict.retryAfterMs / 1000));
391
+ return writeJson(res, 429, { error: 'rate limit exceeded', retryAfterMs: verdict.retryAfterMs }, { 'retry-after': String(retrySeconds) });
392
+ }
393
+ }
394
+ return await gateway.handle(req, res, { readBody: readTextBody });
395
+ }
364
396
  // Authentication gate — when authToken is set, every request must
365
397
  // present `Authorization: Bearer <token>`. This is opt-in because
366
398
  // the default deployment is loopback-only single-user; the token
@@ -430,6 +462,26 @@ export function makeHandler(ctx) {
430
462
  }
431
463
  case route === 'GET /version':
432
464
  return writeJson(res, 200, { version: ctx.version(), nodeVersion: process.version, platform: `${process.platform}-${process.arch}` });
465
+ case route === 'POST /exec/request': {
466
+ // Remote exec-approval bridge. This route is AUTH-TOKEN-GATED
467
+ // (above), so only the trusted local operator/CLI can REQUEST an
468
+ // approval; a paired mobile device RESOLVES it over the gateway
469
+ // (POST /gateway/exec/resolve). The route long-polls: it awaits
470
+ // the device's decision (or the approval's timeout) and returns
471
+ // { approved, by, reason }.
472
+ let body;
473
+ try { body = await readJson(req); }
474
+ catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
475
+ if (!body || typeof body.tool !== 'string' || !body.tool) {
476
+ return writeJson(res, 400, { error: 'tool is required' });
477
+ }
478
+ const { promise } = gateway.requestApproval(
479
+ { tool: body.tool, args: body.args, agentId: body.agentId, summary: body.summary },
480
+ { timeoutMs: Number.isFinite(+body.timeoutMs) ? +body.timeoutMs : undefined },
481
+ );
482
+ const result = await promise;
483
+ return writeJson(res, 200, result);
484
+ }
433
485
  case route === 'GET /health':
434
486
  // Conventional liveness check — always 200 if the process
435
487
  // is alive enough to hit the route. No config inspection
@@ -1486,6 +1538,52 @@ export function makeHandler(ctx) {
1486
1538
  }, m.headers || {});
1487
1539
  }
1488
1540
  }
1541
+ case route === 'POST /inbound': {
1542
+ // Generic inbound bridge — a stable, channel-agnostic relay
1543
+ // target so ANY platform (a Discord/WhatsApp/etc. bot the user
1544
+ // runs elsewhere) can forward a message in and get one reply,
1545
+ // without lazyclaw shipping that platform's SDK. Auth-token
1546
+ // gated like every non-gateway route; additionally pairing-gated
1547
+ // on senderId when a pairing allowlist is configured.
1548
+ const breach = checkCostCap(metrics, costCap);
1549
+ if (breach) return writeJson(res, 402, { error: 'cost cap exceeded', currency: breach.currency, spent: breach.spent, cap: breach.cap });
1550
+ let body;
1551
+ try { body = await readJson(req); }
1552
+ catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
1553
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
1554
+ if (!text) return writeJson(res, 400, { error: 'text is required' });
1555
+ const cfg = ctx.readConfig();
1556
+ // Pairing gate: when the operator has paired any senders, the
1557
+ // relay must identify an allowlisted senderId.
1558
+ const allow = Array.isArray(cfg.pairing) ? cfg.pairing.map((p) => String(p && p.id)) : [];
1559
+ if (allow.length > 0) {
1560
+ const sender = String(body.senderId || '');
1561
+ if (!sender || !allow.includes(sender)) return writeJson(res, 403, { error: 'sender not paired' });
1562
+ }
1563
+ const provName = body.provider || cfg.provider || 'mock';
1564
+ const resolved = resolveProvider(body, provName, cachedByName, logger);
1565
+ if (resolved.error) return writeJson(res, 400, { error: resolved.error });
1566
+ let acc = '';
1567
+ let inboundUsage = null;
1568
+ try {
1569
+ for await (const chunk of resolved.provider.sendMessage(
1570
+ [{ role: 'user', content: text }],
1571
+ { apiKey: cfg['api-key'], model: body.model || cfg.model, onUsage: (u) => { inboundUsage = u; } },
1572
+ )) acc += chunk;
1573
+ } catch (err) {
1574
+ const m = statusForProviderError(err);
1575
+ return writeJson(res, m.status, { error: err?.message || String(err), code: err?.code || null }, m.headers || {});
1576
+ }
1577
+ // Feed the running spend total so the cost cap can actually trip
1578
+ // on /inbound traffic (mirrors POST /agent / POST /chat).
1579
+ if (inboundUsage && cfg.rates) {
1580
+ try {
1581
+ const c = costFromUsage({ provider: provName, model: body.model || cfg.model, usage: inboundUsage }, cfg.rates);
1582
+ if (c) accumulateMetricsFromCost(metrics, inboundUsage, c);
1583
+ } catch { /* cost is best-effort; never block a reply on it */ }
1584
+ }
1585
+ return writeJson(res, 200, { reply: acc, threadId: body.threadId || null });
1586
+ }
1489
1587
  case route === 'POST /agent': {
1490
1588
  const breach = checkCostCap(metrics, costCap);
1491
1589
  if (breach) {