From bb250dff1df88b4541e342e8cb569304532603f4 Mon Sep 17 00:00:00 2001 From: Bargan Constantin Date: Mon, 5 Oct 2026 01:53:55 +0300 Subject: [PATCH] fix(reports): keep bare addresses and host names out of error reports The scrub took the host out of every web address but left an address with no scheme in front, so a network error still named the machine it failed on: connect ECONNREFUSED 192.168.1.5:4317, getaddrinfo ENOTFOUND bobs-pc.local, an IPv6 address with its zone, a Tailscale address or ts.net name. Both the server's scrub and the page's now make every IPv4 and IPv6 address, and a host name after a DNS or connect failure, glued to a port, or ending in .local, .lan, .home, .internal or .ts.net, , with a separate port kept. The loopback and any-address stay, so a local port error reads as it is, and version numbers and file:line:col frames are left alone. --- README.md | 2 +- src/server/reports.mjs | 42 +++++- .../__tests__/error-report-addresses.test.ts | 126 ++++++++++++++++++ src/web/report-errors.ts | 19 ++- 4 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 src/web/__tests__/error-report-addresses.test.ts diff --git a/README.md b/README.md index c5f230067..86e291044 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ Quota is the one thing that is not just reading. It needs a live token, so when It never steers an agent or edits your code, but it is not read-only either — besides the hook entry and its own event log, it manages the two tools it leans on, and it refreshes the Codex token it reads quota with, rewriting `~/.codex/auth.json` the way `codex` itself does. It also reads your browser's history, because Browser Watch is on unless you switch it off; that is its own section below, because it is the one thing here that is about you rather than about an agent. -What does go out is short and ordinary: a ~20-byte version check against the npm registry (plus one small request to confirm a version it has not seen before), installs and daily version checks for the two tools the deck manages (claude-swap from PyPI, ccusage from npm), on an Apple Silicon Mac whose sensors stay silent one release lookup and one binary download from GitHub for `macmon`, and, while the page is open, quota reads to Anthropic and OpenAI signed with your own credentials — that is where those numbers live. While the page is open, and while the desktop app has a session running or waiting, it also reads Anthropic's and OpenAI's public status pages (`status.claude.com`, `status.openai.com`), unsigned, about every three minutes, and once a minute at most while a page is not answering, so an outage on their side shows as a chip in the topbar instead of looking like a fault on yours. The [desktop app](#desktop-app) also asks this repository's GitHub releases for its own updates. With **Local network** on, which it is unless you switch it off, the deck also announces itself to other decks on your local network over UDP (port 45317) and pairs with the ones that answer — see [Local network](#local-network). The deck also sends usage reports to `api.ccdeck.dev`: an install event (naming the ccdeck.dev page the command was copied from, when the command says so with `--ref`, which the site's copy buttons add; a page name and nothing else, sent once), an update event (saying how the new version arrived: the deck's own update, the desktop app's updater, npx, npm or a source checkout), one "active" a day, one "uninstall" when you run `ccdeck --uninstall` (which asks, at a terminal, one skippable question about why, from a short list), one "activated" when a new install's first session arrives (how long after the install, in rough buckets such as "under five minutes", and whether it was a Claude or a Codex session), one "rated" if you answer the one question the deck asks about itself after about a week of use — "How useful is ccdeck to you?", 0 to 10, which you can skip — with the number you picked and how many days you had used the deck, as a range (nothing is sent for a question you put off or leave), and the errors it runs into, with every path outside ccdeck's own code, project folder names, the machine name in every web address, email addresses and keys scrubbed out (a line of ccdeck's own code keeps its place in the package, such as `ccdeck/src/server/tail.mjs:42`, or in the page, such as `/assets/index-abc.js:1:2345`, so the error still says where it happened). Each carries the version, your OS and CPU architecture, the channel (desktop, npm or a source checkout), the runtime, your IP address and a device fingerprint (a stable hashed machine id), and the machine's rough shape — the number of CPU cores, the amount of RAM, the locale, the shell, the terminal, and the Claude Code and Codex versions — and whether the Claude hooks went in and Codex is being watched, with the daily "active" also carrying how many sessions, subagents and projects you ran on the last day you used the deck, that day's date, and which of the deck's own features you used that day — fixed names such as "usage history" or "account switch", a yes for each and nothing about what you did with them — plus how the deck held up and what it runs with: how long this run took to start, that day's peak memory and how many events it took in, each as a rough bucket such as "under two seconds" or "256–512 MB", the Claude and Codex plan as its category only (such as "Max 5x" or "Plus", read from the file each CLI keeps beside its login, and nothing else from it), and how many Claude accounts and paired machines the deck holds, as counts. The fingerprint is a one-way hash of stable machine traits, never those traits in the clear; everything else is coarse counts and short tokens — no path, no project name, no free text. These reports are no longer anonymous: the install id is random, but the fingerprint follows this machine and the server records the IP the report arrives from. What still never leaves is your sessions, prompts, files, project names and paths. The deck has no switch for them; `AGENTS_DECK_NO_REPORTS=1` keeps them off from the first start. While the deck runs it also sends a tiny heartbeat every few minutes, the install id and nothing else, so the count of decks running right now, and how many hours a day a deck stays open, can be seen. **Send feedback** (the topbar's Feedback button) sends what you write, and any screenshots you add, to the same place, where the people who make ccdeck read it and may open a public issue from it, never with the contact you gave or your screenshots. `AGENTS_DECK_NO_INSTALL=1` turns off everything but the quota reads; `AGENTS_DECK_NO_DOWNLOAD=1` is the narrower version — no `uv` binary is fetched, the managed installs stay. +What does go out is short and ordinary: a ~20-byte version check against the npm registry (plus one small request to confirm a version it has not seen before), installs and daily version checks for the two tools the deck manages (claude-swap from PyPI, ccusage from npm), on an Apple Silicon Mac whose sensors stay silent one release lookup and one binary download from GitHub for `macmon`, and, while the page is open, quota reads to Anthropic and OpenAI signed with your own credentials — that is where those numbers live. While the page is open, and while the desktop app has a session running or waiting, it also reads Anthropic's and OpenAI's public status pages (`status.claude.com`, `status.openai.com`), unsigned, about every three minutes, and once a minute at most while a page is not answering, so an outage on their side shows as a chip in the topbar instead of looking like a fault on yours. The [desktop app](#desktop-app) also asks this repository's GitHub releases for its own updates. With **Local network** on, which it is unless you switch it off, the deck also announces itself to other decks on your local network over UDP (port 45317) and pairs with the ones that answer — see [Local network](#local-network). The deck also sends usage reports to `api.ccdeck.dev`: an install event (naming the ccdeck.dev page the command was copied from, when the command says so with `--ref`, which the site's copy buttons add; a page name and nothing else, sent once), an update event (saying how the new version arrived: the deck's own update, the desktop app's updater, npx, npm or a source checkout), one "active" a day, one "uninstall" when you run `ccdeck --uninstall` (which asks, at a terminal, one skippable question about why, from a short list), one "activated" when a new install's first session arrives (how long after the install, in rough buckets such as "under five minutes", and whether it was a Claude or a Codex session), one "rated" if you answer the one question the deck asks about itself after about a week of use — "How useful is ccdeck to you?", 0 to 10, which you can skip — with the number you picked and how many days you had used the deck, as a range (nothing is sent for a question you put off or leave), and the errors it runs into, with every path outside ccdeck's own code, project folder names, the machine name in every web address, every IP address and the machine name a network error carries (the loopback, such as `127.0.0.1:4317`, stays, so a local port error still reads as one), email addresses and keys scrubbed out (a line of ccdeck's own code keeps its place in the package, such as `ccdeck/src/server/tail.mjs:42`, or in the page, such as `/assets/index-abc.js:1:2345`, so the error still says where it happened). Each carries the version, your OS and CPU architecture, the channel (desktop, npm or a source checkout), the runtime, your IP address and a device fingerprint (a stable hashed machine id), and the machine's rough shape — the number of CPU cores, the amount of RAM, the locale, the shell, the terminal, and the Claude Code and Codex versions — and whether the Claude hooks went in and Codex is being watched, with the daily "active" also carrying how many sessions, subagents and projects you ran on the last day you used the deck, that day's date, and which of the deck's own features you used that day — fixed names such as "usage history" or "account switch", a yes for each and nothing about what you did with them — plus how the deck held up and what it runs with: how long this run took to start, that day's peak memory and how many events it took in, each as a rough bucket such as "under two seconds" or "256–512 MB", the Claude and Codex plan as its category only (such as "Max 5x" or "Plus", read from the file each CLI keeps beside its login, and nothing else from it), and how many Claude accounts and paired machines the deck holds, as counts. The fingerprint is a one-way hash of stable machine traits, never those traits in the clear; everything else is coarse counts and short tokens — no path, no project name, no free text. These reports are no longer anonymous: the install id is random, but the fingerprint follows this machine and the server records the IP the report arrives from. What still never leaves is your sessions, prompts, files, project names and paths. The deck has no switch for them; `AGENTS_DECK_NO_REPORTS=1` keeps them off from the first start. While the deck runs it also sends a tiny heartbeat every few minutes, the install id and nothing else, so the count of decks running right now, and how many hours a day a deck stays open, can be seen. **Send feedback** (the topbar's Feedback button) sends what you write, and any screenshots you add, to the same place, where the people who make ccdeck read it and may open a public issue from it, never with the contact you gave or your screenshots. `AGENTS_DECK_NO_INSTALL=1` turns off everything but the quota reads; `AGENTS_DECK_NO_DOWNLOAD=1` is the narrower version — no `uv` binary is fetched, the managed installs stay. ## Browser Watch diff --git a/src/server/reports.mjs b/src/server/reports.mjs index 582b6a06b..00361f23c 100644 --- a/src/server/reports.mjs +++ b/src/server/reports.mjs @@ -38,7 +38,8 @@ // unanswered (rating.mjs), // - errors: a request handler that threw on this server, or an error the page // caught, with every path outside the deck's own package, project folder -// names, the host of every address, email addresses and key-shaped strings +// names, the host of every web address, every IP address and the machine +// name a network error carries, email addresses and key-shaped strings // scrubbed out before they leave (see scrub), // - a "ping" heartbeat, every ten minutes or so while the deck runs, which // moves the install's "last seen" so the admin can show who is online now, @@ -392,6 +393,33 @@ function hideOrigin(_origin, scheme, assets) { return assets ? "" : `${scheme}://`; } +const OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`; +const IPV4 = String.raw`${OCTET}(?:\.${OCTET}){3}`; +const HEX = String.raw`[0-9A-Fa-f]{1,4}`; +/** An IPv6 address: compressed with a `::` or all eight groups there, with a + * digit in it (`Face::Add` is code), an IPv4 address allowed at its end + * (`::ffff:192.168.1.5`), and a port Node glues on with no brackets + * (`fd7a:115c::5:4317`) taken along, since nothing tells it from the last group. */ +const IPV6 = String.raw`(?=[0-9A-Fa-f:]*\d)(?=[0-9A-Fa-f:]*::|(?:${HEX}:){7})(?:(?:${HEX})?:)+(?:${IPV4}|${HEX})?`; +const ZONE = String.raw`%[\w-]+(?:\.[\w-]+)*`; +/** The loopback and the any-address, plain or mapped: they name nobody. */ +const KEPT_IPV6 = String.raw`(?:::1?|::ffff:(?:127(?:\.\d{1,3}){3}|0\.0\.0\.0))`; + +/** Every IPv4 and IPv6 address, bracketed or bare, but the loopback and the + * any-address, so `connect ECONNREFUSED 127.0.0.1:4317` stays readable. Not a + * version: an IPv4 address is four parts, never one after a slash + * (`Chrome/130.0.0.0`) or a dash (`node-22.18.0`). */ +export const ADDRESS_PATTERN = String.raw`(? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); /** Every spelling of `dir` a stack or a message can use — the path, and the @@ -418,13 +446,19 @@ function spellings(dir) { * by (`http://alices-macbook.local:4317/assets/…`); they become `/assets/…`, * which still says where in the bundle and reads the same on every machine, and * any other address keeps its scheme and path and loses its host (hideOrigin). + * An address with no scheme in front goes the same way: every IPv4 and IPv6 + * address (ADDRESS_PATTERN), and a host name where a network error puts one + * (HOST_PATTERN: `getaddrinfo ENOTFOUND bobs-pc.local`, `bobs-pc:4317`), becomes + * ``, a port kept apart from it left in place. The loopback and the + * any-address stay — `connect ECONNREFUSED 127.0.0.1:4317` names nobody, and + * says which port. * * The catch-all for long opaque strings deliberately stops at a slash: with one * in it, the pattern swallowed every long file path, and a stack whose frames * read `at save (~/..mjs:40:3)` tells nobody where the bug is. * The API scrubs again; - * this is the pass that means it never has to: paths, project folders, hosts, - * email addresses, and strings shaped like keys and tokens. + * this is the pass that means it never has to: paths, project folders, hosts + * and addresses, email addresses, and strings shaped like keys and tokens. */ export function scrub(text, home = homedir(), root = PKG_ROOT) { let out = String(text ?? ""); @@ -440,6 +474,8 @@ export function scrub(text, home = homedir(), root = PKG_ROOT) { .replace(new RegExp(PATH_PATTERN, "g"), hidePath) .replace(new RegExp(PROJECT_PATTERN, "g"), "") .replace(new RegExp(ORIGIN_PATTERN, "g"), hideOrigin) + .replace(new RegExp(ADDRESS_PATTERN, "g"), "") + .replace(new RegExp(HOST_PATTERN, "g"), "") .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "") .replace( /\b(?:sk-ant-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abpr]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}|[A-Fa-f0-9]{40,}|[A-Za-z0-9+_=-]{48,})/g, diff --git a/src/web/__tests__/error-report-addresses.test.ts b/src/web/__tests__/error-report-addresses.test.ts new file mode 100644 index 000000000..958225a49 --- /dev/null +++ b/src/web/__tests__/error-report-addresses.test.ts @@ -0,0 +1,126 @@ +// What an error report keeps of an address with no scheme in front. +// +// The scrub took the host out of every web address (error-report-origins.test.ts) +// and left a bare one alone, so a network error still named the machine it +// failed on: `connect ECONNREFUSED 192.168.1.5:4317`, `getaddrinfo ENOTFOUND +// bobs-pc.local`, an IPv6 address with its zone, a Tailscale address or ts.net +// name. +// +// The rule now: every IPv4 and IPv6 address, and a host name where a network +// error puts one — after a DNS or connect failure, glued to a port, or ending in +// a local-network suffix — becomes ``, with a port kept apart from it left +// where it was. The loopback and the any-address name nobody and stay, so a +// local port error reads as it is; a version number or a file:line:col frame is +// not an address. Both scrubs hold to it — the server's, which runs on every +// error that leaves, and the page's, which runs on the crash text a person reads +// before sending feedback. +import { describe, it, expect } from "vitest"; +import { normalise } from "../../server/deck-prefs.mjs"; +import { + createReporter, scrub, ADDRESS_PATTERN as SERVER_ADDRESS, HOST_PATTERN as SERVER_HOST, + // @ts-expect-error — plain JS module, no types +} from "../../server/reports.mjs"; +import { ADDRESS_PATTERN, HOST_PATTERN, scrubReport } from "../report-errors"; + +const HOME = "/home/alice"; +const NPX_ROOT = "/home/alice/.npm/_npx/1a2b3c/node_modules/ccdeck"; + +/** Bare addresses and host names, in the shapes errors carry them. */ +const BARE: [string, string][] = [ + ["connect ECONNREFUSED 192.168.1.5:4317", "connect ECONNREFUSED :4317"], + ["connect ETIMEDOUT 100.101.102.103:4317", "connect ETIMEDOUT :4317"], + ["getaddrinfo ENOTFOUND bobs-pc.local", "getaddrinfo ENOTFOUND "], + ["getaddrinfo EAI_AGAIN forklore", "getaddrinfo EAI_AGAIN "], + ["fetch failed: getaddrinfo ENOTFOUND api.acme-corp.com", "fetch failed: getaddrinfo ENOTFOUND "], + ["no route to fe80::1%en0", "no route to "], + ["dial [fd7a:115c::5]:4317 refused", "dial :4317 refused"], + // Node glues the port to an IPv6 address with no brackets: nothing tells it from the last group. + ["connect ECONNREFUSED fd7a:115c:a1e0::5:4317", "connect ECONNREFUSED "], + ["connect EHOSTUNREACH 2001:db8:85a3:0:0:8a2e:370:7334:443", "connect EHOSTUNREACH "], + ["peer ::ffff:192.168.1.20 dropped", "peer dropped"], + ["peer bobs-pc.tail1234.ts.net unreachable", "peer unreachable"], + ["deck at alices-mac.lan answered 403, nas.home timed out", "deck at answered 403, timed out"], + ["proxy.acme-corp.internal refused", " refused"], + ["Connect Timeout Error (attempted address: bobs-pc:4317, timeout: 10000ms)", "Connect Timeout Error (attempted address: :4317, timeout: 10000ms)"], +]; + +/** What must come through untouched: the loopback and the any-address, versions, frames. */ +const KEPT: string[] = [ + "connect ECONNREFUSED 127.0.0.1:4317", + "connect ECONNREFUSED ::1:4317", + "connect ECONNREFUSED [::1]:4317", + "getaddrinfo ENOTFOUND localhost", + "fetch localhost:4317 failed", + "listen EADDRINUSE: address already in use 0.0.0.0:4317", + "listen EADDRINUSE: address already in use :::4317", + "update 3.36.8 -> 3.36.9 on node-22.18.0, Chrome/130.0.0.0", + " at Object. (index.js:12:5)", + " at readTranscript (ccdeck/src/server/tail.mjs:42:7)", + " at Inner (/assets/index-abc.js:1:2345)", + "codex_core::exec::ExecError at 10:00:00", + // Already scrubbed, as the API gets it from a deck that scrubbed first. + "connect ECONNREFUSED :4317, getaddrinfo ENOTFOUND ", +]; + +describe("the server's scrub sends no bare address", () => { + it.each(BARE)("%s", (input, output) => { + expect(scrub(input, HOME, NPX_ROOT)).toBe(output); + }); + + it.each(KEPT)("leaves %s as it was", text => { + expect(scrub(text, HOME, NPX_ROOT)).toBe(text); + }); +}); + +describe("the page's scrub shows none either", () => { + it.each(BARE)("%s", (input, output) => { + expect(scrubReport(input)).toBe(output); + }); + + it.each(KEPT)("leaves %s as it was", text => { + expect(scrubReport(text)).toBe(text); + }); + + it("finds addresses and host names with the server's very patterns", () => { + expect(typeof SERVER_ADDRESS).toBe("string"); + expect(typeof SERVER_HOST).toBe("string"); + expect(ADDRESS_PATTERN).toBe(SERVER_ADDRESS); + expect(HOST_PATTERN).toBe(SERVER_HOST); + }); +}); + +describe("a network error the deck sends", () => { + it("names neither the address nor the machine it failed on", async () => { + let prefs = normalise({}); + const sent: Record[] = []; + const reporter = createReporter({ + fetchImpl: async (_url: string, init: { body?: string }) => { + if (init.body) sent.push(JSON.parse(init.body)); + return { ok: true, status: 202 }; + }, + now: () => new Date("2026-10-05T10:00:00Z"), + prefs: { current: () => prefs, update: async (mutate: (p: typeof prefs) => object) => (prefs = normalise({ ...prefs, ...mutate(prefs) })) }, + env: {}, + facts: { version: "3.36.0", os: "linux", arch: "x64", channel: "npm", runtime: "node-22.18.0" }, + home: HOME, + root: NPX_ROOT, + firstRun: () => true, + }); + await reporter.checkIn(); + const error = new Error("fetch failed: getaddrinfo ENOTFOUND bobs-pc.local"); + error.stack = [ + "Error: connect ECONNREFUSED 192.168.1.5:4317", + " at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1611:16)", + ` at dial (file://${NPX_ROOT}/src/server/lan-engine.mjs:515:5)`, + ].join("\n"); + + expect(await reporter.reportError("server", error)).toBe(true); + + const body = sent.at(-1)!; + const text = JSON.stringify(body); + for (const word of ["bobs-pc", "192.168"]) expect(text).not.toContain(word); + expect(body.message).toBe("fetch failed: getaddrinfo ENOTFOUND "); + expect(String(body.stack)).toContain("Error: connect ECONNREFUSED :4317"); + expect(String(body.stack)).toContain("at dial (ccdeck/src/server/lan-engine.mjs:515:5)"); + }); +}); diff --git a/src/web/report-errors.ts b/src/web/report-errors.ts index 39bfd71fe..2db999753 100644 --- a/src/web/report-errors.ts +++ b/src/web/report-errors.ts @@ -42,14 +42,27 @@ function hideOrigin(_origin: string, scheme: string, assets?: string): string { return assets ? "" : `${scheme}://`; } +// The bare-address and host-name patterns of `scrub`, the same text +// (error-report-addresses.test.ts holds the two together): see there. +const OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`; +const IPV4 = String.raw`${OCTET}(?:\.${OCTET}){3}`; +const HEX = String.raw`[0-9A-Fa-f]{1,4}`; +const IPV6 = String.raw`(?=[0-9A-Fa-f:]*\d)(?=[0-9A-Fa-f:]*::|(?:${HEX}:){7})(?:(?:${HEX})?:)+(?:${IPV4}|${HEX})?`; +const ZONE = String.raw`%[\w-]+(?:\.[\w-]+)*`; +const KEPT_IPV6 = String.raw`(?:::1?|::ffff:(?:127(?:\.\d{1,3}){3}|0\.0\.0\.0))`; +export const ADDRESS_PATTERN = String.raw`(?`), project folders in Claude Code's encoding (``), the host - * of every address (``, ``), email addresses and strings shaped - * like keys or tokens. That server scrub is still the one that runs on every + * of every address (``, ``), every bare IP address and the machine + * name a network error carries (``, the loopback kept), email addresses + * and strings shaped like keys or tokens. That server scrub is still the one that runs on every * error the page forwards, and the one the README's promise rests on; this is * the same shape done in the browser for the words a person reads before Send — * feedback is not scrubbed on its way through the server, so a path must never @@ -65,6 +78,8 @@ export function scrubReport(text: string): string { .replace(new RegExp(PATH_PATTERN, "g"), hidePath) .replace(new RegExp(PROJECT_PATTERN, "g"), "") .replace(new RegExp(ORIGIN_PATTERN, "g"), hideOrigin) + .replace(new RegExp(ADDRESS_PATTERN, "g"), "") + .replace(new RegExp(HOST_PATTERN, "g"), "") .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "") .replace( /\b(?:sk-ant-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abpr]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}|[A-Fa-f0-9]{40,}|[A-Za-z0-9+_=-]{48,})/g,