lazyclaw 6.3.0 → 6.4.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 (165) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +6 -1
  10. package/commands/auth_nodes.mjs +19 -1
  11. package/commands/automation.mjs +20 -5
  12. package/commands/channels.mjs +46 -3
  13. package/commands/chat.mjs +95 -37
  14. package/commands/chat_hardening.mjs +23 -0
  15. package/commands/daemon.mjs +99 -1
  16. package/commands/gateway.mjs +123 -4
  17. package/commands/mcp.mjs +150 -0
  18. package/commands/misc.mjs +20 -0
  19. package/commands/sessions.mjs +68 -8
  20. package/commands/setup.mjs +44 -8
  21. package/commands/setup_channels.mjs +219 -47
  22. package/commands/skills.mjs +19 -1
  23. package/commands/workflow_named.mjs +137 -0
  24. package/config-validate.mjs +10 -1
  25. package/config_features.mjs +45 -0
  26. package/cron.mjs +19 -8
  27. package/daemon/lib/auth.mjs +25 -0
  28. package/daemon/lib/cost.mjs +36 -0
  29. package/daemon/lib/respond.mjs +20 -1
  30. package/daemon/lib/team_inbound.mjs +69 -0
  31. package/daemon/route_table.mjs +5 -1
  32. package/daemon/routes/_deps.mjs +2 -2
  33. package/daemon/routes/config.mjs +11 -6
  34. package/daemon/routes/conversation.mjs +103 -40
  35. package/daemon/routes/events.mjs +45 -0
  36. package/daemon/routes/meta.mjs +38 -7
  37. package/daemon/routes/providers.mjs +26 -6
  38. package/daemon/routes/workflows.mjs +30 -0
  39. package/daemon.mjs +26 -2
  40. package/dotenv_min.mjs +15 -3
  41. package/gateway/challenge_registry.mjs +90 -0
  42. package/gateway/device_auth.mjs +71 -97
  43. package/gateway/http_gateway.mjs +29 -3
  44. package/lib/args.mjs +69 -3
  45. package/lib/config.mjs +85 -3
  46. package/lib/render.mjs +43 -0
  47. package/lib/service_install.mjs +15 -1
  48. package/mas/agent_turn.mjs +61 -12
  49. package/mas/confidence.mjs +20 -1
  50. package/mas/embedder.mjs +100 -0
  51. package/mas/events.mjs +57 -0
  52. package/mas/index_db.mjs +103 -10
  53. package/mas/learning.mjs +96 -72
  54. package/mas/mention_router.mjs +48 -90
  55. package/mas/prompt_stack.mjs +60 -3
  56. package/mas/recall_blend.mjs +56 -0
  57. package/mas/redact.mjs +55 -0
  58. package/mas/router_posting.mjs +96 -0
  59. package/mas/router_termination.mjs +63 -0
  60. package/mas/scrub_env.mjs +21 -6
  61. package/mas/skill_synth.mjs +7 -33
  62. package/mas/tool_runner.mjs +3 -2
  63. package/mas/tools/bash.mjs +9 -2
  64. package/mas/tools/coding.mjs +28 -5
  65. package/mas/tools/delegation.mjs +34 -4
  66. package/mas/tools/git.mjs +19 -9
  67. package/mas/tools/ha.mjs +2 -0
  68. package/mas/tools/learning.mjs +55 -0
  69. package/mas/tools/media.mjs +21 -3
  70. package/mas/tools/os.mjs +70 -16
  71. package/mas/tools/recall.mjs +28 -2
  72. package/mcp/client.mjs +8 -1
  73. package/mcp/server_spawn.mjs +5 -1
  74. package/memory.mjs +24 -0
  75. package/package.json +3 -1
  76. package/providers/anthropic.mjs +101 -6
  77. package/providers/cache.mjs +9 -1
  78. package/providers/claude_cli.mjs +104 -22
  79. package/providers/claude_cli_session.mjs +166 -0
  80. package/providers/cli_error.mjs +38 -0
  81. package/providers/cli_login.mjs +179 -0
  82. package/providers/codex_cli.mjs +66 -11
  83. package/providers/gemini.mjs +101 -3
  84. package/providers/gemini_cli.mjs +62 -15
  85. package/providers/model_catalogue.mjs +33 -2
  86. package/providers/ollama.mjs +104 -3
  87. package/providers/openai.mjs +110 -8
  88. package/providers/openai_compat.mjs +97 -6
  89. package/providers/orchestrator.mjs +112 -12
  90. package/providers/registry.mjs +15 -9
  91. package/providers/retry.mjs +14 -2
  92. package/providers/tool_use/anthropic.mjs +17 -3
  93. package/providers/tool_use/claude_cli.mjs +72 -20
  94. package/providers/tool_use/gemini.mjs +18 -2
  95. package/providers/tool_use/openai.mjs +28 -5
  96. package/sandbox/confiners/seatbelt.mjs +37 -12
  97. package/sandbox/index.mjs +49 -0
  98. package/sandbox/local.mjs +7 -1
  99. package/sandbox/spawn.mjs +144 -0
  100. package/sandbox.mjs +5 -1
  101. package/scripts/loop-worker.mjs +25 -1
  102. package/sessions.mjs +0 -0
  103. package/skills/channel-style.md +20 -0
  104. package/skills/code-review.md +33 -0
  105. package/skills/commit-message.md +30 -0
  106. package/skills/concise.md +24 -0
  107. package/skills/debug-coach.md +25 -0
  108. package/skills/explain.md +24 -0
  109. package/skills/korean.md +25 -0
  110. package/skills/summarize.md +33 -0
  111. package/skills.mjs +24 -2
  112. package/skills_curator.mjs +6 -0
  113. package/skills_install.mjs +10 -2
  114. package/tasks.mjs +6 -1
  115. package/teams.mjs +78 -0
  116. package/tui/chat_mode_slash.mjs +59 -0
  117. package/tui/config_picker.mjs +1 -0
  118. package/tui/editor.mjs +178 -151
  119. package/tui/editor_keys.mjs +275 -0
  120. package/tui/hud.mjs +111 -0
  121. package/tui/login_flow.mjs +113 -0
  122. package/tui/modal_picker.mjs +10 -1
  123. package/tui/model_pick.mjs +287 -0
  124. package/tui/orchestrator_flow.mjs +164 -0
  125. package/tui/pickers.mjs +210 -95
  126. package/tui/repl.mjs +106 -44
  127. package/tui/repl_reset.mjs +37 -0
  128. package/tui/run_turn.mjs +228 -26
  129. package/tui/slash_args.mjs +159 -0
  130. package/tui/slash_commands.mjs +7 -5
  131. package/tui/slash_dispatcher.mjs +537 -344
  132. package/tui/slash_popup.mjs +5 -1
  133. package/tui/splash.mjs +15 -2
  134. package/tui/status_bar.mjs +26 -0
  135. package/tui/theme.mjs +28 -0
  136. package/web/avatars/01.png +0 -0
  137. package/web/avatars/02.png +0 -0
  138. package/web/avatars/03.png +0 -0
  139. package/web/avatars/04.png +0 -0
  140. package/web/avatars/05.png +0 -0
  141. package/web/avatars/06.png +0 -0
  142. package/web/avatars/07.png +0 -0
  143. package/web/avatars/08.png +0 -0
  144. package/web/avatars/09.png +0 -0
  145. package/web/avatars/10.png +0 -0
  146. package/web/avatars/11.png +0 -0
  147. package/web/avatars/12.png +0 -0
  148. package/web/avatars/13.png +0 -0
  149. package/web/avatars/14.png +0 -0
  150. package/web/avatars/15.png +0 -0
  151. package/web/avatars/16.png +0 -0
  152. package/web/avatars/17.png +0 -0
  153. package/web/avatars/18.png +0 -0
  154. package/web/avatars/19.png +0 -0
  155. package/web/avatars/20.png +0 -0
  156. package/web/dashboard.css +77 -0
  157. package/web/dashboard.html +29 -2
  158. package/web/dashboard.js +296 -33
  159. package/workflow/builtin_caps.mjs +94 -0
  160. package/workflow/declarative.mjs +101 -0
  161. package/workflow/named.mjs +71 -0
  162. package/workflow/named_cron.mjs +50 -0
  163. package/workflow/nodes.mjs +67 -0
  164. package/workflow/run_request.mjs +74 -0
  165. package/workflow/yaml_min.mjs +97 -0
