lazyclaw 3.99.16 → 3.99.18
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/cli.mjs +109 -23
- package/daemon.mjs +27 -3
- package/package.json +1 -1
- package/web/dashboard.html +31 -1
package/cli.mjs
CHANGED
|
@@ -2488,13 +2488,46 @@ async function cmdChat(flags = {}) {
|
|
|
2488
2488
|
// `dashboard` is the discoverable name and it auto-opens the browser
|
|
2489
2489
|
// (which the bare daemon doesn't, since most daemon callers are
|
|
2490
2490
|
// scripts).
|
|
2491
|
+
// Best-effort port-occupant kill — macOS / Linux only. Returns true when
|
|
2492
|
+
// at least one PID was signalled. Used by cmdDashboard so a leftover
|
|
2493
|
+
// listener from a previous run doesn't crash the launch with EADDRINUSE.
|
|
2494
|
+
// Mirrors the Python server's auto-kill behaviour described in CLAUDE.md.
|
|
2495
|
+
async function _killPortOccupant(port) {
|
|
2496
|
+
if (process.platform === 'win32') return false;
|
|
2497
|
+
const { spawn } = await import('node:child_process');
|
|
2498
|
+
return new Promise((resolve) => {
|
|
2499
|
+
let lsof;
|
|
2500
|
+
try {
|
|
2501
|
+
lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
2502
|
+
} catch (_) { return resolve(false); }
|
|
2503
|
+
let buf = '';
|
|
2504
|
+
lsof.stdout.on('data', (d) => { buf += d.toString('utf8'); });
|
|
2505
|
+
lsof.on('error', () => resolve(false));
|
|
2506
|
+
lsof.on('close', () => {
|
|
2507
|
+
const pids = buf.trim().split(/\s+/).map((s) => parseInt(s, 10)).filter(Number.isFinite);
|
|
2508
|
+
if (!pids.length) return resolve(false);
|
|
2509
|
+
// SIGTERM first so node has a chance to clean up; SIGKILL the
|
|
2510
|
+
// holdouts after a short grace window.
|
|
2511
|
+
for (const pid of pids) {
|
|
2512
|
+
try { process.kill(pid, 'SIGTERM'); } catch (_) { /* gone already */ }
|
|
2513
|
+
}
|
|
2514
|
+
setTimeout(() => {
|
|
2515
|
+
for (const pid of pids) {
|
|
2516
|
+
try { process.kill(pid, 'SIGKILL'); } catch (_) { /* gone */ }
|
|
2517
|
+
}
|
|
2518
|
+
resolve(true);
|
|
2519
|
+
}, 200);
|
|
2520
|
+
});
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2491
2524
|
async function cmdDashboard(flags = {}) {
|
|
2492
2525
|
await ensureRegistry();
|
|
2493
2526
|
const sessionsMod = await import('./sessions.mjs');
|
|
2494
2527
|
const { startDaemon } = await import('./daemon.mjs');
|
|
2495
2528
|
const port = flags.port !== undefined ? parseInt(flags.port, 10) : 19600;
|
|
2496
2529
|
const cfgDir = path.dirname(configPath());
|
|
2497
|
-
const
|
|
2530
|
+
const daemonOpts = {
|
|
2498
2531
|
port,
|
|
2499
2532
|
once: false,
|
|
2500
2533
|
readConfig,
|
|
@@ -2508,11 +2541,45 @@ async function cmdDashboard(flags = {}) {
|
|
|
2508
2541
|
// LAZYCLAW_AUTH_TOKEN + --allow-origin via the daemon command.
|
|
2509
2542
|
authToken: undefined,
|
|
2510
2543
|
allowedOrigins: [],
|
|
2544
|
+
// The dashboard's browser tab posts back to the same loopback URL
|
|
2545
|
+
// it was served from (e.g. `http://127.0.0.1:19600`). Without this
|
|
2546
|
+
// opt-in every chat send / mutation tripped the daemon's CSRF gate
|
|
2547
|
+
// with `403 forbidden origin`. Safe — the daemon binds 127.0.0.1
|
|
2548
|
+
// only, so an attacker can't reach it with a loopback origin
|
|
2549
|
+
// unless they're already on the machine.
|
|
2550
|
+
allowLoopbackOrigin: true,
|
|
2511
2551
|
rateLimit: null,
|
|
2512
2552
|
responseCache: null,
|
|
2513
2553
|
logger: null,
|
|
2514
2554
|
costCap: null,
|
|
2515
|
-
}
|
|
2555
|
+
};
|
|
2556
|
+
let d;
|
|
2557
|
+
try {
|
|
2558
|
+
d = await startDaemon(daemonOpts);
|
|
2559
|
+
} catch (err) {
|
|
2560
|
+
if (err?.code !== 'EADDRINUSE') throw err;
|
|
2561
|
+
// Port is held by a leftover dashboard / daemon. Try to free it
|
|
2562
|
+
// (lsof + kill on macOS/Linux); on failure, fall back to a random
|
|
2563
|
+
// port so the user always gets a working dashboard rather than a
|
|
2564
|
+
// crash trace.
|
|
2565
|
+
const portInUse = port;
|
|
2566
|
+
process.stderr.write(` ⚠ port ${portInUse} is in use — likely a previous dashboard didn't shut down.\n`);
|
|
2567
|
+
const killed = await _killPortOccupant(portInUse);
|
|
2568
|
+
if (killed) {
|
|
2569
|
+
process.stderr.write(` ✓ freed port ${portInUse} (killed prior listener) — retrying…\n`);
|
|
2570
|
+
// Short pause so the OS releases the port before we re-listen.
|
|
2571
|
+
await new Promise(r => setTimeout(r, 250));
|
|
2572
|
+
try { d = await startDaemon(daemonOpts); }
|
|
2573
|
+
catch (err2) {
|
|
2574
|
+
if (err2?.code !== 'EADDRINUSE') throw err2;
|
|
2575
|
+
process.stderr.write(` ⚠ still in use — falling back to a random port.\n`);
|
|
2576
|
+
d = await startDaemon({ ...daemonOpts, port: 0 });
|
|
2577
|
+
}
|
|
2578
|
+
} else {
|
|
2579
|
+
process.stderr.write(` ⚠ couldn't free port ${portInUse} automatically — falling back to a random port.\n`);
|
|
2580
|
+
d = await startDaemon({ ...daemonOpts, port: 0 });
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2516
2583
|
const url = `http://127.0.0.1:${d.port}/dashboard`;
|
|
2517
2584
|
process.stdout.write(`🦞 LazyClaw dashboard listening at ${url}\n`);
|
|
2518
2585
|
if (!flags['no-open']) {
|
|
@@ -2600,27 +2667,46 @@ async function cmdDaemon(flags) {
|
|
|
2600
2667
|
|| process.env.LAZYCLAW_WORKFLOW_STATE_DIR
|
|
2601
2668
|
|| '.workflow-state';
|
|
2602
2669
|
const cfgDir = path.dirname(configPath());
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2670
|
+
let d;
|
|
2671
|
+
try {
|
|
2672
|
+
d = await startDaemon({
|
|
2673
|
+
port: Number.isFinite(port) ? port : 0,
|
|
2674
|
+
once,
|
|
2675
|
+
readConfig,
|
|
2676
|
+
// `lazyclaw daemon` exposes mutation endpoints (POST /providers,
|
|
2677
|
+
// PUT /rates/<key>, etc.) only when an auth token is configured
|
|
2678
|
+
// — without one the daemon is loopback-only but still untrusted
|
|
2679
|
+
// (any process on the box can hit it). dashboard subcommand sets
|
|
2680
|
+
// writeConfig unconditionally because it always runs as the user.
|
|
2681
|
+
writeConfig: authToken ? writeConfig : undefined,
|
|
2682
|
+
sessionsDirGetter: () => cfgDir,
|
|
2683
|
+
sessionsMod,
|
|
2684
|
+
version: () => readVersionFromRepo(),
|
|
2685
|
+
workflowStateDir: () => workflowStateDirValue,
|
|
2686
|
+
authToken: authToken || undefined,
|
|
2687
|
+
allowedOrigins,
|
|
2688
|
+
rateLimit,
|
|
2689
|
+
responseCache,
|
|
2690
|
+
logger,
|
|
2691
|
+
costCap: costCapOrNull,
|
|
2692
|
+
});
|
|
2693
|
+
} catch (err) {
|
|
2694
|
+
// `lazyclaw daemon` exits cleanly on EADDRINUSE with a readable
|
|
2695
|
+
// message instead of the historical unhandled-error stack trace.
|
|
2696
|
+
// Unlike `lazyclaw dashboard`, daemon doesn't auto-kill the prior
|
|
2697
|
+
// listener — bare daemon callers are usually scripts that expect
|
|
2698
|
+
// exact port semantics, so we surface the failure and let them
|
|
2699
|
+
// choose (re-run with --port 0 for random, or kill the holdout).
|
|
2700
|
+
if (err?.code === 'EADDRINUSE') {
|
|
2701
|
+
process.stderr.write(
|
|
2702
|
+
`lazyclaw daemon: port ${port} is in use.\n` +
|
|
2703
|
+
` Re-run with --port 0 for a random port, or free the port:\n` +
|
|
2704
|
+
` lsof -ti tcp:${port} | xargs kill -9\n`
|
|
2705
|
+
);
|
|
2706
|
+
process.exit(2);
|
|
2707
|
+
}
|
|
2708
|
+
throw err;
|
|
2709
|
+
}
|
|
2624
2710
|
// Print the bound port immediately so test/script callers can pick it up
|
|
2625
2711
|
// even when we asked for port 0. Indicate auth presence (not the token)
|
|
2626
2712
|
// and the allowed-origin count (not the values, just whether browser
|
package/daemon.mjs
CHANGED
|
@@ -231,13 +231,22 @@ function isAuthorized(req, expectedToken) {
|
|
|
231
231
|
* - `Origin` set → must be in `allowedOrigins`. Empty allowlist
|
|
232
232
|
* means "reject all browser-originated requests" — the default,
|
|
233
233
|
* because the daemon is designed for CLI/script callers.
|
|
234
|
+
* - `allowLoopback: true` (set by `lazyclaw dashboard`) additionally
|
|
235
|
+
* accepts any `Origin` that looks like loopback (`http://127.0.0.1:*`,
|
|
236
|
+
* `http://localhost:*`, `http://[::1]:*`). Safe because the daemon
|
|
237
|
+
* binds only to 127.0.0.1, so an attacker can't reach us with a
|
|
238
|
+
* loopback Origin unless they're already on the box. DNS rebinding
|
|
239
|
+
* can't forge `127.0.0.1` as a hostname — that resolves before
|
|
240
|
+
* `fetch()` ever issues the request.
|
|
234
241
|
*
|
|
235
242
|
* Returns true when the request should proceed, false when it should
|
|
236
243
|
* be rejected with 403.
|
|
237
244
|
*/
|
|
238
|
-
|
|
245
|
+
const LOOPBACK_ORIGIN_RE = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
|
|
246
|
+
function isOriginAllowed(req, allowedOrigins, allowLoopback) {
|
|
239
247
|
const origin = req.headers['origin'];
|
|
240
248
|
if (!origin) return true;
|
|
249
|
+
if (allowLoopback && LOOPBACK_ORIGIN_RE.test(origin)) return true;
|
|
241
250
|
if (!allowedOrigins || allowedOrigins.length === 0) return false;
|
|
242
251
|
return allowedOrigins.includes(origin);
|
|
243
252
|
}
|
|
@@ -267,6 +276,10 @@ function isOriginAllowed(req, allowedOrigins) {
|
|
|
267
276
|
export function makeHandler(ctx) {
|
|
268
277
|
const authToken = ctx.authToken || null;
|
|
269
278
|
const allowedOrigins = Array.isArray(ctx.allowedOrigins) ? ctx.allowedOrigins : [];
|
|
279
|
+
// dashboard subcommand opts in so the browser tab it just opened can
|
|
280
|
+
// actually call its own daemon. Bare `lazyclaw daemon` leaves this off
|
|
281
|
+
// and the explicit allowlist (or no-browser default) stays in force.
|
|
282
|
+
const allowLoopback = !!ctx.allowLoopbackOrigin;
|
|
270
283
|
// Default state dir matches the CLI's default. Callers can override
|
|
271
284
|
// via ctx.workflowStateDir or LAZYCLAW_WORKFLOW_STATE_DIR env var.
|
|
272
285
|
const workflowStateDir = ctx.workflowStateDir
|
|
@@ -345,7 +358,7 @@ export function makeHandler(ctx) {
|
|
|
345
358
|
try {
|
|
346
359
|
// Origin gate runs *before* auth so a browser-originated request
|
|
347
360
|
// can't even probe whether a token is required.
|
|
348
|
-
if (!isOriginAllowed(req, allowedOrigins)) {
|
|
361
|
+
if (!isOriginAllowed(req, allowedOrigins, allowLoopback)) {
|
|
349
362
|
return writeJson(res, 403, { error: 'forbidden origin' });
|
|
350
363
|
}
|
|
351
364
|
// Authentication gate — when authToken is set, every request must
|
|
@@ -1666,8 +1679,19 @@ export async function startDaemon(opts) {
|
|
|
1666
1679
|
setImmediate(() => server.close());
|
|
1667
1680
|
}
|
|
1668
1681
|
});
|
|
1669
|
-
return new Promise((resolve) => {
|
|
1682
|
+
return new Promise((resolve, reject) => {
|
|
1683
|
+
// EADDRINUSE (and other listen-time errors) used to crash the
|
|
1684
|
+
// process — listen() emits 'error' before the success callback
|
|
1685
|
+
// fires, and we never wired that channel. Capture it once so
|
|
1686
|
+
// callers (cmdDashboard / cmdDaemon) can choose to kill the
|
|
1687
|
+
// occupant or fall back to a random port.
|
|
1688
|
+
const onError = (err) => {
|
|
1689
|
+
server.off('error', onError);
|
|
1690
|
+
reject(err);
|
|
1691
|
+
};
|
|
1692
|
+
server.once('error', onError);
|
|
1670
1693
|
server.listen(opts.port ?? 0, '127.0.0.1', () => {
|
|
1694
|
+
server.off('error', onError);
|
|
1671
1695
|
const addr = server.address();
|
|
1672
1696
|
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
1673
1697
|
resolve({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "3.99.
|
|
3
|
+
"version": "3.99.18",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama and orchestrating multi-step LLM workflows. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/web/dashboard.html
CHANGED
|
@@ -523,9 +523,27 @@
|
|
|
523
523
|
LOADERS.chat = async function loadChat() {
|
|
524
524
|
try {
|
|
525
525
|
const r = await api('/providers');
|
|
526
|
+
// GET /providers returns a bare array; older drafts wrapped it
|
|
527
|
+
// as { providers: [...] }. Accept both so the dashboard works
|
|
528
|
+
// against any daemon version users might happen to be running.
|
|
529
|
+
const arr = Array.isArray(r) ? r : (r.providers || []);
|
|
526
530
|
const sel = document.getElementById('chat-assignee');
|
|
527
531
|
sel.innerHTML = '';
|
|
528
|
-
|
|
532
|
+
if (arr.length === 0) {
|
|
533
|
+
const opt = document.createElement('option');
|
|
534
|
+
opt.value = ''; opt.textContent = '(no providers — run lazyclaw onboard)';
|
|
535
|
+
sel.appendChild(opt);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
// Preselect the configured default when possible so the user
|
|
539
|
+
// doesn't have to scroll through the list before sending the
|
|
540
|
+
// first message.
|
|
541
|
+
let defaultStatus = null;
|
|
542
|
+
try { defaultStatus = await api('/status'); } catch { /* keep going */ }
|
|
543
|
+
const defaultProv = defaultStatus?.provider || null;
|
|
544
|
+
const defaultModel = defaultStatus?.model || null;
|
|
545
|
+
const defaultValue = defaultProv && defaultModel ? `${defaultProv}:${defaultModel}` : defaultProv;
|
|
546
|
+
for (const p of arr) {
|
|
529
547
|
const ms = (p.suggestedModels || []);
|
|
530
548
|
if (!ms.length) {
|
|
531
549
|
const opt = document.createElement('option');
|
|
@@ -540,6 +558,18 @@
|
|
|
540
558
|
sel.appendChild(opt);
|
|
541
559
|
}
|
|
542
560
|
}
|
|
561
|
+
if (defaultValue) {
|
|
562
|
+
// Try exact match first (provider:model); fall back to any
|
|
563
|
+
// option starting with `<provider>:` if the configured model
|
|
564
|
+
// isn't in the suggested list.
|
|
565
|
+
const exact = Array.from(sel.options).find((o) => o.value === defaultValue);
|
|
566
|
+
if (exact) sel.value = defaultValue;
|
|
567
|
+
else {
|
|
568
|
+
const prefix = (defaultProv || '') + ':';
|
|
569
|
+
const byProv = Array.from(sel.options).find((o) => o.value.startsWith(prefix) || o.value === defaultProv);
|
|
570
|
+
if (byProv) sel.value = byProv.value;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
543
573
|
} catch (e) {
|
|
544
574
|
document.getElementById('chat-meta').textContent = '⚠ ' + e.message;
|
|
545
575
|
}
|