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/README.md +155 -0
- package/agents.mjs +19 -3
- package/channels/matrix.mjs +417 -0
- package/channels/telegram.mjs +362 -0
- package/cli.mjs +381 -60
- package/daemon.mjs +98 -0
- package/gateway/device_auth.mjs +664 -0
- package/gateway/http_gateway.mjs +304 -0
- package/mas/agent_memory.mjs +35 -34
- package/mas/agent_turn.mjs +2 -1
- package/mas/mention_router.mjs +75 -4
- package/mas/provider_adapters.mjs +83 -0
- package/mas/redact.mjs +46 -0
- package/mas/skill_synth.mjs +232 -0
- package/mas/tool_runner.mjs +24 -2
- package/mas/tools/skill_view.mjs +43 -0
- package/package.json +3 -1
- package/skills.mjs +101 -8
- package/skills_curator.mjs +323 -0
- package/workspace.mjs +18 -3
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) {
|