@@ -74,30 +74,50 @@ export async function providersTest(c) {
74
74
  const cfg = ctx.readConfig();
75
75
  const apiKey = cfg['api-key'] || '';
76
76
  const sharedPrompt = url.searchParams.get('prompt') || 'ping';
77
+ // Per-provider timeout so one unreachable provider (a keyless
78
+ // claude-cli subprocess that never logs in, a dead network endpoint)
79
+ // can't hang the whole all-providers probe — without it the route
80
+ // blocks until every sendMessage settles. Override with ?timeoutMs=;
81
+ // floor 1000, default 8000.
82
+ const _tm = parseInt(url.searchParams.get('timeoutMs') || '', 10);
83
+ const perTimeoutMs = Number.isFinite(_tm) && _tm >= 1000 ? _tm : 8000;
77
84
  const tAll = Date.now();
78
85
  const results = await Promise.all(
79
86
  Object.entries(PROVIDERS).map(async ([pid, provider]) => {
80
87
  const meta = PROVIDER_INFO[pid] || {};
81
88
  const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
82
89
  const t0 = Date.now();
90
+ const ac = new AbortController();
91
+ let timer = null;
83
92
  try {
84
- let reply = '';
85
- const stream = provider.sendMessage([{ role: 'user', content: sharedPrompt }], { apiKey, model });
86
- for await (const chunk of stream) {
87
- if (typeof chunk === 'string') reply += chunk;
88
- }
93
+ const consume = (async () => {
94
+ let reply = '';
95
+ const stream = provider.sendMessage([{ role: 'user', content: sharedPrompt }], { apiKey, model, signal: ac.signal });
96
+ for await (const chunk of stream) {
97
+ if (typeof chunk === 'string') reply += chunk;
98
+ }
99
+ return reply;
100
+ })();
101
+ const timeout = new Promise((_, reject) => {
102
+ timer = setTimeout(() => { try { ac.abort(); } catch { /* best-effort */ } reject(new Error(`timed out after ${perTimeoutMs}ms`)); }, perTimeoutMs);
103
+ });
104
+ const reply = await Promise.race([consume, timeout]);
89
105
  return {
90
106
  name: pid, ok: reply.length > 0, model,
91
107
  durationMs: Date.now() - t0,
92
108
  replyLength: reply.length,
93
109
  };
94
110
  } catch (err) {
111
+ try { ac.abort(); } catch { /* best-effort */ }
112
+ const timedOut = /timed out after/.test(err?.message || '');
95
113
  return {
96
114
  name: pid, ok: false, model,
97
115
  durationMs: Date.now() - t0,
98
116
  error: err?.message || String(err),
99
- code: err?.code || null,
117
+ code: timedOut ? 'TIMEOUT' : (err?.code || null),
100
118
  };
119
+ } finally {
120
+ if (timer) clearTimeout(timer);
101
121
  }
102
122
  }),
103
123
  );
@@ -222,3 +222,33 @@ export async function workflowDelete(c) {
222
222
  }
223
223
  }
