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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.13.5",
3
+ "version": "0.13.7",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
- if (['EPIPE', 'ECONNRESET', 'ENOENT', 'ECONNREFUSED', 'ERR_STREAM_WRITE_AFTER_END'].includes(code)) return true;
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
  }
@@ -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
- request(cmd, args) {
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
- this._pending.set(nonce, { resolve, reject });
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;
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
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
  }
@@ -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 {
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.13.5';
14
+ const BAKED = '0.13.7';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {