@promptctl/cc-candybar 1.21.0 → 1.23.0

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.
@@ -66,32 +66,43 @@ export function socketPath(): string {
66
66
  // Throws on any unsafe state; callers are expected to let the daemon exit.
67
67
  // [LAW:no-silent-fallbacks] do NOT auto-rmdir + recreate — a wrong-owner dir
68
68
  // is hostile state, not a recoverable error.
69
- export function ensureSocketParentSafe(sockPath: string): void {
70
- const parent = path.dirname(sockPath);
69
+ // [LAW:one-source-of-truth] The owner/mode/symlink verification a private
70
+ // per-uid directory needs is declared once here — both the socket parent
71
+ // (below) and the fork-bomb breaker's daemon registry dir
72
+ // (daemonRegistryDir(), fork-bomb-breaker.ts) sit under the same untrusted
73
+ // shared /tmp root and must reject the identical attack (a pre-created
74
+ // world-writable dir, a planted symlink), so they share one enforcer instead
75
+ // of two copies that could silently drift apart on what "safe" means.
76
+ export function ensureOwnedPrivateDir(dir: string): void {
71
77
  // mkdir with mode 0o700; harmless if already exists (mode is not applied
72
78
  // post-hoc — we verify it next).
73
- fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
79
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
74
80
 
75
- const st = fs.lstatSync(parent);
81
+ const st = fs.lstatSync(dir);
76
82
  if (st.isSymbolicLink()) {
77
- throw new Error(`socket parent is a symlink: ${parent}`);
83
+ throw new Error(`directory is a symlink: ${dir}`);
78
84
  }
79
85
  if (!st.isDirectory()) {
80
- throw new Error(`socket parent is not a directory: ${parent}`);
86
+ throw new Error(`not a directory: ${dir}`);
81
87
  }
82
88
  const myUid = os.userInfo().uid;
83
89
  // getuid is undefined on Windows; we don't ship there, but guard cheaply.
84
90
  if (typeof myUid === "number" && st.uid !== myUid) {
85
91
  throw new Error(
86
- `socket parent is not owned by uid ${myUid}: ${parent} (owner uid=${st.uid})`,
92
+ `directory is not owned by uid ${myUid}: ${dir} (owner uid=${st.uid})`,
87
93
  );
88
94
  }
89
95
  // Reject any group/world bits — only the owner may traverse.
90
96
  if ((st.mode & 0o077) !== 0) {
91
97
  throw new Error(
92
- `socket parent has unsafe permissions: ${parent} (mode=${(st.mode & 0o777).toString(8)}, expected 0700)`,
98
+ `directory has unsafe permissions: ${dir} (mode=${(st.mode & 0o777).toString(8)}, expected 0700)`,
93
99
  );
94
100
  }
101
+ }
102
+
103
+ export function ensureSocketParentSafe(sockPath: string): void {
104
+ const parent = path.dirname(sockPath);
105
+ ensureOwnedPrivateDir(parent);
95
106
  // If a stale socket file is a symlink, refuse — an attacker who briefly
96
107
  // had write access to a previously-permissive dir could have planted a
97
108
  // symlink even after we tighten perms.
@@ -138,6 +149,23 @@ export function sessionStatePath(): string {
138
149
  return path.join(stateDir(), "session-state.json");
139
150
  }
140
151
 
152
+ // [LAW:one-source-of-truth] The fork-bomb breaker's daemon-population registry
153
+ // (fork-bomb-breaker.ts) shares socketPath()'s UID-anchored /tmp root and, like
154
+ // it, deliberately ignores XDG_STATE_HOME — the very isolation
155
+ // `CC_CANDYBAR_SOCKET`/`XDG_STATE_HOME` overrides grant a test daemon is the
156
+ // thing this registry exists to see THROUGH, so every daemon on this machine
157
+ // (production and every isolated instance) that does not explicitly override
158
+ // this path lands in the same directory and is counted together.
159
+ // `CC_CANDYBAR_DAEMON_REGISTRY_DIR` is the explicit override, used only by
160
+ // tests of the breaker itself so they don't contend over the machine's real
161
+ // shared registry.
162
+ export function daemonRegistryDir(): string {
163
+ const override = process.env.CC_CANDYBAR_DAEMON_REGISTRY_DIR;
164
+ if (override) return override;
165
+ const uid = os.userInfo().uid;
166
+ return path.join("/tmp", `cc-candybar-${uid}`, "daemons");
167
+ }
168
+
141
169
  // [LAW:single-enforcer] Caller-side spawn dedup. Held by a client *only* during
142
170
  // the spawn window — never for the daemon's lifetime. The actual one-daemon
143
171
  // invariant is enforced by atomic bind() on socketPath() inside the daemon.
@@ -157,6 +185,17 @@ export function spawnCooldownPath(): string {
157
185
  return path.join(stateDir(), SPAWN_COOLDOWN_FILE);
158
186
  }
159
187
 
188
+ // [LAW:one-source-of-truth] Sibling of spawn.cooldown: that file's mtime
189
+ // answers "when was a spawn last attempted"; this file's content answers
190
+ // "how many attempts in a row have failed to converge on a live daemon" —
191
+ // the consecutive-non-convergence streak that widens the cooldown window
192
+ // (see effectiveCooldownMs in acquire.ts). Same filename mirrored TS↔Rust,
193
+ // diffed by scripts/check-protocol.mjs.
194
+ const SPAWN_BACKOFF_FILE = "spawn.backoff";
195
+ export function spawnBackoffPath(): string {
196
+ return path.join(stateDir(), SPAWN_BACKOFF_FILE);
197
+ }
198
+
160
199
  export function logPath(): string {
161
200
  return path.join(stateDir(), "daemon.log");
162
201
  }
@@ -25,6 +25,17 @@ import { launchSync, type LaunchOpts, type LaunchResult } from "../proc/launch";
25
25
  // `TZ=UTC` — so the token is a locale- and timezone-invariant UTC rendering of
26
26
  // the start instant, and equality is sound.
27
27
 
28
+ // [LAW:one-source-of-truth] The (pid, start-time) pair IS a process identity
29
+ // (see the file header) — every owner-of-a-resource record in this codebase
30
+ // (a socket lease, a test-pool slot) names its owner with exactly this shape.
31
+ // Declared once here, the module that owns the process-identity concept, so
32
+ // a future field addition to "what identifies a process" can't drift between
33
+ // independent copies.
34
+ export interface ProcessIdentity {
35
+ pid: number;
36
+ startTime: string | null;
37
+ }
38
+
28
39
  // A read of a pid's kernel start-time. Only TWO outcomes, because nothing
29
40
  // derivable from a `ps` exit code can SOUNDLY prove a process is dead — a
30
41
  // non-zero exit means "no start-time to report", which conflates a genuinely
@@ -27,6 +27,12 @@ import {
27
27
  readOwnStartTime,
28
28
  sameLiveProcess,
29
29
  } from "./process-fingerprint";
30
+ import {
31
+ admitDaemon,
32
+ realBreakerDeps,
33
+ releaseRegistration,
34
+ readRegistryEntry,
35
+ } from "./fork-bomb-breaker";
30
36
  import { dlog, closeLog } from "./log";
31
37
  import {
32
38
  PROTOCOL_VERSION,
@@ -42,6 +48,7 @@ import { WatcherRegistry } from "./cache/watchers";
42
48
  import { RuntimeStats } from "./stats";
43
49
  import { makeLimits, realLimitsDeps, type LimitsHandle } from "./limits";
44
50
  import { armParentWatchdog, anchorFromEnv, pidAlive } from "./parent-watchdog";
51
+ import { resetSpawnBackoff } from "./acquire";
45
52
  import { SessionState } from "./session-state";
46
53
  import { FileSessionStorage } from "./session-state-file";
47
54
  import { VERBS, BadVerbArgs, SESSION_CONFIG_OVERRIDE_KEY } from "./verbs";
@@ -141,28 +148,23 @@ const BIN_CHECK_INTERVAL_MS = 60 * 1000;
141
148
  // kill(pid,0), no worse than before the fingerprint existed.
142
149
  let myStartTime: string | null = null;
143
150
 
144
- export function runDaemon(): void {
145
- fs.mkdirSync(daemonDir(), { recursive: true });
146
- myStartTime = readOwnStartTime(process.pid);
147
- // [LAW:single-enforcer] Verify the socket parent is uid==me + mode 0700 +
148
- // not a symlink before we bind. Without this check, a same-host attacker
149
- // could pre-create the predictable `/tmp/cc-candybar-<uid>` directory and
150
- // squat the socket name. The check applies regardless of CC_CANDYBAR_SOCKET
151
- // location — every bind path goes through the same trust precondition.
152
- // No symmetric client-side check: the daemon is the sole creator, so a
153
- // successful bind already proves the parent is trusted. Failure here surfaces
154
- // as a daemon exit; the client falls back to the last cached render.
155
- ensureSocketParentSafe(socketPath());
156
-
157
- // Bind disk persistence now that we know we are the daemon process — load
158
- // prior session state and become the sole writer of the state file.
159
- sessionState.useStorage(
160
- new FileSessionStorage(sessionStatePath(), 500, dlog),
161
- );
151
+ // The registry path this daemon claimed in the fork-bomb breaker's population
152
+ // registry (fork-bomb-breaker.ts), or null when exempt (the canonical
153
+ // production socket) or never reached (refused before claiming one). Released
154
+ // on shutdown so a graceful exit frees its slot immediately rather than
155
+ // waiting for the next boot's stale-sweep.
156
+ let breakerRegistryPath: string | null = null;
162
157
 
158
+ export function runDaemon(): void {
163
159
  // Catch-alls log + exit so the supervisor (the next client) can restart us.
164
160
  // [LAW:no-defensive-null-guards] These are *trust boundaries* — we are
165
161
  // catching all of unknown space, not skipping known optional values.
162
+ // [LAW:single-enforcer] Registered FIRST, before any of the startup calls
163
+ // below that can throw synchronously (admitDaemon's ensureDirSafe/writeEntry,
164
+ // ensureSocketParentSafe) — otherwise an early throw is a raw unhandled
165
+ // exception (stack trace to stderr, bypassing the clean shutdown(1) log +
166
+ // SIGKILL backstop) rather than funneling through the same death path as
167
+ // every other failure mode.
166
168
  process.on("uncaughtException", (err) => {
167
169
  dlog("error", `uncaughtException: ${err.stack || err.message}`);
168
170
  shutdown(1);
@@ -178,6 +180,42 @@ export function runDaemon(): void {
178
180
  });
179
181
  }
180
182
 
183
+ // [LAW:single-enforcer] The fork-bomb circuit breaker runs FIRST among the
184
+ // resource-committing steps (no dir created, no socket touched, no session
185
+ // state loaded) — the whole point of a load-independent backstop is that it
186
+ // holds even when everything downstream of it is thrashing. Own start-time
187
+ // must be read first: it is both this check's identity and the lease's
188
+ // fingerprint later, so it is read exactly once and threaded through both
189
+ // (see realBreakerDeps' doc comment).
190
+ myStartTime = readOwnStartTime(process.pid);
191
+ const admission = admitDaemon(realBreakerDeps(myStartTime));
192
+ if (!admission.decision.allow) {
193
+ dlog(
194
+ "warn",
195
+ `fork-bomb breaker: ${admission.decision.reason}; refusing to boot`,
196
+ );
197
+ shutdown(1);
198
+ return;
199
+ }
200
+ breakerRegistryPath = admission.registryPath;
201
+
202
+ fs.mkdirSync(daemonDir(), { recursive: true });
203
+ // [LAW:single-enforcer] Verify the socket parent is uid==me + mode 0700 +
204
+ // not a symlink before we bind. Without this check, a same-host attacker
205
+ // could pre-create the predictable `/tmp/cc-candybar-<uid>` directory and
206
+ // squat the socket name. The check applies regardless of CC_CANDYBAR_SOCKET
207
+ // location — every bind path goes through the same trust precondition.
208
+ // No symmetric client-side check: the daemon is the sole creator, so a
209
+ // successful bind already proves the parent is trusted. Failure here surfaces
210
+ // as a daemon exit; the client falls back to the last cached render.
211
+ ensureSocketParentSafe(socketPath());
212
+
213
+ // Bind disk persistence now that we know we are the daemon process — load
214
+ // prior session state and become the sole writer of the state file.
215
+ sessionState.useStorage(
216
+ new FileSessionStorage(sessionStatePath(), 500, dlog),
217
+ );
218
+
181
219
  // [LAW:single-enforcer] Same death funnel as the signals and the RSS backstop:
182
220
  // the watchdog calls shutdown(0), it never exits on its own. A production
183
221
  // daemon has no spawner to outlive (env unset) and arms an inert handle; only
@@ -340,6 +378,11 @@ function onListening(sockPath: string): void {
340
378
  "info",
341
379
  `daemon up: pid=${process.pid} v=${PROTOCOL_VERSION} sock=${sockPath}`,
342
380
  );
381
+ // [LAW:single-enforcer] This bind is the one process-wide fact that answers
382
+ // "did an outage just end" — see resetSpawnBackoff's doc comment in
383
+ // acquire.ts. Any consecutive-spawn backoff accumulated getting here no
384
+ // longer applies once a daemon is actually serving.
385
+ resetSpawnBackoff();
343
386
  armBinaryWatch();
344
387
  armLimits();
345
388
  armOwnershipWatch(sockPath, boundRead.identity);
@@ -515,6 +558,18 @@ function shutdown(code: number): void {
515
558
  // the live owner's lease on its way out, or the next EADDRINUSE would read
516
559
  // `absent` and reclaim the thief's live socket — cascading the theft.
517
560
  removeLeaseIfOwned(leasePath(), process.pid);
561
+ // [LAW:one-source-of-truth] Same "only if it still names us" guard as the
562
+ // lease above, reused via releaseRegistration — a slot this daemon never
563
+ // claimed (exempt production, or refused before claiming one) is null and
564
+ // skipped.
565
+ if (breakerRegistryPath !== null) {
566
+ releaseRegistration(
567
+ breakerRegistryPath,
568
+ process.pid,
569
+ readRegistryEntry,
570
+ (p) => fs.unlinkSync(p),
571
+ );
572
+ }
518
573
  closeLog();
519
574
  process.exit(code);
520
575
  }
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import process from "node:process";
3
3
 
4
+ import type { ProcessIdentity } from "./process-fingerprint";
5
+
4
6
  // ─── Socket ownership lease ──────────────────────────────────────────────────
5
7
  //
6
8
  // [LAW:one-source-of-truth] The authority for "who owns this socket path" is a
@@ -30,7 +32,7 @@ import process from "node:process";
30
32
  export type LeaseRead =
31
33
  | { kind: "absent" }
32
34
  | { kind: "unreadable"; detail: string }
33
- | { kind: "owned"; pid: number; startTime: string | null };
35
+ | ({ kind: "owned" } & ProcessIdentity);
34
36
 
35
37
  // The EADDRINUSE arbitration outcome. The path already exists (something bound
36
38
  // it or a stale file remains); this says whether a LIVE owner holds it.
@@ -44,11 +46,9 @@ export type SocketArbitration =
44
46
  // root. `startTime` is the kernel start-time token (also human-readable, so it
45
47
  // doubles as the "daemon started at" diagnostic the old `startedAt` gave), or
46
48
  // null when this host could not fingerprint.
47
- export interface LeaseRecord {
48
- pid: number;
49
+ export interface LeaseRecord extends ProcessIdentity {
49
50
  version: number;
50
51
  binPath: string | undefined;
51
- startTime: string | null;
52
52
  }
53
53
 
54
54
  // [LAW:effects-at-boundaries][LAW:dataflow-not-control-flow] The whole
package/src/index.ts CHANGED
@@ -53,12 +53,14 @@ Configuration:
53
53
  to point at a specific file. See the default config for all available options:
54
54
  node dist/index.mjs debug --project-dir . --cwd .
55
55
 
56
- Subcommands (macOS):
57
- install One-shot setup: creates the URL handler app, registers
58
- the cc-candybar:// scheme, and writes the statusLine
59
- command into ~/.claude/settings.json.
60
- install-url-handler Just create + register the URL handler app
61
- (~/Applications/CCCandybarURLHandler.app).
56
+ Subcommands:
57
+ install One-shot setup: stages the runtime (native render
58
+ binary + dist bundle) at a stable path, creates the
59
+ URL handler app + cc-candybar:// scheme (macOS), and
60
+ writes the staged entry as the statusLine command in
61
+ ~/.claude/settings.json. Re-run to update.
62
+ install-url-handler Just stage the runtime and create + register the URL
63
+ handler app (macOS only).
62
64
  url-handle URL Internal — invoked by the URL handler app on
63
65
  cmd-click. Parses cc-candybar://<verb>/<value> and
64
66
  dispatches (currently: copy to clipboard).