224
224
 
225
+
226
+ // POST /workflows/run — run a DECLARATIVE workflow (data, not code) safely.
227
+ // The definition comes from the body ({workflow:{...}} or the def directly);
228
+ // caps are derived from the daemon config (http SSRF-guarded + the configured
229
+ // llm provider), never from the workflow, so a posted workflow can't spawn a
230
+ // process or reach private hosts. Auth-gated like every non-gateway route.
231
+ export async function workflowRun(c) {
232
+ const { ctx, req, res } = c;
233
+ let body;
234
+ try { body = await readJson(req); }
235
+ catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
236
+ const def = (body && typeof body === 'object' && body.workflow) ? body.workflow : body;
237
+ const cfg = ctx.readConfig();
238
+ try {
239
+ const { runDeclarativeRequest } = await import('../../workflow/run_request.mjs');
240
+ // A body.sessionId opts into the persisted/resumable engine, keyed under the
241
+ // daemon's workflow-state dir — re-POSTing the same sessionId resumes.
242
+ const sessionId = (body && typeof body === 'object' && typeof body.sessionId === 'string' && body.sessionId) ? body.sessionId : undefined;
243
+ const out = await runDeclarativeRequest(def, cfg, {
244
+ providerLookup: (name) => PROVIDERS[name] || null,
245
+ input: body && typeof body === 'object' ? body.input : undefined,
246
+ ...(sessionId ? { sessionId, dir: ctx.workflowStateDir() } : {}),
247
+ });
248
+ return writeJson(res, out.success ? 200 : 500, out);
249
+ } catch (e) {
250
+ // WorkflowError (bad definition) → 400; anything else → 500.
251
+ const code = e?.code && String(e.code).startsWith('WF_') ? 400 : 500;
252
+ return writeJson(res, code, { error: e?.message || String(e), code: e?.code });
253
+ }
254
+ }
package/daemon.mjs CHANGED
@@ -18,6 +18,12 @@ import { createGateway } from './gateway/http_gateway.mjs';
18
18
  import { TokenBucketLimiter } from './ratelimit.mjs';
19
19
  import { createLogger } from './logger.mjs';
20
20
  import * as nudge from './mas/nudge.mjs';
21
+ // MCP: cfg.mcp.servers[] are spawned once per daemon process at boot and
22
+ // stopped on graceful shutdown. Booted here (the single per-process seam,
23
+ // after config is available) — the unattended-safety guard runs earlier in
24
+ // commands/daemon.mjs before startDaemon, so this only spawns once exposure
25
+ // has been cleared. Best-effort: a failing server is logged, never fatal.
26
+ import * as mcpSpawn from './mcp/server_spawn.mjs';
21
27
 
22
28
  // Route bodies moved to daemon/routes/*; makeHandler now only needs the
23
29
  // response/auth helpers used by the pre-switch middleware + dispatch.
@@ -160,8 +166,26 @@ export function makeHandler(ctx) {
160
166
  },
161
167
  logger,
162
168
  });
