claude-rpc 0.13.5 → 0.13.7
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/package.json +1 -1
- package/src/daemon.js +3 -1
- package/src/discord-ipc.js +26 -4
- package/src/leaderboard.js +0 -0
- package/src/server/assets/dashboard.client.js +12 -6
- package/src/server/index.js +13 -0
- package/src/state.js +14 -0
- package/src/version.js +1 -1
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -370,7 +370,9 @@ async function pushPresence() {
|
|
|
370
370
|
// socket so we only force a reconnect when the connection is actually gone.
|
|
371
371
|
function isConnectionError(e) {
|
|
372
372
|
const code = (e && e.code) || '';
|
|
373
|
-
|
|
373
|
+
// ETIMEDOUT: request() now deadlines nonce replies — a half-open pipe that
|
|
374
|
+
// acks writes but never answers is a dead transport, so reconnect.
|
|
375
|
+
if (['EPIPE', 'ECONNRESET', 'ENOENT', 'ECONNREFUSED', 'ETIMEDOUT', 'ERR_STREAM_WRITE_AFTER_END'].includes(code)) return true;
|
|
374
376
|
const m = String((e && e.message) || '').toLowerCase();
|
|
375
377
|
return /closed|reset|broken pipe|not connected|disconnect|write after end|socket|econnreset|epipe|connection/.test(m);
|
|
376
378
|
}
|
package/src/discord-ipc.js
CHANGED
|
@@ -228,7 +228,11 @@ export class Client extends EventEmitter {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
// Send a command frame and resolve when the nonce-matched reply arrives.
|
|
231
|
-
|
|
231
|
+
// Times out after 10s: on a half-open pipe Discord can ack the socket write
|
|
232
|
+
// but never send the nonce reply, and without a deadline that await would
|
|
233
|
+
// hang forever — freezing the daemon's presence on a stale frame, with the
|
|
234
|
+
// watchdog blind because `connected` still reads true.
|
|
235
|
+
request(cmd, args, timeoutMs = 10_000) {
|
|
232
236
|
return new Promise((resolve, reject) => {
|
|
233
237
|
if (!this.socket) {
|
|
234
238
|
const err = new Error('Not connected');
|
|
@@ -237,7 +241,14 @@ export class Client extends EventEmitter {
|
|
|
237
241
|
return;
|
|
238
242
|
}
|
|
239
243
|
const nonce = randomUUID();
|
|
240
|
-
|
|
244
|
+
const timer = setTimeout(() => {
|
|
245
|
+
this._pending.delete(nonce);
|
|
246
|
+
const err = new Error(`No reply to ${cmd} within ${timeoutMs}ms`);
|
|
247
|
+
err.code = 'ETIMEDOUT';
|
|
248
|
+
reject(err);
|
|
249
|
+
}, timeoutMs);
|
|
250
|
+
if (timer.unref) timer.unref();
|
|
251
|
+
this._pending.set(nonce, { resolve, reject, timer });
|
|
241
252
|
this._send(OP_FRAME, { cmd, args, nonce });
|
|
242
253
|
});
|
|
243
254
|
}
|
|
@@ -272,7 +283,8 @@ export class Client extends EventEmitter {
|
|
|
272
283
|
|
|
273
284
|
// Nonce-matched response to a request() (e.g. SET_ACTIVITY).
|
|
274
285
|
if (msg.nonce && this._pending.has(msg.nonce)) {
|
|
275
|
-
const { resolve, reject } = this._pending.get(msg.nonce);
|
|
286
|
+
const { resolve, reject, timer } = this._pending.get(msg.nonce);
|
|
287
|
+
clearTimeout(timer);
|
|
276
288
|
this._pending.delete(msg.nonce);
|
|
277
289
|
if (msg.evt === 'ERROR') {
|
|
278
290
|
const err = new Error(msg.data?.message || 'Discord RPC error');
|
|
@@ -297,7 +309,8 @@ export class Client extends EventEmitter {
|
|
|
297
309
|
_onClose(reason) {
|
|
298
310
|
const wasConnected = this._connected || !!this.socket;
|
|
299
311
|
// Fail any in-flight requests so awaiters don't hang forever.
|
|
300
|
-
for (const { reject } of this._pending.values()) {
|
|
312
|
+
for (const { reject, timer } of this._pending.values()) {
|
|
313
|
+
clearTimeout(timer);
|
|
301
314
|
const err = new Error(typeof reason === 'string' ? reason : 'Connection closed');
|
|
302
315
|
err.code = 'ECONNRESET';
|
|
303
316
|
reject(err);
|
|
@@ -329,6 +342,15 @@ export class Client extends EventEmitter {
|
|
|
329
342
|
// Best-effort close; don't emit 'disconnected' on an explicit teardown
|
|
330
343
|
// (the daemon calls destroy() itself and manages its own reconnect).
|
|
331
344
|
this._readyResolve = this._readyReject = null;
|
|
345
|
+
// Reject in-flight requests (mirrors _onClose) — a pushPresence parked on
|
|
346
|
+
// `await setActivity` when the watchdog tears the client down must settle,
|
|
347
|
+
// not leak as a forever-pending promise.
|
|
348
|
+
for (const { reject, timer } of this._pending.values()) {
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
const err = new Error('Client destroyed');
|
|
351
|
+
err.code = 'ECONNRESET';
|
|
352
|
+
reject(err);
|
|
353
|
+
}
|
|
332
354
|
this._pending.clear();
|
|
333
355
|
this._teardownSocket();
|
|
334
356
|
this._connected = false;
|
package/src/leaderboard.js
CHANGED
|
Binary file
|
|
@@ -30,6 +30,12 @@
|
|
|
30
30
|
let churnSeries = []; // [{ d: Date, add, rem }] — for the churn-sparkline tooltip
|
|
31
31
|
|
|
32
32
|
// ── Utilities ───────────────────────────────────────────
|
|
33
|
+
// Escape before any innerHTML interpolation: project/file/command/model
|
|
34
|
+
// names come from the aggregate, i.e. ultimately from directory and file
|
|
35
|
+
// names on disk — a repo named `<img onerror=…>` must render, not run.
|
|
36
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => (
|
|
37
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
38
|
+
));
|
|
33
39
|
const fmtH = (ms) => {
|
|
34
40
|
if (!ms) return '0h';
|
|
35
41
|
const h = ms / 3_600_000;
|
|
@@ -232,10 +238,10 @@
|
|
|
232
238
|
if (r.onClick) tr.classList.add('clickable');
|
|
233
239
|
const ico = r.color ? '<span class="ico" style="background:' + r.color + '"></span>' : '';
|
|
234
240
|
const nameHtml = opts.mono
|
|
235
|
-
? '<code style="font-family: JetBrains Mono, monospace; font-size: 12px;">' + ico + r.name + '</code>'
|
|
236
|
-
: ico + r.name;
|
|
241
|
+
? '<code style="font-family: JetBrains Mono, monospace; font-size: 12px;">' + ico + esc(r.name) + '</code>'
|
|
242
|
+
: ico + esc(r.name);
|
|
237
243
|
tr.innerHTML = '<td class="name">' + nameHtml + '</td>' +
|
|
238
|
-
'<td class="val">' + r.val + (r.unit ? '<span class="u">' + r.unit + '</span>' : '') + '</td>';
|
|
244
|
+
'<td class="val">' + esc(r.val) + (r.unit ? '<span class="u">' + esc(r.unit) + '</span>' : '') + '</td>';
|
|
239
245
|
if (r.onClick) tr.addEventListener('click', r.onClick);
|
|
240
246
|
tbl.appendChild(tr);
|
|
241
247
|
});
|
|
@@ -276,7 +282,7 @@
|
|
|
276
282
|
const w = Math.max(2, (cost / total) * 100);
|
|
277
283
|
const row = document.createElement('div');
|
|
278
284
|
row.className = 'cost-bar';
|
|
279
|
-
row.innerHTML = '<span class="name">' + model + '</span>' +
|
|
285
|
+
row.innerHTML = '<span class="name">' + esc(model) + '</span>' +
|
|
280
286
|
'<span class="track"><span class="fill" style="width:' + w.toFixed(0) + '%"></span></span>' +
|
|
281
287
|
'<span class="val">' + fmtCost(cost) + '</span>';
|
|
282
288
|
bars.appendChild(row);
|
|
@@ -303,7 +309,7 @@
|
|
|
303
309
|
const row = document.createElement('div');
|
|
304
310
|
row.className = 'row';
|
|
305
311
|
row.innerHTML = '<span class="swatch" style="background:' + (LANGS[name] || '#888') + '"></span>' +
|
|
306
|
-
'<span class="name">' + name + '</span>' +
|
|
312
|
+
'<span class="name">' + esc(name) + '</span>' +
|
|
307
313
|
'<span class="val">' + fmtN(v.edits) + ' edits · ' + fmtN(v.files) + ' files</span>';
|
|
308
314
|
list.appendChild(row);
|
|
309
315
|
}
|
|
@@ -337,7 +343,7 @@
|
|
|
337
343
|
const isCurrent = i === onAir;
|
|
338
344
|
li.className = isCurrent ? 'current' : f.passes ? 'live' : 'skip';
|
|
339
345
|
const summary = f.passes ? ((f.details || '—') + (f.state ? ' · ' + f.state : '')) : (f.details || '—');
|
|
340
|
-
li.innerHTML = '<span class="pip"></span><span class="frame-text">' + summary + '</span>';
|
|
346
|
+
li.innerHTML = '<span class="pip"></span><span class="frame-text">' + esc(summary) + '</span>';
|
|
341
347
|
ul.appendChild(li);
|
|
342
348
|
});
|
|
343
349
|
}
|
package/src/server/index.js
CHANGED
|
@@ -30,7 +30,20 @@ function parseUrl(rawUrl) {
|
|
|
30
30
|
return { path: url.pathname, query: Object.fromEntries(url.searchParams) };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// Loopback-only Host allowlist. Binding to 127.0.0.1 blocks the LAN, but not
|
|
34
|
+
// DNS rebinding: a malicious page can point its own hostname at 127.0.0.1 and
|
|
35
|
+
// become "same-origin" with this server, then read /api/export.json. Rejecting
|
|
36
|
+
// non-local Host headers closes that — browsers always send the page's host.
|
|
37
|
+
function isLocalHost(host) {
|
|
38
|
+
const h = String(host || '').replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
39
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '::1';
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
const server = createServer((req, res) => {
|
|
43
|
+
if (!isLocalHost(req.headers.host)) {
|
|
44
|
+
res.writeHead(403, JSON_HEADERS).end(JSON.stringify({ error: 'forbidden' }));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
34
47
|
const { path, query } = parseUrl(req.url);
|
|
35
48
|
const key = `${req.method} ${path}`;
|
|
36
49
|
|
package/src/state.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
closeSync,
|
|
9
9
|
unlinkSync,
|
|
10
10
|
statSync,
|
|
11
|
+
fstatSync,
|
|
11
12
|
} from 'node:fs';
|
|
12
13
|
import { basename } from 'node:path';
|
|
13
14
|
import { STATE_PATH, STATE_DIR } from './paths.js';
|
|
@@ -110,11 +111,24 @@ function acquireLock() {
|
|
|
110
111
|
|
|
111
112
|
function releaseLock(fd) {
|
|
112
113
|
if (fd === null) return;
|
|
114
|
+
// Only unlink the lock if the path still points at OUR lock file. If this
|
|
115
|
+
// process somehow held it past LOCK_STALE_MS, a sibling has reclaimed the
|
|
116
|
+
// path (unlink + fresh 'wx' create); deleting that by path would collapse
|
|
117
|
+
// mutual exclusion for a third writer. Inode equality proves ownership.
|
|
118
|
+
let ours;
|
|
119
|
+
try {
|
|
120
|
+
const a = fstatSync(fd);
|
|
121
|
+
const b = statSync(LOCK_PATH);
|
|
122
|
+
ours = a.ino === b.ino && a.dev === b.dev;
|
|
123
|
+
} catch {
|
|
124
|
+
ours = false; // lock already gone — nothing to unlink
|
|
125
|
+
}
|
|
113
126
|
try {
|
|
114
127
|
closeSync(fd);
|
|
115
128
|
} catch {
|
|
116
129
|
/* already closed */
|
|
117
130
|
}
|
|
131
|
+
if (!ours) return;
|
|
118
132
|
try {
|
|
119
133
|
unlinkSync(LOCK_PATH);
|
|
120
134
|
} catch {
|