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.
- package/README.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +6 -1
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +20 -5
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +95 -37
- package/commands/chat_hardening.mjs +23 -0
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -8
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +36 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +26 -2
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +61 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +166 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +66 -11
- package/providers/gemini.mjs +101 -3
- package/providers/gemini_cli.mjs +62 -15
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +18 -2
- package/providers/tool_use/openai.mjs +28 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +178 -151
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/pickers.mjs +210 -95
- package/tui/repl.mjs +106 -44
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dispatcher.mjs +537 -344
- package/tui/slash_popup.mjs +5 -1
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- 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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/gateway/device_auth.mjs
CHANGED
|
@@ -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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/gateway/http_gateway.mjs
CHANGED
|
@@ -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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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' });
|