163
- process.on('SIGTERM', () => { _nudgeLoop.stop(); });
164
- process.on('SIGINT', () => { _nudgeLoop.stop(); });
169
+ // Boot any configured MCP servers (cfg.mcp.servers[]). Each registers its
170
+ // tools as mcp:<server>:<tool> (sensitive=true approval gate). Fire-and-
171
+ // forget: startConfigured catches per-server, and we swallow the outer
172
+ // promise so a slow/failing spawn never blocks or crashes the daemon.
173
+ (async () => {
174
+ try {
175
+ const cfg = typeof ctx.readConfig === 'function' ? ctx.readConfig() : {};
176
+ const results = await mcpSpawn.startConfigured(cfg);
177
+ for (const r of results) {
178
+ if (r?.ok) logger?.info?.('mcp.server_started', { name: r.name, tools: r.tools?.length ?? 0 });
179
+ else logger?.warn?.('mcp.server_failed', { name: r?.name, err: r?.error });
180
+ }
181
+ } catch (err) {
182
+ logger?.warn?.('mcp.boot_failed', { err: err?.message });
183
+ }
184
+ })();
185
+ // Stop MCP servers on graceful shutdown (unregisters their tools too).
186
+ const _stopMcp = () => { mcpSpawn.stopAll().catch(() => { /* best-effort */ }); };
187
+ process.on('SIGTERM', () => { _nudgeLoop.stop(); _stopMcp(); });
188
+ process.on('SIGINT', () => { _nudgeLoop.stop(); _stopMcp(); });
165
189
  return async function handler(req, res) {
166
190
  // Capture method+path before any handler logic runs; req.url survives
167
191
  // the response but capturing now keeps the log line stable even if a
package/dotenv_min.mjs CHANGED
@@ -6,9 +6,18 @@
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { writeTextSecure } from './secure_write.mjs';
9
+ import { configPath } from './lib/config.mjs';
10
+
11
+ // Resolve the .env directory, falling back to the active config dir. Callers
12
+ // that forward an undefined ctx.cfgDir (the /channels test slash path) used to
13
+ // crash here (path.join(undefined,…)) and have the throw swallowed, silently
14
+ // skipping every credential — so default rather than throw.
15
+ function resolveCfgDir(cfgDir) {
16
+ return cfgDir || path.dirname(configPath());
17
+ }
9
18
 
10
19
  export function loadDotenvIfAny(cfgDir) {
11
- const p = path.join(cfgDir, '.env');
20
+ const p = path.join(resolveCfgDir(cfgDir), '.env');
12
21
  if (!fs.existsSync(p)) return { path: p, loaded: 0 };
13
22
  let loaded = 0;
14
23
  const raw = fs.readFileSync(p, 'utf8');
@@ -28,11 +37,14 @@ export function loadDotenvIfAny(cfgDir) {
28
37
  // (no quoting) — callers pass already-trimmed strings. Returns the path.
29
38
  // Mirror of loadDotenvIfAny so .env read + write live together.
30
39
  export function writeDotenvMerge(cfgDir, vars) {
31
- const p = path.join(cfgDir, '.env');
40
+ const p = path.join(resolveCfgDir(cfgDir), '.env');
32
41
  const lines = [];
33
42
  const seen = new Set();
34
43
  const existing = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
35
- for (const line of existing.split(/\r?\n/)) {
44
+ // Splitting '' yields [''] — one empty element that would be pushed ahead of
45
+ // the vars and surface as a leading blank line in a brand-new .env. Only walk
46
+ // existing lines when there is actual content to preserve.
47
+ for (const line of existing ? existing.split(/\r?\n/) : []) {
36
48
  const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=/);
37
49
  if (m && Object.prototype.hasOwnProperty.call(vars, m[1])) {
38
50
  seen.add(m[1]);
@@ -0,0 +1,90 @@
1
+ // gateway/challenge_registry.mjs — single-use anti-replay challenge ledger.
2
+ //
3
+ // Extracted from device_auth.mjs (which sat at its size ceiling) with ZERO
4
+ // behaviour change. verifyConnect is intentionally pure (it cannot mutate
5
+ // state), so the anti-replay guarantee lives here: every minted challenge can
6
+ // be redeemed AT MOST ONCE and only inside its freshness window. createChallenge
7
+ // + DEFAULT_MAX_SKEW_MS stay in device_auth.mjs (verifyConnect uses them too)
8
+ // and are imported here, so there is one source of truth and no import cycle.
9
+ //
10
+ // Lifecycle per connect:
11
+ // const c = registry.create(); // hand `c` to the client
12
+ // ...client signs payload carrying c.nonce...
13
+ // if (!verifyConnect({ ..., challenge: c, nowMs })) reject;
14
+ // if (!registry.consume(c.nonce, nowMs)) reject; // replay / expiry guard
15
+
16
+ import { createChallenge, DEFAULT_MAX_SKEW_MS } from './device_auth.mjs';
17
+
18
+ export class ChallengeRegistry {
19
+ /**
20
+ * @param {{ maxSkewMs?: number, maxPending?: number, sweepEvery?: number }} [opts]
21
+ */
22
+ constructor({ maxSkewMs = DEFAULT_MAX_SKEW_MS, maxPending = 10000, sweepEvery = 256 } = {}) {
23
+ this.maxSkewMs = maxSkewMs;
24
+ this.maxPending = maxPending;
25
+ this.sweepEvery = sweepEvery;
26
+ this._sinceSweep = 0;
27
+ /** @type {Map<string, number>} nonce -> mint ts (epoch ms) */
28
+ this._pending = new Map();
29
+ }
30
+
31
+ /**
32
+ * Mint and register a fresh challenge. Identical shape to createChallenge().
33
+ * The ledger is self-healing: stale nonces (older than ±maxSkewMs, which can
34
+ * never be consumed anyway) are swept opportunistically, and a hard cap
35
+ * evicts the oldest so an unauthenticated flood of un-redeemed challenges can
36
+ * never grow memory without bound.
37
+ * @returns {{ nonce: string, ts: number }}
38
+ */
39
+ create() {
40
+ const challenge = createChallenge();
41
+ this._pending.set(challenge.nonce, challenge.ts);
42
+ if (++this._sinceSweep >= this.sweepEvery) {
43
+ this._sinceSweep = 0;
44
+ this._sweep(challenge.ts);
45
+ }
46
+ // Hard cap — evict oldest (Map preserves insertion order) until within
47
+ // bound. O(1) amortized per create under a sustained flood.
48
+ while (this._pending.size > this.maxPending) {
49
+ const oldest = this._pending.keys().next().value;
50
+ if (oldest === undefined) break;
51
+ this._pending.delete(oldest);
52
+ }
53
+ return challenge;
54
+ }
55
+
56
+ // Drop entries that can no longer verify (outside ±maxSkewMs of nowMs).
57
+ _sweep(nowMs) {
58
+ for (const [nonce, ts] of this._pending) {
59
+ if (Math.abs(nowMs - ts) > this.maxSkewMs) this._pending.delete(nonce);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Redeem a challenge exactly once. Returns true only when the nonce is known,
65
+ * has NOT been consumed before, and is still within ±maxSkewMs of `nowMs`.
66
+ * Any successful or expired redemption removes the nonce so it can never be
67
+ * replayed.
68
+ *
69
+ * @param {string} nonce
70
+ * @param {number} nowMs
71
+ * @returns {boolean}
72
+ */
73
+ consume(nonce, nowMs) {
74
+ if (typeof nonce !== 'string' || nonce.length === 0) return false;
75
+ if (typeof nowMs !== 'number' || !Number.isFinite(nowMs)) return false;
76
+ const ts = this._pending.get(nonce);
77
+ if (ts === undefined) {
78
+ // Unknown OR already consumed — either way, reject as replay.
79
+ return false;
80
+ }
81
+ // Single-use: drop it now, before any further decision, so a concurrent or
82
+ // repeated call can never redeem the same nonce twice.
83
+ this._pending.delete(nonce);
84
+ const skew = Math.abs(nowMs - ts);
85
+ if (skew > this.maxSkewMs) {
86
+ return false; // expired (stale or implausibly future)
87
+ }
88
+ return true;
89
+ }
90
+ }
@@ -28,7 +28,7 @@ const DEVICES_FILENAME = 'devices.json';
28
28
  const PAYLOAD_VERSION = 'v3';
29
29
  const FIELD_SEP = '|';
30
30
 
31
- const DEFAULT_MAX_SKEW_MS = 120_000;
31
+ export const DEFAULT_MAX_SKEW_MS = 120_000;
32
32
 
33
33
  export function defaultConfigDir() {
34
34
  return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
@@ -116,94 +116,10 @@ export function createChallenge() {
116
116
  };
117
117
  }
118
118
 
119
- /**
120
- * In-memory single-use challenge ledger. verifyConnect is intentionally pure
121
- * (it cannot mutate state), so the anti-replay guarantee lives here: every
122
- * minted challenge can be redeemed AT MOST ONCE and only inside its freshness
123
- * window.
124
- *
125
- * Lifecycle per connect:
126
- * const c = registry.create(); // hand `c` to the client
127
- * ...client signs payload carrying c.nonce...
128
- * if (!verifyConnect({ ..., challenge: c, nowMs })) reject;
129
- * if (!registry.consume(c.nonce, nowMs)) reject; // replay / expiry guard
130
- *
131
- * `nowMs` is injected on consume() (never read from the wall-clock) so the
132
- * skew window is test-drivable and consistent with verifyConnect.
133
- */
134
- export class ChallengeRegistry {
135
- /**
136
- * @param {{ maxSkewMs?: number }} [opts]
137
- */
138
- constructor({ maxSkewMs = DEFAULT_MAX_SKEW_MS, maxPending = 10000, sweepEvery = 256 } = {}) {
139
- this.maxSkewMs = maxSkewMs;
140
- this.maxPending = maxPending;
141
- this.sweepEvery = sweepEvery;
142
- this._sinceSweep = 0;
143
- /** @type {Map<string, number>} nonce -> mint ts (epoch ms) */
144
- this._pending = new Map();
145
- }
146
-
147
- /**
148
- * Mint and register a fresh challenge. Identical shape to createChallenge().
149
- * The ledger is self-healing: stale nonces (older than ±maxSkewMs, which
150
- * can never be consumed anyway) are swept opportunistically, and a hard
151
- * cap evicts the oldest so an unauthenticated flood of un-redeemed
152
- * challenges can never grow memory without bound.
153
- * @returns {{ nonce: string, ts: number }}
154
- */
155
- create() {
156
- const challenge = createChallenge();
157
- this._pending.set(challenge.nonce, challenge.ts);
158
- if (++this._sinceSweep >= this.sweepEvery) {
159
- this._sinceSweep = 0;
160
- this._sweep(challenge.ts);
161
- }
162
- // Hard cap — evict oldest (Map preserves insertion order) until within
163
- // bound. O(1) amortized per create under a sustained flood.
164
- while (this._pending.size > this.maxPending) {
165
- const oldest = this._pending.keys().next().value;
166
- if (oldest === undefined) break;
167
- this._pending.delete(oldest);
168
- }
169
- return challenge;
170
- }
171
-
172
- // Drop entries that can no longer verify (outside ±maxSkewMs of nowMs).
173
- _sweep(nowMs) {
174
- for (const [nonce, ts] of this._pending) {
175
- if (Math.abs(nowMs - ts) > this.maxSkewMs) this._pending.delete(nonce);
176
- }
177
- }
178
-
179
- /**
180
- * Redeem a challenge exactly once. Returns true only when the nonce is
181
- * known, has NOT been consumed before, and is still within ±maxSkewMs of
182
- * `nowMs`. Any successful or expired redemption removes the nonce so it can
183
- * never be replayed.
184
- *
185
- * @param {string} nonce
186
- * @param {number} nowMs
187
- * @returns {boolean}
188
- */
189
- consume(nonce, nowMs) {
190
- if (typeof nonce !== 'string' || nonce.length === 0) return false;
191
- if (typeof nowMs !== 'number' || !Number.isFinite(nowMs)) return false;
192
- const ts = this._pending.get(nonce);
193
- if (ts === undefined) {
194
- // Unknown OR already consumed — either way, reject as replay.
195
- return false;
196
- }
197
- // Single-use: drop it now, before any further decision, so a concurrent or
198
- // repeated call can never redeem the same nonce twice.
199
- this._pending.delete(nonce);
200
- const skew = Math.abs(nowMs - ts);
201
- if (skew > this.maxSkewMs) {
202
- return false; // expired (stale or implausibly future)
203
- }
204
- return true;
205
- }
206
- }
119
+ // ChallengeRegistry (the single-use anti-replay ledger) lives in its own module
120
+ // now re-exported here so daemon.mjs and existing importers/tests are
121
+ // unchanged. createChallenge + DEFAULT_MAX_SKEW_MS stay above for verifyConnect.
122
+ export { ChallengeRegistry } from './challenge_registry.mjs';
207
123
 
208
124
  // Canonicalise scopes to a single deterministic token so two clients that
209
125
  // list the same scopes in a different order still produce an identical
@@ -266,7 +182,7 @@ export function buildSignPayload({
266
182
  // must stay in lock-step with buildSignPayload's field order. Returns null
267
183
  // for a malformed / wrong-version payload so the caller can reject cleanly
268
184
  // rather than throwing.
269
- function parsePayload(payload) {
185
+ export function parsePayload(payload) {
270
186
  if (typeof payload !== 'string') return null;
271
187
  const parts = payload.split(FIELD_SEP);
272
188
  if (parts.length !== 11) return null;
@@ -505,7 +421,7 @@ export class PairingStore {
505
421
  * @param {{ deviceId: string, platform?: string, label?: string }} args
506
422
  * @returns {{ requestId: string, status: 'pending' }}
507
423
  */
508
- requestPairing({ deviceId, platform = '', label = '' } = {}) {
424
+ requestPairing({ deviceId, platform = '', label = '', role = '', scopes = [] } = {}) {
509
425
  if (!deviceId || typeof deviceId !== 'string') {
510
426
  throw new Error('requestPairing requires a deviceId');
511
427
  }
@@ -524,6 +440,10 @@ export class PairingStore {
524
440
  deviceId,
525
441
  platform: String(platform || ''),
526
442
  label: String(label || ''),
443
+ // Capability metadata from the signed payload (read-only vs approver).
444
+ // Persisted so approve() can stamp it on the device for authz.
445
+ role: String(role || ''),
446
+ scopes: Array.isArray(scopes) ? scopes.map(String) : [],
527
447
  status: 'pending',
528
448
  createdAt: nowIso(),
529
449
  };
@@ -544,7 +464,7 @@ export class PairingStore {
544
464
  * @param {string} requestId
545
465
  * @returns {{ deviceId: string, token: string }}
546
466
  */
547
- approve(requestId) {
467
+ approve(requestId, { ttlMs, nowMs = Date.now() } = {}) {
548
468
  const req = this._data.requests[requestId];
549
469
  if (!req) {
550
470
  throw new Error(`unknown pairing request: ${requestId}`);
@@ -553,17 +473,25 @@ export class PairingStore {
553
473
  throw new Error(`request not pending: ${requestId} (status=${req.status})`);
554
474
  }
555
475
  const token = freshToken();
556
- this._data.devices[req.deviceId] = {
476
+ const dev = {
557
477
  deviceId: req.deviceId,
558
478
  platform: req.platform || '',
559
479
  label: req.label || '',
480
+ role: req.role || '',
481
+ scopes: Array.isArray(req.scopes) ? req.scopes : [],
560
482
  token,
561
483
  approvedAt: nowIso(),
562
484
  };
485
+ // Optional TTL: stamp an absolute expiry only when a positive ttlMs is
486
+ // given. Omitting it keeps the legacy never-expires record.
487
+ if (Number.isFinite(ttlMs) && ttlMs > 0 && Number.isFinite(nowMs)) {
488
+ dev.expiresAt = nowMs + ttlMs;
489
+ }
490
+ this._data.devices[req.deviceId] = dev;
563
491
  req.status = 'approved';
564
492
  req.approvedAt = nowIso();
565
493
  this._persist();
566
- return { deviceId: req.deviceId, token };
494
+ return { deviceId: req.deviceId, token, ...(dev.expiresAt ? { expiresAt: dev.expiresAt } : {}) };
567
495
  }
568
496
 
569
497
  /**
@@ -602,14 +530,20 @@ export class PairingStore {
602
530
  * @param {string} presentedToken
603
531
  * @returns {boolean}
604
532
  */
605
- verifyToken(deviceId, presentedToken) {
606
- const stored = this.tokenFor(deviceId);
533
+ verifyToken(deviceId, presentedToken, nowMs) {
534
+ const dev = this._data.devices[deviceId];
535
+ const stored = dev && typeof dev.token === 'string' && dev.token.length > 0 ? dev.token : null;
607
536
  if (typeof stored !== 'string' || stored.length === 0) return false;
608
537
  if (typeof presentedToken !== 'string' || presentedToken.length === 0) return false;
609
538
  const a = Buffer.from(stored, 'utf8');
610
539
  const b = Buffer.from(presentedToken, 'utf8');
611
540
  if (a.length !== b.length) return false; // length mismatch — cannot timingSafeEqual
612
- return crypto.timingSafeEqual(a, b);
541
+ // Constant-time compare FIRST, expiry AFTER — so response timing can't leak
542
+ // "expired" vs "wrong token". TTL is only enforced when both the record has
543
+ // an expiresAt AND the caller injects a nowMs (legacy callers/records skip).
544
+ if (!crypto.timingSafeEqual(a, b)) return false;
545
+ if (typeof dev.expiresAt === 'number' && Number.isFinite(nowMs) && nowMs >= dev.expiresAt) return false;
546
+ return true;
613
547
  }
614
548
 
615
549
  /**
@@ -624,6 +558,46 @@ export class PairingStore {
624
558
  return { deviceId, revoked: existed };
625
559
  }
626
560
 
561
+ /**
562
+ * Re-issue a fresh token for an already-approved device WITHOUT a new pairing
563
+ * handshake. The old token stops authenticating immediately; non-secret
564
+ * metadata (platform/label/role/scopes) is preserved. A positive ttlMs stamps
565
+ * a fresh expiresAt; omitting it clears any prior expiry (never-expires).
566
+ * Throws when the device is unknown or was never approved.
567
+ * @returns {{ deviceId: string, token: string, expiresAt?: number }}
568
+ */
569
+ rotate(deviceId, { ttlMs, nowMs = Date.now() } = {}) {
570
+ const dev = this._data.devices[deviceId];
571
+ if (!dev || typeof dev.token !== 'string' || dev.token.length === 0) {
572
+ throw new Error(`cannot rotate: no approved device ${deviceId}`);
573
+ }
574
+ dev.token = freshToken();
575
+ dev.rotatedAt = nowIso();
576
+ if (Number.isFinite(ttlMs) && ttlMs > 0 && Number.isFinite(nowMs)) dev.expiresAt = nowMs + ttlMs;
577
+ else delete dev.expiresAt;
578
+ this._persist();
579
+ return { deviceId, token: dev.token, ...(dev.expiresAt ? { expiresAt: dev.expiresAt } : {}) };
580
+ }
581
+
582
+ /**
583
+ * Non-secret view of a device record (NEVER includes the token) — for the
584
+ * gateway authz layer (role/scopes) and the `nodes` CLI. null when unknown.
585
+ * @returns {{ deviceId, platform, label, role, scopes, expiresAt, approvedAt } | null}
586
+ */
587
+ deviceInfo(deviceId) {
588
+ const dev = this._data.devices[deviceId];
589
+ if (!dev) return null;
590
+ return {
591
+ deviceId: dev.deviceId,
592
+ platform: dev.platform || '',
593
+ label: dev.label || '',
594
+ role: dev.role || '',
595
+ scopes: Array.isArray(dev.scopes) ? dev.scopes : [],
596
+ expiresAt: dev.expiresAt,
597
+ approvedAt: dev.approvedAt,
598
+ };
599
+ }
600
+
627
601
  /**
628
602
  * All pending pairing requests (awaiting an explicit approve()), newest
629
603
  * first. Tokens are never part of a request record, so this is safe to
@@ -33,6 +33,7 @@ import {
33
33
  PairingStore,
34
34
  deviceIdFromPublicKey,
35
35
  verifyConnect,
36
+ parsePayload,
36
37
  } from './device_auth.mjs';
37
38
  import { redactSecrets } from '../mas/redact.mjs';
38
39
 
@@ -154,8 +155,12 @@ export function createGateway({ configDir, challengeRegistry, nowFn = Date.now,
154
155
  const token = bearerToken(req);
155
156
  const deviceId = String(req.headers?.['x-device-id'] || '').trim();
156
157
  if (!token || !deviceId) return null;
157
- if (!store().verifyToken(deviceId, token)) return null;
158
- return { deviceId };
158
+ const st = store();
159
+ // Inject nowFn so an EXPIRED token (device TTL) is rejected across every
160
+ // authenticated route, not just freshly after issue.
161
+ if (!st.verifyToken(deviceId, token, nowFn())) return null;
162
+ const info = st.deviceInfo(deviceId);
163
+ return { deviceId, role: info?.role || '', scopes: Array.isArray(info?.scopes) ? info.scopes : [] };
159
164
  }
160
165
 
161
166
  async function handle(req, res, { readBody }) {
@@ -214,8 +219,22 @@ export function createGateway({ configDir, challengeRegistry, nowFn = Date.now,
214
219
  if (existing) {
215
220
  receipt = { requestId: existing.requestId };
216
221
  } else {
222
+ // role + scopes come from the SIGNATURE-VERIFIED payload (tamper-
223
+ // evident), not the unsigned body fields, so a client can't forge its
224
+ // own capabilities. parsePayload is safe here — verifyConnect passed.
225
+ let role = '';
226
+ let scopes = [];
217
227
  try {
218
- receipt = st.requestPairing({ deviceId, platform, label });
228
+ const parsed = parsePayload(payload);
229
+ if (parsed) {
230
+ role = String(parsed.role || '');
231
+ scopes = typeof parsed.scopes === 'string' && parsed.scopes
232
+ ? parsed.scopes.split(',').filter(Boolean)
233
+ : (Array.isArray(parsed.scopes) ? parsed.scopes : []);
234
+ }
235
+ } catch { /* fall back to no capability — default device */ }
236
+ try {
237
+ receipt = st.requestPairing({ deviceId, platform, label, role, scopes });
219
238
  } catch (err) {
220
239
  // Pending-requests ceiling hit — shed load rather than 500.
221
240
  if (err && err.code === 'PAIRING_CAP') {
@@ -238,6 +257,13 @@ export function createGateway({ configDir, challengeRegistry, nowFn = Date.now,
238
257
  if (m === 'POST' && p === '/gateway/exec/resolve') {
239
258
  const ident = authDevice(req);
240
259
  if (!ident) return writeJson(res, 401, { ok: false, reason: 'invalid device token' });
260
+ // Capability gate: a read-only device may observe (whoami/events/pending)
261
+ // but MUST NOT resolve an exec approval (the one mutating gateway action).
262
+ // Backward-compatible: only an EXPLICITLY read-only role is denied; legacy
263
+ // devices (role '') and approvers keep the prior behaviour.
264
+ if (ident.role === 'read-only') {
265
+ return writeJson(res, 403, { ok: false, reason: 'insufficient scope: a read-only device cannot resolve exec approvals' });
266
+ }
241
267
  const body = await readJsonBody(req, readBody);
242
268
  if (body && body.__tooLarge) return writeJson(res, 413, { ok: false, reason: 'request body too large' });
243
269
  if (!body || !body.id) return writeJson(res, 400, { ok: false, reason: 'id and decision are required' });