lazyclaw 6.0.0 → 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 (47) hide show
  1. package/channels-discord/index.mjs +76 -0
  2. package/channels-discord/package.json +14 -0
  3. package/channels-email/index.mjs +109 -0
  4. package/channels-email/package.json +14 -0
  5. package/channels-signal/index.mjs +69 -0
  6. package/channels-signal/package.json +9 -0
  7. package/channels-voice/index.mjs +81 -0
  8. package/channels-voice/package.json +9 -0
  9. package/channels-whatsapp/index.mjs +70 -0
  10. package/channels-whatsapp/package.json +13 -0
  11. package/commands/agents.mjs +669 -0
  12. package/commands/auth_nodes.mjs +323 -0
  13. package/commands/automation.mjs +582 -0
  14. package/commands/channels.mjs +255 -0
  15. package/commands/chat.mjs +1217 -0
  16. package/commands/config.mjs +315 -0
  17. package/commands/daemon.mjs +260 -0
  18. package/commands/misc.mjs +128 -0
  19. package/commands/providers.mjs +511 -0
  20. package/commands/sessions.mjs +343 -0
  21. package/commands/setup.mjs +741 -0
  22. package/commands/skills.mjs +218 -0
  23. package/commands/workflow.mjs +661 -0
  24. package/daemon/lib/auth.mjs +58 -0
  25. package/daemon/lib/cost.mjs +30 -0
  26. package/daemon/lib/provider.mjs +69 -0
  27. package/daemon/lib/respond.mjs +83 -0
  28. package/daemon/route_table.mjs +96 -0
  29. package/daemon/routes/_deps.mjs +36 -0
  30. package/daemon/routes/config.mjs +99 -0
  31. package/daemon/routes/conversation.mjs +371 -0
  32. package/daemon/routes/meta.mjs +239 -0
  33. package/daemon/routes/ops.mjs +185 -0
  34. package/daemon/routes/providers.mjs +211 -0
  35. package/daemon/routes/rates.mjs +90 -0
  36. package/daemon/routes/registry.mjs +223 -0
  37. package/daemon/routes/sessions.mjs +213 -0
  38. package/daemon/routes/skills.mjs +260 -0
  39. package/daemon/routes/workflows.mjs +224 -0
  40. package/dotenv_min.mjs +23 -0
  41. package/first_run.mjs +15 -0
  42. package/goals_cron.mjs +37 -0
  43. package/lib/args.mjs +216 -0
  44. package/lib/config.mjs +113 -0
  45. package/lib/registry_boot.mjs +55 -0
  46. package/package.json +14 -1
  47. package/secure_write.mjs +46 -0
