Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<deck>/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 `<deck>/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

Expand Down
42 changes: 39 additions & 3 deletions src/server/reports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -392,6 +393,33 @@ function hideOrigin(_origin, scheme, assets) {
return assets ? "<deck>" : `${scheme}://<host>`;
}

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`(?<![\w:.%-])(?:\[(?!${KEPT_IPV6}\])${IPV6}(?:${ZONE})?\]|(?!${KEPT_IPV6}(?::\d{1,5})?(?![\w:%]|\.\d))${IPV6}(?:${ZONE})?(?![\w%]|\.\d))|(?<![\w./-])(?!127\.|0\.0\.0\.0\b)${IPV4}(?![\w-]|\.\d)`;

/** A host name, with a letter in it somewhere: one of all digits is an IPv4 address. */
const HOST = String.raw`(?=[\w.-]*[A-Za-z])[A-Za-z0-9][\w-]*(?:\.[\w-]+)*`;
/** A host name where a network error puts one: after a DNS or connect failure
* (`getaddrinfo ENOTFOUND bobs-pc`), glued to a port (`bobs-pc:4317`), or
* ending in a local-network suffix (.local, .lan, .home, .internal, .ts.net).
* Not localhost, and not a file:line:col frame: a port has no second number
* after it. */
export const HOST_PATTERN = String.raw`(?<![\w.@/\\%-])(?!localhost(?![\w-]|\.\w))(?:(?<=\b(?:(?:getaddrinfo|connect) E[A-Z_]+|ENOTFOUND|EAI_AGAIN) )${HOST}|${HOST}(?=:\d{1,5}(?![\w:-]|\.\w))|${HOST}\.(?:local|lan|home|internal|ts\.net)(?![\w-]|\.\w))`;

const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

/** Every spelling of `dir` a stack or a message can use — the path, and the
Expand All @@ -418,13 +446,19 @@ function spellings(dir) {
* by (`http://alices-macbook.local:4317/assets/…`); they become `<deck>/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
* `<host>`, 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 (~/.<secret>.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 ?? "");
Expand All @@ -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"), "<project>")
.replace(new RegExp(ORIGIN_PATTERN, "g"), hideOrigin)
.replace(new RegExp(ADDRESS_PATTERN, "g"), "<host>")
.replace(new RegExp(HOST_PATTERN, "g"), "<host>")
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<email>")
.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,
Expand Down
Loading
Loading