@@ -0,0 +1,58 @@
1
+ // Auth-token + origin gate helpers for the daemon. Pure — used only by
2
+ // the pre-switch middleware in makeHandler.
3
+
4
+ /**
5
+ * Constant-time string equality. Plain `===` would short-circuit on the
6
+ * first mismatching byte, leaking timing info that lets an attacker on
7
+ * a shared host narrow the secret one byte at a time. We compare every
8
+ * byte with XOR + accumulator.
9
+ */
10
+ export function constantTimeEqual(a, b) {
11
+ const aStr = String(a ?? '');
12
+ const bStr = String(b ?? '');
13
+ if (aStr.length !== bStr.length) return false;
14
+ let diff = 0;
15
+ for (let i = 0; i < aStr.length; i++) {
16
+ diff |= aStr.charCodeAt(i) ^ bStr.charCodeAt(i);
17
+ }
18
+ return diff === 0;
19
+ }
20
+
21
+ export function isAuthorized(req, expectedToken) {
22
+ if (!expectedToken) return true; // auth disabled
23
+ const header = req.headers['authorization'] || '';
24
+ const m = /^Bearer\s+(.+)$/i.exec(header);
25
+ if (!m) return false;
26
+ return constantTimeEqual(m[1].trim(), expectedToken);
27
+ }
28
+
29
+ /**
30
+ * Origin gate — protect against DNS-rebinding / CSRF where a page in
31
+ * the user's browser posts to 127.0.0.1:<our port>. Browsers always
32
+ * attach `Origin` for cross-origin POSTs (and increasingly for GETs);
33
+ * CLI tools (curl, fetch from a script) usually don't.
34
+ *
35
+ * Policy:
36
+ * - No `Origin` header → assume non-browser caller, allow.
37
+ * - `Origin` set → must be in `allowedOrigins`. Empty allowlist
38
+ * means "reject all browser-originated requests" — the default,
39
+ * because the daemon is designed for CLI/script callers.
40
+ * - `allowLoopback: true` (set by `lazyclaw dashboard`) additionally
41
+ * accepts any `Origin` that looks like loopback (`http://127.0.0.1:*`,
42
+ * `http://localhost:*`, `http://[::1]:*`). Safe because the daemon
43
+ * binds only to 127.0.0.1, so an attacker can't reach us with a
44
+ * loopback Origin unless they're already on the box. DNS rebinding
45
+ * can't forge `127.0.0.1` as a hostname — that resolves before
46
+ * `fetch()` ever issues the request.
47
+ *
48
+ * Returns true when the request should proceed, false when it should
49
+ * be rejected with 403.
50
+ */
51
+ const LOOPBACK_ORIGIN_RE = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
52
+ export function isOriginAllowed(req, allowedOrigins, allowLoopback) {
53
+ const origin = req.headers['origin'];
54
+ if (!origin) return true;
55
+ if (allowLoopback && LOOPBACK_ORIGIN_RE.test(origin)) return true;
56
+ if (!allowedOrigins || allowedOrigins.length === 0) return false;
57
+ return allowedOrigins.includes(origin);
58
+ }
@@ -0,0 +1,30 @@
1
+ // Cost-cap enforcement + per-handler metrics accumulation for the daemon.
2
+ // Pure — operates only on the passed-in metrics/costCap objects.
3
+
4
+ // Has the cumulative cost in any capped currency reached the cap?
5
+ // Returns the offending currency + amount + cap so the caller can
6
+ // surface it cleanly, or null when no cap is breached.
7
+ export function checkCostCap(metrics, costCap) {
8
+ if (!costCap) return null;
9
+ for (const [cur, cap] of Object.entries(costCap)) {
10
+ if (!Number.isFinite(cap) || cap <= 0) continue;
11
+ const spent = metrics.costsByCurrency[cur] || 0;
12
+ if (spent >= cap) return { currency: cur, spent: Math.round(spent * 1_000_000) / 1_000_000, cap };
13
+ }
14
+ return null;
15
+ }
16
+
17
+ // Bump per-handler metrics from a single request's cost+usage. Keys
18
+ // cost by currency so heterogeneous fleets (USD-priced anthropic, EUR
19
+ // regional contracts) don't silently sum mismatched numbers. Tokens
20
+ // are unit-free → single counter.
21
+ export function accumulateMetricsFromCost(metrics, usage, cost) {
22
+ if (cost && Number.isFinite(cost.cost)) {
23
+ const cur = cost.currency || 'USD';
24
+ metrics.costsByCurrency[cur] = (metrics.costsByCurrency[cur] || 0) + cost.cost;
25
+ }
26
+ if (usage) {
27
+ if (Number.isFinite(usage.inputTokens)) metrics.tokensTotal.inputTokens += usage.inputTokens;
28
+ if (Number.isFinite(usage.outputTokens)) metrics.tokensTotal.outputTokens += usage.outputTokens;
29
+ }
30
+ }
@@ -0,0 +1,69 @@
1
+ // Provider resolution for the daemon: composes the base provider with
2
+ // opt-in cache / fallback / retry wrappers per request.
3
+
4
+ import { PROVIDERS } from '../../providers/registry.mjs';
5
+ import { withRateLimitRetry } from '../../providers/retry.mjs';
6
+ import { withFallback } from '../../providers/fallback.mjs';
7
+ import { withResponseCache } from '../../providers/cache.mjs';
8
+
9
+ // Resolve the provider for a request. Composes opt-in wrappers in this
10
+ // order (innermost first):
11
+ // 1. cache — wraps the base provider so cache hits never trigger
12
+ // fallback or retry (a hit is a successful response).
13
+ // 2. fallback — chain of alternates; pre-yield recoverable errors fall
14
+ // through; mid-stream errors bubble.
15
+ // 3. retry — outermost so each retry covers the full chain (retry
16
+ // exhausts → 429 to client).
17
+ // `cachedByName` is a per-handler Map shared across requests so the
18
+ // cache state actually persists between calls. Without that the cache
19
+ // would be empty on every request.
20
+ //
21
+ // Returns { provider } on success or { error } when the primary or any
22
+ // listed fallback name is unknown.
23
+ export function resolveProvider(body, providerName, cachedByName, logger) {
24
+ if (!PROVIDERS[providerName]) return { error: `unknown provider: ${providerName}` };
25
+ // The decorator callbacks emit one debug line each — useful for ops who
26
+ // set --log debug to diagnose why a request is slow or which provider
27
+ // actually served it. With the default level (info) these are silent.
28
+ const dbg = (msg, fields) => { if (logger) logger.debug(msg, fields); };
29
+ const wrapWithCache = (name) => {
30
+ if (!cachedByName) return PROVIDERS[name];
31
+ if (!cachedByName.has(name)) {
32
+ cachedByName.set(name, withResponseCache(PROVIDERS[name], {
33
+ maxEntries: cachedByName._opts?.maxEntries,
34
+ ttlMs: cachedByName._opts?.ttlMs,
35
+ onHit: ({ keyHash, size }) => dbg('cache.hit', { provider: name, keyHash: keyHash.slice(0, 12), size }),
36
+ onMiss: ({ keyHash }) => dbg('cache.miss', { provider: name, keyHash: keyHash.slice(0, 12) }),
37
+ }));
38
+ }
39
+ return cachedByName.get(name);
40
+ };
41
+ // Cache only when the request explicitly opts in. The handler-level
42
+ // Map is shared so two requests with body.cache=true to the same base
43
+ // provider hit the same cache.
44
+ const useCache = !!body?.cache;
45
+ let prov = useCache ? wrapWithCache(providerName) : PROVIDERS[providerName];
46
+ if (Array.isArray(body?.fallback) && body.fallback.length > 0) {
47
+ const chain = [prov];
48
+ for (const name of body.fallback) {
49
+ if (!PROVIDERS[name]) return { error: `unknown fallback provider: ${name}` };
50
+ chain.push(useCache ? wrapWithCache(name) : PROVIDERS[name]);
51
+ }
52
+ prov = withFallback(chain, {
53
+ onFallback: ({ from, to, err }) => dbg('provider.fallback', {
54
+ from, to, errorCode: err?.code || null, errorMsg: String(err?.message || err).slice(0, 120),
55
+ }),
56
+ });
57
+ }
58
+ const r = body?.retry;
59
+ if (r && Number.isFinite(r.attempts) && r.attempts > 0) {
60
+ prov = withRateLimitRetry(prov, {
61
+ attempts: r.attempts,
62
+ maxBackoffMs: r.maxBackoffMs,
63
+ onRetry: ({ attempt, retryAfterMs, err }) => dbg('provider.retry', {
64
+ attempt, retryAfterMs, errorCode: err?.code || null,
65
+ }),
66
+ });
67
+ }
68
+ return { provider: prov };
69
+ }
@@ -0,0 +1,83 @@
1
+ // HTTP request/response helpers for the daemon: body readers, JSON/SSE
2
+ // writers, provider-error status mapping, and a small fs existence check.
3
+ // Pure — no daemon state — so any route module can import these freely.
4
+
5
+ import fs from 'node:fs';
6
+
7
+ export async function fileExists(p) {
8
+ try { await fs.promises.access(p); return true; }
9
+ catch { return false; }
10
+ }
11
+
12
+ export function readJson(req) {
13
+ return new Promise((resolve, reject) => {
14
+ let buf = '';
15
+ req.setEncoding('utf8');
16
+ req.on('data', d => { buf += d; if (buf.length > 5 * 1024 * 1024) { reject(new Error('body too large')); req.destroy(); } });
17
+ req.on('end', () => {
18
+ if (!buf) return resolve({});
19
+ try { resolve(JSON.parse(buf)); }
20
+ catch (e) { reject(new Error(`invalid JSON body: ${e.message}`)); }
21
+ });
22
+ req.on('error', reject);
23
+ });
24
+ }
25
+
26
+ // Raw body reader — used for `PUT /skills/<name>` where the body is
27
+ // markdown rather than JSON. Same 1 MiB cap as the CLI's `--from-url`
28
+ // path so HTTP can't sneak past the safeguard the CLI enforces.
29
+ export const SKILL_MAX_BYTES = 1_048_576;
30
+ export function readTextBody(req, maxBytes = SKILL_MAX_BYTES) {
31
+ return new Promise((resolve, reject) => {
32
+ let buf = '';
33
+ req.setEncoding('utf8');
34
+ req.on('data', d => {
35
+ buf += d;
36
+ if (buf.length > maxBytes) {
37
+ reject(new Error(`body exceeds ${maxBytes} bytes`));
38
+ req.destroy();
39
+ }
40
+ });
41
+ req.on('end', () => resolve(buf));
42
+ req.on('error', reject);
43
+ });
44
+ }
45
+
46
+ export function writeJson(res, status, obj, extraHeaders = {}) {
47
+ const body = JSON.stringify(obj);
48
+ res.writeHead(status, {
49
+ 'content-type': 'application/json; charset=utf-8',
50
+ 'content-length': Buffer.byteLength(body),
51
+ ...extraHeaders,
52
+ });
53
+ res.end(body);
54
+ }
55
+
56
+ // Map provider error codes to HTTP statuses so clients can branch on
57
+ // res.status instead of parsing error messages. Returns
58
+ // { status, headers? } so 429 can attach a Retry-After.
59
+ //
60
+ // Exported for unit testing without spinning up an actual provider that
61
+ // would only fail under live network conditions.
62
+ export function statusForProviderError(err) {
63
+ if (err?.code === 'INVALID_KEY') return { status: 401 };
64
+ if (err?.code === 'RATE_LIMIT') {
65
+ const retrySeconds = Math.max(1, Math.ceil((err.retryAfterMs || 1000) / 1000));
66
+ return { status: 429, headers: { 'retry-after': String(retrySeconds) } };
67
+ }
68
+ if (err?.status && err.status >= 400 && err.status < 600) return { status: err.status };
69
+ return { status: 502 };
70
+ }
71
+
72
+ export function writeSseHead(res) {
73
+ res.writeHead(200, {
74
+ 'content-type': 'text/event-stream; charset=utf-8',
75
+ 'cache-control': 'no-cache, no-transform',
76
+ 'connection': 'close',
77
+ });
78
+ }
79
+
80
+ export function writeSse(res, event, data) {
81
+ if (event) res.write(`event: ${event}\n`);
82
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
83
+ }
@@ -0,0 +1,96 @@
1
+ // Ordered route table for the daemon. The dispatch loop in makeHandler
2
+ // walks this array top-to-bottom and runs the first entry whose `m`
3
+ // predicate matches the request — FIRST MATCH WINS.
4
+ //
5
+ // The order here is IDENTICAL to the original makeHandler `switch (true)`
6
+ // case order. Several predicates are exclusion-based (e.g.
7
+ // `providerMatch[1] !== 'test'`, `configKeyMatch[1] !== 'validate'`) and
8
+ // rely on that ordering to stay correct. DO NOT REORDER.
9
+ //
10
+ // `m(c)` receives the per-request dispatch context built in makeHandler
11
+ // (route/method/url + the pre-computed path-param regex matches). `h(c)`
12
+ // is the matching route handler.
13
+
14
+ import * as meta from './routes/meta.mjs';
15
+ import * as providers from './routes/providers.mjs';
16
+ import * as rates from './routes/rates.mjs';
17
+ import * as config from './routes/config.mjs';
18
+ import * as sessions from './routes/sessions.mjs';
19
+ import * as workflows from './routes/workflows.mjs';
20
+ import * as skills from './routes/skills.mjs';
21
+ import * as conversation from './routes/conversation.mjs';
22
+ import * as registry from './routes/registry.mjs';
23
+ import * as ops from './routes/ops.mjs';
24
+
25
+ export const ROUTES = [
26
+ { m: (c) => c.route === 'GET /' || c.route === 'GET /dashboard', h: meta.dashboard },
27
+ { m: (c) => c.route === 'GET /dashboard.css', h: meta.dashboardCss },
28
+ { m: (c) => c.route === 'GET /dashboard.js', h: meta.dashboardJs },
29
+ { m: (c) => c.route === 'GET /version', h: meta.version },
30
+ { m: (c) => c.route === 'POST /exec/request', h: conversation.execRequest },
31
+ { m: (c) => c.route === 'GET /health' || c.route === 'GET /healthz', h: meta.health },
32
+ { m: (c) => c.route === 'GET /metrics', h: meta.metrics },
33
+ { m: (c) => c.route === 'GET /providers', h: providers.providersList },
34
+ { m: (c) => c.req.method === 'GET' && !!c.providerMatch && c.providerMatch[1] !== 'test', h: providers.providerGet },
35
+ { m: (c) => c.route === 'GET /providers/test', h: providers.providersTest },
36
+ { m: (c) => c.req.method === 'GET' && !!c.providerTestMatch, h: providers.providerTest },
37
+ { m: (c) => c.route === 'POST /providers', h: providers.providersCreate },
38
+ { m: (c) => c.req.method === 'DELETE' && !!c.providerMatch && c.providerMatch[1] !== 'test', h: providers.providerDelete },
39
+ { m: (c) => c.route === 'GET /rates', h: rates.ratesList },
40
+ { m: (c) => c.route === 'GET /rates/validate', h: rates.ratesValidate },
41
+ { m: (c) => c.route === 'GET /rates/shape', h: rates.ratesShape },
42
+ { m: (c) => c.req.method === 'PUT' && !!c.ratesKeyMatch && c.ratesKeyMatch[1] !== 'validate' && c.ratesKeyMatch[1] !== 'shape', h: rates.ratePut },
43
+ { m: (c) => c.req.method === 'DELETE' && !!c.ratesKeyMatch && c.ratesKeyMatch[1] !== 'validate' && c.ratesKeyMatch[1] !== 'shape', h: rates.rateDelete },
44
+ { m: (c) => c.route === 'GET /status', h: meta.status },
45
+ { m: (c) => c.route === 'GET /config/validate', h: config.configValidate },
46
+ { m: (c) => c.route === 'GET /config', h: config.configGet },
47
+ { m: (c) => c.req.method === 'GET' && !!c.configKeyMatch && c.configKeyMatch[1] !== 'validate', h: config.configKeyGet },
48
+ { m: (c) => c.req.method === 'PUT' && !!c.configKeyMatch && c.configKeyMatch[1] !== 'validate', h: config.configKeyPut },
49
+ { m: (c) => c.req.method === 'DELETE' && !!c.configKeyMatch && c.configKeyMatch[1] !== 'validate', h: config.configKeyDelete },
50
+ { m: (c) => c.route === 'GET /doctor', h: meta.doctor },
51
+ { m: (c) => c.route === 'GET /sessions', h: sessions.sessionsList },
52
+ { m: (c) => c.route === 'GET /sessions/search', h: sessions.sessionsSearch },
53
+ { m: (c) => c.req.method === 'GET' && !!c.sessionExportMatch, h: sessions.sessionExport },
54
+ { m: (c) => c.req.method === 'GET' && !!c.sessionMatch, h: sessions.sessionGet },
55
+ { m: (c) => c.route === 'GET /workflows/aggregate', h: workflows.workflowsAggregate },
56
+ { m: (c) => c.route === 'GET /workflows', h: workflows.workflowsList },
57
+ { m: (c) => c.req.method === 'GET' && !!c.workflowMatch, h: workflows.workflowGet },
58
+ { m: (c) => c.route === 'GET /skills', h: skills.skillsList },
59
+ { m: (c) => c.route === 'GET /skills/suggestions', h: skills.skillsSuggestions },
60
+ { m: (c) => c.route === 'POST /skills/synth', h: skills.skillsSynth },
61
+ { m: (c) => c.route === 'GET /skills/search', h: skills.skillsSearch },
62
+ { m: (c) => c.req.method === 'GET' && !!c.skillMatch, h: skills.skillGet },
63
+ { m: (c) => c.req.method === 'PUT' && !!c.skillMatch, h: skills.skillPut },
64
+ { m: (c) => c.req.method === 'DELETE' && !!c.skillMatch, h: skills.skillDelete },
65
+ { m: (c) => c.req.method === 'DELETE' && !!c.sessionMatch, h: sessions.sessionDelete },
66
+ { m: (c) => c.req.method === 'DELETE' && !!c.workflowMatch, h: workflows.workflowDelete },
67
+ { m: (c) => c.route === 'POST /chat', h: conversation.chat },
68
+ { m: (c) => c.route === 'POST /inbound', h: conversation.inbound },
69
+ { m: (c) => c.route === 'POST /handoff', h: conversation.handoff },
70
+ { m: (c) => c.route === 'POST /agent', h: conversation.agent },
71
+ { m: (c) => c.route === 'GET /agents', h: registry.agentsList },
72
+ { m: (c) => c.route === 'POST /agents', h: registry.agentsCreate },
73
+ { m: (c) => c.req.method === 'GET' && /^\/agents\/([^/]+)$/.test(c.url.pathname), h: registry.agentGet },
74
+ { m: (c) => c.req.method === 'PATCH' && /^\/agents\/([^/]+)$/.test(c.url.pathname), h: registry.agentPatch },
75
+ { m: (c) => c.req.method === 'DELETE' && /^\/agents\/([^/]+)$/.test(c.url.pathname), h: registry.agentDelete },
76
+ { m: (c) => c.req.method === 'GET' && /^\/agents\/([^/]+)\/memory$/.test(c.url.pathname), h: registry.agentMemoryGet },
77
+ { m: (c) => c.req.method === 'PUT' && /^\/agents\/([^/]+)\/memory$/.test(c.url.pathname), h: registry.agentMemoryPut },
78
+ { m: (c) => c.req.method === 'DELETE' && /^\/agents\/([^/]+)\/memory$/.test(c.url.pathname), h: registry.agentMemoryDelete },
79
+ { m: (c) => c.route === 'GET /teams', h: registry.teamsList },
80
+ { m: (c) => c.route === 'POST /teams', h: registry.teamsCreate },
81
+ { m: (c) => c.req.method === 'GET' && /^\/teams\/([^/]+)$/.test(c.url.pathname), h: registry.teamGet },
82
+ { m: (c) => c.req.method === 'PATCH' && /^\/teams\/([^/]+)$/.test(c.url.pathname), h: registry.teamPatch },
83
+ { m: (c) => c.req.method === 'DELETE' && /^\/teams\/([^/]+)$/.test(c.url.pathname), h: registry.teamDelete },
84
+ { m: (c) => c.route === 'GET /tasks', h: registry.tasksList },
85
+ { m: (c) => c.req.method === 'GET' && /^\/tasks\/([^/]+)\/transcript$/.test(c.url.pathname), h: registry.taskTranscript },
86
+ { m: (c) => c.req.method === 'GET' && /^\/tasks\/([^/]+)$/.test(c.url.pathname), h: registry.taskGet },
87
+ { m: (c) => c.req.method === 'DELETE' && /^\/tasks\/([^/]+)$/.test(c.url.pathname), h: registry.taskDelete },
88
+ { m: (c) => c.req.method === 'POST' && /^\/tasks\/([^/]+)\/(done|abandon)$/.test(c.url.pathname), h: registry.taskAction },
89
+ { m: (c) => c.route === 'GET /trainer/status', h: ops.trainerStatus },
90
+ { m: (c) => c.route === 'GET /recall', h: ops.recall },
91
+ { m: (c) => c.route === 'GET /sandbox', h: ops.sandboxList },
92
+ { m: (c) => c.req.method === 'POST' && /^\/sandbox\/([^/]+)\/test$/.test(c.url.pathname), h: ops.sandboxTest },
93
+ { m: (c) => c.route === 'POST /sandbox/use', h: ops.sandboxUse },
94
+ { m: (c) => c.route === 'GET /channels', h: ops.channels },
95
+ { m: (c) => c.route === 'POST /index/rebuild', h: ops.indexRebuild },
96
+ ];
@@ -0,0 +1,36 @@
1
+ // Dependency barrel for daemon route modules. Re-exports every
2
+ // module-level helper/import that route handler bodies reference, so each
3
+ // route module can pull them with a single import and the handler bodies
4
+ // stay byte-identical to their original form in makeHandler.
5
+
6
+ import fs from 'node:fs';
7
+ import nodePath from 'node:path';
8
+ export { fs, nodePath };
9
+
10
+ export { PROVIDERS, PROVIDER_INFO, maskApiKey } from '../../providers/registry.mjs';
11
+ export { costFromUsage, RATE_CARD_SHAPE } from '../../providers/rates.mjs';
12
+ export {
13
+ composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill,
14
+ removeSkill, parseFrontmatter, defaultConfigDir as skillsDefaultConfigDir,
15
+ } from '../../skills.mjs';
16
+ export * as indexDb from '../../mas/index_db.mjs';
17
+ export * as skillSynth from '../../mas/skill_synth.mjs';
18
+ export { listBackends as sandboxListBackends } from '../../sandbox/index.mjs';
19
+ export {
20
+ summarizeState, listSessions as listWorkflowSessions,
21
+ loadStateFile as loadWorkflowState, aggregateNodeStats,
22
+ } from '../../workflow/summary.mjs';
23
+ export { validateConfig } from '../../config-validate.mjs';
24
+ export { validateRates } from '../../rates-validate.mjs';
25
+
26
+ export {
27
+ fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse,
28
+ statusForProviderError,
29
+ } from '../lib/respond.mjs';
30
+ export { checkCostCap, accumulateMetricsFromCost } from '../lib/cost.mjs';
31
+ export { resolveProvider } from '../lib/provider.mjs';
32
+ // F5/F6 — cross-channel handoff: the threads store + the rollback-aware
33
+ // migration helper, so the conversation routes can bind inbound messages to a
34
+ // persistent thread/session and re-point them across channels.
35
+ export { openThreads } from '../../channels/threads.mjs';
36
+ export { handoffWithRollback } from '../../channels/handoff.mjs';
@@ -0,0 +1,99 @@
1
+ // Daemon route handlers (config), extracted verbatim from makeHandler (D5).
2
+ // Each handler takes the per-request dispatch context `c` and returns the
3
+ // HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
4
+ import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
5
+
6
+ export async function configValidate(c) {
7
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
8
+ // Mirror of v3.39's `lazyclaw config validate`. Same shape
9
+ // (single source of truth in config-validate.mjs). HTTP
10
+ // status reflects ok/issues so a UI's "config status"
11
+ // badge can branch on HTTP code: 200 = ok, 422 = issues
12
+ // (Unprocessable Entity — semantically right for "the
13
+ // config you supplied is malformed").
14
+ const cfg = ctx.readConfig();
15
+ const { ok, issues, warnings } = validateConfig(cfg, PROVIDERS);
16
+ return writeJson(res, ok ? 200 : 422, {
17
+ ok,
18
+ keys: Object.keys(cfg),
19
+ issues,
20
+ warnings,
21
+ });
22
+ }
23
+
24
+ export async function configGet(c) {
25
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
26
+ // Mirror of `lazyclaw config list`. Returns every stored key
27
+ // with the api-key value masked — lets a dashboard or script
28
+ // inspect the active configuration without shelling to the CLI.
29
+ const cfg = ctx.readConfig();
30
+ const safe = { ...cfg };
31
+ if (safe['api-key']) safe['api-key'] = maskApiKey(safe['api-key']);
32
+ return writeJson(res, 200, safe);
33
+ }
34
+
35
+ export async function configKeyGet(c) {
36
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
37
+ // Mirror of `lazyclaw config get <key>`. The `!== 'validate'`
38
+ // guard ensures the literal GET /config/validate case (above)
39
+ // is never shadowed by this dynamic handler.
40
+ const key = configKeyMatch[1];
41
+ const cfg = ctx.readConfig();
42
+ if (!(key in cfg)) {
43
+ return writeJson(res, 404, { error: 'key not found', key });
44
+ }
45
+ const raw = cfg[key];
46
+ const value = key === 'api-key' ? maskApiKey(raw) : raw;
47
+ return writeJson(res, 200, { key, value });
48
+ }
49
+
50
+ export async function configKeyPut(c) {
51
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
52
+ // PUT /config/<key> body: { value: <any> }
53
+ // Mirror of `lazyclaw config set <key> <value>`. Re-validates the
54
+ // whole config after the write so we never persist a broken state.
55
+ // Nested cargo (customProviders / rates / authProfiles) goes
56
+ // through its own dedicated endpoint — guarded here so a
57
+ // dashboard PUT can't bypass schema validation.
58
+ if (typeof ctx.writeConfig !== 'function') {
59
+ return writeJson(res, 405, { error: 'mutation disabled' });
60
+ }
61
+ const key = configKeyMatch[1];
62
+ if (key === 'customProviders' || key === 'rates' || key === 'authProfiles') {
63
+ return writeJson(res, 400, {
64
+ error: `use the dedicated endpoint for "${key}" — POST /providers · PUT /rates/<key> · authProfiles via CLI`,
65
+ });
66
+ }
67
+ let body;
68
+ try { body = await readJson(req); }
69
+ catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
70
+ const value = body && Object.prototype.hasOwnProperty.call(body, 'value') ? body.value : undefined;
71
+ const cfg = ctx.readConfig();
72
+ if (value === null || value === undefined) delete cfg[key];
73
+ else cfg[key] = value;
74
+ const v = validateConfig(cfg, PROVIDERS);
75
+ if (!v.ok) return writeJson(res, 422, v);
76
+ ctx.writeConfig(cfg);
77
+ const stored = key === 'api-key' && typeof cfg[key] === 'string' ? maskApiKey(cfg[key]) : cfg[key];
78
+ return writeJson(res, 200, { ok: true, key, value: stored });
79
+ }
80
+
81
+ export async function configKeyDelete(c) {
82
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
83
+ // DELETE /config/<key> — same as `lazyclaw config delete`.
84
+ // Idempotent: 200 with `removed: false` when the key wasn't
85
+ // present.
86
+ if (typeof ctx.writeConfig !== 'function') {
87
+ return writeJson(res, 405, { error: 'mutation disabled' });
88
+ }
89
+ const key = configKeyMatch[1];
90
+ if (key === 'customProviders' || key === 'rates' || key === 'authProfiles') {
91
+ return writeJson(res, 400, { error: `delete via the dedicated endpoint for "${key}"` });
92
+ }
93
+ const cfg = ctx.readConfig();
94
+ if (!(key in cfg)) return writeJson(res, 200, { ok: true, key, removed: false });
95
+ delete cfg[key];
96
+ ctx.writeConfig(cfg);
97
+ return writeJson(res, 200, { ok: true, key, removed: true });
98
+ }
99
+