@promptctl/cc-candybar 1.20.0 → 1.22.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.
@@ -0,0 +1,351 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { daemonRegistryDir, ensureOwnedPrivateDir } from "./paths";
5
+ import {
6
+ readStartTime,
7
+ sameLiveProcess,
8
+ type ProcessIdentity,
9
+ } from "./process-fingerprint";
10
+ import { pidAlive } from "./parent-watchdog";
11
+
12
+ // ─── Daemon-side fork-bomb circuit breaker ───────────────────────────────────
13
+ //
14
+ // [FRAMING:representation] The 192-daemon storm (epic brandon-daemon-lifecycle-
15
+ // gad) happened because every existing single-instance guard (atomic bind(),
16
+ // the socket lease, the ownership self-check, the spawn cooldown) keys off ONE
17
+ // socket path — daemons on DIFFERENT sockets (test isolation's per-file
18
+ // CC_CANDYBAR_SOCKET) never arbitrate each other and pile up unboundedly. .1
19
+ // (test/helpers/daemon-pool.ts) bounds that from the SPAWNER side, but the
20
+ // spawner's own cleanup (afterAll, globalTeardown) fails under the exact
21
+ // fork-exhaustion condition it exists to prevent. This module is the
22
+ // load-INDEPENDENT backstop: a daemon refuses to boot past a sibling ceiling
23
+ // using only its own startup-time read of a shared registry — no external
24
+ // cleanup path required for the invariant to hold.
25
+ //
26
+ // [LAW:one-source-of-truth] The registry lives at daemonRegistryDir() (paths.ts)
27
+ // — a fixed, UID-anchored /tmp path that, like socketPath(), deliberately
28
+ // ignores XDG_STATE_HOME, so isolation overrides can't hide a daemon from the
29
+ // count. Every daemon that does NOT explicitly override
30
+ // CC_CANDYBAR_DAEMON_REGISTRY_DIR lands in the same directory.
31
+ //
32
+ // [FRAMING:representation] The production daemon is a different POPULATION
33
+ // than an isolated (test/dev) instance, not a smaller version of the same one:
34
+ // it is already bounded to exactly one by bind()'s kernel-enforced exclusion on
35
+ // the canonical socket path, so no ceiling can ever be its failure mode — only
36
+ // isolation (an explicit CC_CANDYBAR_SOCKET override) creates the "many
37
+ // coexisting instances" population this breaker exists to bound. Classifying by
38
+ // "is CC_CANDYBAR_SOCKET set" keeps the two populations from ever counting
39
+ // against each other: the production daemon is exempt (and so always boots,
40
+ // however many isolated instances are registered), and isolated instances
41
+ // compete only with each other over the shared ceiling.
42
+ //
43
+ // [FRAMING:representation] admitDaemon's count-then-write (read the registry,
44
+ // decide, write our own entry) is NOT a compare-and-swap — the same accepted
45
+ // tradeoff as test/helpers/daemon-pool.ts's tryClaim. Two daemons starting in
46
+ // the same instant can both observe the same below-ceiling count and both
47
+ // admit, so the ceiling is a soft bound (liveCount can briefly overshoot by
48
+ // the number of true simultaneous spawns), not a strict mutex. A real fix
49
+ // needs a cross-process lock (flock, an O_EXCL pre-registration file); skipped
50
+ // as disproportionate here — this is a load-independent BACKSTOP against a
51
+ // 192-daemon storm, not a precision gate, and the ticket's own acceptance
52
+ // criterion is "a small, asserted ceiling", never exact atomicity. The
53
+ // failure mode of the race is a brief, bounded overshoot that the next boot's
54
+ // stale-sweep does not even need to correct (the overshooting daemons are
55
+ // live, not stale) — categorically smaller than the storm this breaker
56
+ // exists to prevent.
57
+
58
+ export interface BootDecision {
59
+ allow: boolean;
60
+ reason: string;
61
+ }
62
+
63
+ const DEFAULT_CEILING = 16;
64
+
65
+ // [LAW:no-silent-failure] `Number(...)`, not `parseInt(...)` — parseInt
66
+ // truncates trailing garbage ("16o" reads as 16, silently accepting a typo
67
+ // that likely meant 160) instead of surfacing it. `Number` requires the
68
+ // WHOLE string to be numeric, so a typo becomes NaN and falls through to the
69
+ // default like any other garbage value.
70
+ export function daemonCeiling(): number {
71
+ const raw = Number(process.env["CC_CANDYBAR_DAEMON_CEILING"] ?? "");
72
+ return Number.isInteger(raw) && raw > 0 ? raw : DEFAULT_CEILING;
73
+ }
74
+
75
+ // [LAW:dataflow-not-control-flow] The whole decision is this one pure fold —
76
+ // full input space:
77
+ // isolated=false → allow, unconditionally (production is
78
+ // already singular via bind(); a
79
+ // ceiling here could only ever refuse
80
+ // the user's one real daemon, which the
81
+ // epic requires never happens)
82
+ // isolated=true, count < ceiling → allow (below the backstop)
83
+ // isolated=true, count >= ceiling → deny (the fork-bomb condition)
84
+ // [LAW:no-silent-failure] "Fails safe" is achieved by construction here, not by
85
+ // a guard clause: an unreadable/uncountable population reads as count=0 (see
86
+ // countLiveEntries), which always falls in the `allow` branch — the failure
87
+ // direction is never "refuse to boot", it is "undercount and allow".
88
+ export function decideBoot(
89
+ isolated: boolean,
90
+ liveSiblingCount: number,
91
+ ceiling: number,
92
+ ): BootDecision {
93
+ if (!isolated) {
94
+ return {
95
+ allow: true,
96
+ reason:
97
+ "canonical production socket — exempt (bind() already caps it to one)",
98
+ };
99
+ }
100
+ if (liveSiblingCount >= ceiling) {
101
+ return {
102
+ allow: false,
103
+ reason: `${liveSiblingCount} live isolated daemons registered >= ceiling ${ceiling}`,
104
+ };
105
+ }
106
+ return {
107
+ allow: true,
108
+ reason: `${liveSiblingCount} live isolated daemons registered < ceiling ${ceiling}`,
109
+ };
110
+ }
111
+
112
+ // A registry entry read, alongside its source path so a stale one can be
113
+ // swept. `null` is every unreadable/corrupt/absent-pid outcome — collapsed
114
+ // early here (unlike readLease's richer enumeration) because the only
115
+ // downstream use is "count it or don't"; there is no distinct action for
116
+ // "unreadable" vs "absent" the way socket-lease arbitration has one.
117
+ export interface RegistryEntry {
118
+ path: string;
119
+ identity: ProcessIdentity;
120
+ }
121
+
122
+ // [LAW:no-silent-failure] Never throws: a directory that doesn't exist yet (no
123
+ // isolated daemon has ever registered) or is transiently unreadable both mean
124
+ // "no known siblings", which is the fail-open direction decideBoot expects.
125
+ export function listRegistryFiles(dir: string): string[] {
126
+ try {
127
+ return fs
128
+ .readdirSync(dir)
129
+ .filter((f) => f.endsWith(".json"))
130
+ .map((f) => path.join(dir, f));
131
+ } catch {
132
+ return [];
133
+ }
134
+ }
135
+
136
+ // Mirrors readSlot (test/helpers/daemon-pool.ts) / readLease's pid validation
137
+ // (socket-lease.ts): a corrupt or unreadable file is excluded from the count
138
+ // rather than treated as a special decision branch — see the module header for
139
+ // why undercounting, never overcounting, is the safe direction here.
140
+ export function readRegistryEntry(filePath: string): ProcessIdentity | null {
141
+ let raw: string;
142
+ try {
143
+ raw = fs.readFileSync(filePath, "utf8");
144
+ } catch {
145
+ return null;
146
+ }
147
+ try {
148
+ const parsed = JSON.parse(raw) as Partial<ProcessIdentity> | null;
149
+ const pid = parsed?.pid;
150
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
151
+ return null;
152
+ }
153
+ const startTime =
154
+ typeof parsed?.startTime === "string" ? parsed.startTime : null;
155
+ return { pid, startTime };
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ // [LAW:dataflow-not-control-flow] Pure fold over already-read entries + an
162
+ // injected liveness predicate — full branch coverage needs no fs, no real
163
+ // processes: an empty list, an all-dead list, an all-live list, and a mixed
164
+ // list are the entire input space. `sweepStale`, kept in the same pass rather
165
+ // than a second read, is the effect side; the count itself never depends on
166
+ // whether the sweep succeeds.
167
+ export function countLiveEntries(
168
+ entries: readonly RegistryEntry[],
169
+ isSameLiveProcess: (pid: number, startTime: string | null) => boolean,
170
+ sweepStale: (filePath: string) => void,
171
+ ): number {
172
+ let count = 0;
173
+ for (const entry of entries) {
174
+ if (isSameLiveProcess(entry.identity.pid, entry.identity.startTime)) {
175
+ count++;
176
+ } else {
177
+ sweepStale(entry.path);
178
+ }
179
+ }
180
+ return count;
181
+ }
182
+
183
+ export interface BreakerDeps {
184
+ isolated: boolean;
185
+ registryDir: string;
186
+ ceiling: number;
187
+ pid: number;
188
+ startTime: string | null;
189
+ isSameLiveProcess: (pid: number, startTime: string | null) => boolean;
190
+ listFiles: (dir: string) => string[];
191
+ readEntry: (filePath: string) => ProcessIdentity | null;
192
+ removeFile: (filePath: string) => void;
193
+ writeEntry: (filePath: string, identity: ProcessIdentity) => void;
194
+ ensureDirSafe: (dir: string) => void;
195
+ }
196
+
197
+ export interface BreakerResult {
198
+ decision: BootDecision;
199
+ // The path this daemon registered at, or null when exempt/refused. Callers
200
+ // that boot successfully thread this into their shutdown cleanup so the slot
201
+ // is released promptly instead of waiting for the next boot's stale-sweep.
202
+ registryPath: string | null;
203
+ }
204
+
205
+ // [LAW:effects-at-boundaries] The one place that turns the pure fold into a
206
+ // boot/refuse decision by reading + writing the real registry. Exempt
207
+ // (production) daemons never touch the registry at all — not even to read
208
+ // it — so a corrupt or unreadable registry can never affect the one instance
209
+ // the epic requires to always boot.
210
+ export function admitDaemon(deps: BreakerDeps): BreakerResult {
211
+ if (!deps.isolated) {
212
+ return { decision: decideBoot(false, 0, deps.ceiling), registryPath: null };
213
+ }
214
+ // [LAW:single-enforcer] `ensureDirSafe` — not a bare `ensureOwnedPrivateDir`
215
+ // call — because the safety boundary differs by registry: the default,
216
+ // UID-anchored registry sits under the same shared /tmp root the socket
217
+ // does and needs the two-level check `realBreakerDeps` builds for it (see
218
+ // its comment); an overridden registry (tests) is the caller's own
219
+ // directory and needs only the one-level leaf check. `admitDaemon` stays
220
+ // agnostic to which — it just asks the injected dependency to prove the
221
+ // directory is safe to use.
222
+ deps.ensureDirSafe(deps.registryDir);
223
+ const entries: RegistryEntry[] = [];
224
+ for (const filePath of deps.listFiles(deps.registryDir)) {
225
+ const identity = deps.readEntry(filePath);
226
+ // [LAW:no-silent-failure] Exclude any entry named with OUR OWN pid,
227
+ // unconditionally — no other currently-live process can ever share it
228
+ // (the kernel guarantees pid uniqueness among live processes), so such an
229
+ // entry is always either a stale pid-recycled ghost from a past
230
+ // incarnation, or moot (we haven't written our own entry yet). This
231
+ // matters specifically when `ps` is unavailable: `isSameLiveProcess`'s
232
+ // fallback (bare `pidAlive`) would read OUR OWN pid as alive and
233
+ // misclassify the ghost as a live sibling, consuming a ceiling slot and
234
+ // risking a spurious refusal of the one daemon that pid actually names.
235
+ // Excluding it here means it never reaches that ambiguous check at all.
236
+ if (identity !== null && identity.pid !== deps.pid) {
237
+ entries.push({ path: filePath, identity });
238
+ }
239
+ }
240
+ const liveCount = countLiveEntries(
241
+ entries,
242
+ deps.isSameLiveProcess,
243
+ deps.removeFile,
244
+ );
245
+ const decision = decideBoot(true, liveCount, deps.ceiling);
246
+ if (!decision.allow) {
247
+ return { decision, registryPath: null };
248
+ }
249
+ const registryPath = path.join(deps.registryDir, `pid-${deps.pid}.json`);
250
+ deps.writeEntry(registryPath, { pid: deps.pid, startTime: deps.startTime });
251
+ return { decision, registryPath };
252
+ }
253
+
254
+ // Best-effort self-cleanup on shutdown — mirrors removeLeaseIfOwned
255
+ // (socket-lease.ts): only remove the entry if it still names us, so a
256
+ // displaced/superseded record from a different process is never deleted.
257
+ export function releaseRegistration(
258
+ registryPath: string,
259
+ myPid: number,
260
+ readEntry: (filePath: string) => ProcessIdentity | null,
261
+ removeFile: (filePath: string) => void,
262
+ ): void {
263
+ const entry = readEntry(registryPath);
264
+ if (entry !== null && entry.pid === myPid) {
265
+ try {
266
+ removeFile(registryPath);
267
+ } catch {
268
+ // Best-effort; a leftover entry naming a dead pid is harmless — the
269
+ // next boot's sweep reclaims it.
270
+ }
271
+ }
272
+ }
273
+
274
+ // `myStartTime` is threaded in rather than recomputed here so callers (only
275
+ // server.ts today) fingerprint themselves exactly once at startup and reuse
276
+ // that same read for both the registry entry and the socket lease — two
277
+ // independent `ps` calls could theoretically observe different processes if
278
+ // this pid were somehow recycled between them.
279
+ export function realBreakerDeps(
280
+ myStartTime: string | null,
281
+ overrides: Partial<BreakerDeps> = {},
282
+ ): BreakerDeps {
283
+ return {
284
+ isolated: Boolean(process.env["CC_CANDYBAR_SOCKET"]),
285
+ registryDir: daemonRegistryDir(),
286
+ ceiling: daemonCeiling(),
287
+ pid: process.pid,
288
+ startTime: myStartTime,
289
+ isSameLiveProcess: (pid, startTime) =>
290
+ sameLiveProcess(pid, startTime, { readStartTime, pidAlive }),
291
+ listFiles: listRegistryFiles,
292
+ readEntry: readRegistryEntry,
293
+ removeFile: (filePath) => {
294
+ try {
295
+ fs.unlinkSync(filePath);
296
+ } catch {
297
+ // best-effort
298
+ }
299
+ },
300
+ // [LAW:one-source-of-truth] Same write-tmp-then-rename shape as
301
+ // socket-lease.ts's writeLease, so it gets the same cleanup: if
302
+ // writeFileSync succeeds but renameSync fails, best-effort unlink the tmp
303
+ // file (tolerating ENOENT — writeFileSync itself may have been what
304
+ // failed) before rethrowing, so a write failure never leaves an orphaned
305
+ // `.tmp` file behind (listRegistryFiles only collects `*.json`, so a
306
+ // stray `.tmp` would never be swept).
307
+ writeEntry: (filePath, identity) => {
308
+ const tmp = `${filePath}.${identity.pid}.tmp`;
309
+ try {
310
+ fs.writeFileSync(tmp, JSON.stringify(identity), { mode: 0o600 });
311
+ fs.renameSync(tmp, filePath);
312
+ } catch (e) {
313
+ try {
314
+ fs.unlinkSync(tmp);
315
+ } catch (cleanupErr) {
316
+ if ((cleanupErr as NodeJS.ErrnoException).code !== "ENOENT") {
317
+ // Best-effort cleanup failed for a reason other than "never
318
+ // created" — the original error is still the one that matters,
319
+ // so it is not swallowed; a leaked tmp file here is a secondary
320
+ // symptom the next admission's stale-sweep does not reclaim
321
+ // (only *.json is collected), but it is not this daemon's job to
322
+ // retry a failing filesystem.
323
+ }
324
+ }
325
+ throw e;
326
+ }
327
+ },
328
+ // [LAW:single-enforcer] Two levels, mirroring ensureSocketParentSafe's own
329
+ // shape, but ONLY for the default (unoverridden) registry path: its
330
+ // parent is the shared UID-anchored /tmp root an attacker could pre-plant
331
+ // as a symlink before any daemon has ever run, and `lstatSync` only
332
+ // inspects a path's FINAL component — verifying the leaf alone lets a
333
+ // symlinked parent be silently followed by `mkdirSync({recursive:true})`,
334
+ // after which the freshly-created leaf looks perfectly clean (owned by
335
+ // us, 0700) despite living inside attacker-controlled storage. An
336
+ // OVERRIDDEN registry dir (CC_CANDYBAR_DAEMON_REGISTRY_DIR, tests only)
337
+ // has no such shared root by construction — its parent is whatever
338
+ // directory the caller happened to put it under (a system tmpdir on some
339
+ // platforms), which is not a boundary this breaker owns or should assert
340
+ // on; there the one-level leaf check alone is the correct, portable
341
+ // parity with how ensureSocketParentSafe treats an overridden
342
+ // CC_CANDYBAR_SOCKET (exactly one level, whatever that parent is).
343
+ ensureDirSafe: process.env["CC_CANDYBAR_DAEMON_REGISTRY_DIR"]
344
+ ? ensureOwnedPrivateDir
345
+ : (dir: string): void => {
346
+ ensureOwnedPrivateDir(path.dirname(dir));
347
+ ensureOwnedPrivateDir(dir);
348
+ },
349
+ ...overrides,
350
+ };
351
+ }
@@ -20,7 +20,14 @@ import process from "node:process";
20
20
 
21
21
  export const PARENT_PID_ENV = "CC_CANDYBAR_PARENT_PID";
22
22
 
23
- const DEFAULT_POLL_INTERVAL_MS = 1000;
23
+ // [LAW:verifiable-goals] Only ever consulted for an ANCHORED (test) daemon —
24
+ // "outlives-nobody" (the production daemon) arms no timer at all, so
25
+ // tightening this is invisible to production. A dead-runner orphan can live
26
+ // at most one poll tick before the watchdog trips; 250ms (down from 1000ms,
27
+ // brandon-daemon-lifecycle-gad.1) shrinks that window fourfold without
28
+ // meaningfully increasing CPU — polling a single `kill(pid,0)` 4x/sec is
29
+ // negligible next to the daemon work it guards.
30
+ const DEFAULT_POLL_INTERVAL_MS = 250;
24
31
 
25
32
  export type LivenessAnchor =
26
33
  | { kind: "outlives-nobody" }
@@ -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.
@@ -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
@@ -63,6 +63,12 @@ export interface RenderPayload extends ClaudeHookData {
63
63
  // domain truth is "always present". A `?` here would let a callsite believe it
64
64
  // could be undefined and guard defensively against an impossibility.
65
65
  readonly theme: { readonly effective: string };
66
+ // [LAW:one-type-per-behavior] The daemon-resolved effective LOOK name —
67
+ // effectiveLookName(sessionState.look, globals.look, looks) — the exact twin
68
+ // of `theme` one dimension over: the SAME name whose ThemeKey adapts the
69
+ // rendered palette, surfaced so a trigger label can display the active look.
70
+ // Required for the same reason as theme: resolved unconditionally per render.
71
+ readonly look: { readonly effective: string };
66
72
 
67
73
  // Usage-family. Each provider returns null when it has no data (no
68
74
  // transcript yet, no rate-limit window active, etc.); we drop the field
@@ -582,6 +588,11 @@ export async function buildRenderPayload(
582
588
  // in (not re-resolved here) because the daemon already computes it for the
583
589
  // palette; this is that same value, threaded to the sole payload assembler.
584
590
  effectiveTheme: string,
591
+ // [LAW:one-source-of-truth] The effective look name, resolved ONCE by the
592
+ // daemon beside the theme (effectiveLookName over SessionState/globals/looks)
593
+ // and used for BOTH the rendered adaptation and this payload field — so a
594
+ // trigger label reading `.look.effective` can never disagree with the colors.
595
+ effectiveLook: string,
585
596
  ): Promise<RenderPayload> {
586
597
  const wants = (prefix: string): boolean =>
587
598
  anyPathStartsWith(neededInputPaths, prefix);
@@ -788,6 +799,8 @@ export async function buildRenderPayload(
788
799
  // No `wants` gate: it costs nothing (a string already in hand) and a
789
800
  // config that reads `.theme.effective` must always find it.
790
801
  theme: { effective: effectiveTheme },
802
+ // Same contract as theme: always present, a string already in hand.
803
+ look: { effective: effectiveLook },
791
804
  ...(sessionPayload !== undefined && { session: sessionPayload }),
792
805
  ...(todayPayload !== undefined && { today: todayPayload }),
793
806
  ...(costPerHour !== undefined && { burn: { costPerHour } }),
@@ -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,
@@ -59,6 +65,8 @@ import { renderDsl } from "../dsl/render.js";
59
65
  import {
60
66
  effectiveStripStyle,
61
67
  effectiveThemeName,
68
+ effectiveLookName,
69
+ lookKeyByName,
62
70
  resolverForThemeName,
63
71
  } from "../themes/index.js";
64
72
  import {
@@ -139,28 +147,23 @@ const BIN_CHECK_INTERVAL_MS = 60 * 1000;
139
147
  // kill(pid,0), no worse than before the fingerprint existed.
140
148
  let myStartTime: string | null = null;
141
149
 
142
- export function runDaemon(): void {
143
- fs.mkdirSync(daemonDir(), { recursive: true });
144
- myStartTime = readOwnStartTime(process.pid);
145
- // [LAW:single-enforcer] Verify the socket parent is uid==me + mode 0700 +
146
- // not a symlink before we bind. Without this check, a same-host attacker
147
- // could pre-create the predictable `/tmp/cc-candybar-<uid>` directory and
148
- // squat the socket name. The check applies regardless of CC_CANDYBAR_SOCKET
149
- // location — every bind path goes through the same trust precondition.
150
- // No symmetric client-side check: the daemon is the sole creator, so a
151
- // successful bind already proves the parent is trusted. Failure here surfaces
152
- // as a daemon exit; the client falls back to the last cached render.
153
- ensureSocketParentSafe(socketPath());
154
-
155
- // Bind disk persistence now that we know we are the daemon process — load
156
- // prior session state and become the sole writer of the state file.
157
- sessionState.useStorage(
158
- new FileSessionStorage(sessionStatePath(), 500, dlog),
159
- );
150
+ // The registry path this daemon claimed in the fork-bomb breaker's population
151
+ // registry (fork-bomb-breaker.ts), or null when exempt (the canonical
152
+ // production socket) or never reached (refused before claiming one). Released
153
+ // on shutdown so a graceful exit frees its slot immediately rather than
154
+ // waiting for the next boot's stale-sweep.
155
+ let breakerRegistryPath: string | null = null;
160
156
 
157
+ export function runDaemon(): void {
161
158
  // Catch-alls log + exit so the supervisor (the next client) can restart us.
162
159
  // [LAW:no-defensive-null-guards] These are *trust boundaries* — we are
163
160
  // catching all of unknown space, not skipping known optional values.
161
+ // [LAW:single-enforcer] Registered FIRST, before any of the startup calls
162
+ // below that can throw synchronously (admitDaemon's ensureDirSafe/writeEntry,
163
+ // ensureSocketParentSafe) — otherwise an early throw is a raw unhandled
164
+ // exception (stack trace to stderr, bypassing the clean shutdown(1) log +
165
+ // SIGKILL backstop) rather than funneling through the same death path as
166
+ // every other failure mode.
164
167
  process.on("uncaughtException", (err) => {
165
168
  dlog("error", `uncaughtException: ${err.stack || err.message}`);
166
169
  shutdown(1);
@@ -176,6 +179,42 @@ export function runDaemon(): void {
176
179
  });
177
180
  }
178
181
 
182
+ // [LAW:single-enforcer] The fork-bomb circuit breaker runs FIRST among the
183
+ // resource-committing steps (no dir created, no socket touched, no session
184
+ // state loaded) — the whole point of a load-independent backstop is that it
185
+ // holds even when everything downstream of it is thrashing. Own start-time
186
+ // must be read first: it is both this check's identity and the lease's
187
+ // fingerprint later, so it is read exactly once and threaded through both
188
+ // (see realBreakerDeps' doc comment).
189
+ myStartTime = readOwnStartTime(process.pid);
190
+ const admission = admitDaemon(realBreakerDeps(myStartTime));
191
+ if (!admission.decision.allow) {
192
+ dlog(
193
+ "warn",
194
+ `fork-bomb breaker: ${admission.decision.reason}; refusing to boot`,
195
+ );
196
+ shutdown(1);
197
+ return;
198
+ }
199
+ breakerRegistryPath = admission.registryPath;
200
+
201
+ fs.mkdirSync(daemonDir(), { recursive: true });
202
+ // [LAW:single-enforcer] Verify the socket parent is uid==me + mode 0700 +
203
+ // not a symlink before we bind. Without this check, a same-host attacker
204
+ // could pre-create the predictable `/tmp/cc-candybar-<uid>` directory and
205
+ // squat the socket name. The check applies regardless of CC_CANDYBAR_SOCKET
206
+ // location — every bind path goes through the same trust precondition.
207
+ // No symmetric client-side check: the daemon is the sole creator, so a
208
+ // successful bind already proves the parent is trusted. Failure here surfaces
209
+ // as a daemon exit; the client falls back to the last cached render.
210
+ ensureSocketParentSafe(socketPath());
211
+
212
+ // Bind disk persistence now that we know we are the daemon process — load
213
+ // prior session state and become the sole writer of the state file.
214
+ sessionState.useStorage(
215
+ new FileSessionStorage(sessionStatePath(), 500, dlog),
216
+ );
217
+
179
218
  // [LAW:single-enforcer] Same death funnel as the signals and the RSS backstop:
180
219
  // the watchdog calls shutdown(0), it never exits on its own. A production
181
220
  // daemon has no spawner to outlive (env unset) and arms an inert handle; only
@@ -513,6 +552,18 @@ function shutdown(code: number): void {
513
552
  // the live owner's lease on its way out, or the next EADDRINUSE would read
514
553
  // `absent` and reclaim the thief's live socket — cascading the theft.
515
554
  removeLeaseIfOwned(leasePath(), process.pid);
555
+ // [LAW:one-source-of-truth] Same "only if it still names us" guard as the
556
+ // lease above, reused via releaseRegistration — a slot this daemon never
557
+ // claimed (exempt production, or refused before claiming one) is null and
558
+ // skipped.
559
+ if (breakerRegistryPath !== null) {
560
+ releaseRegistration(
561
+ breakerRegistryPath,
562
+ process.pid,
563
+ readRegistryEntry,
564
+ (p) => fs.unlinkSync(p),
565
+ );
566
+ }
516
567
  closeLog();
517
568
  process.exit(code);
518
569
  }
@@ -802,12 +853,24 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
802
853
  sessionState.get(req.hookData.session_id, "theme"),
803
854
  entry.state.config.globals.palette,
804
855
  );
856
+ // [LAW:one-type-per-behavior] The LOOK, resolved per render the exact
857
+ // way the theme is: the session's clicked look (SessionState) over the
858
+ // config default over the "none" floor — so a look click recolors the
859
+ // whole bar on the next render, composing with whatever theme is
860
+ // active. The name feeds the payload's `look.effective`; its ThemeKey
861
+ // (lookKeyByName) feeds renderDsl below — one resolution, two readers.
862
+ const effectiveLook = effectiveLookName(
863
+ sessionState.get(req.hookData.session_id, "look"),
864
+ entry.state.config.globals.look,
865
+ entry.state.config.looks,
866
+ );
805
867
  const payload = await buildRenderPayload(
806
868
  req.hookData,
807
869
  payloadDeps,
808
870
  req.cwd,
809
871
  entry.state.neededInputPaths,
810
872
  effectiveTheme,
873
+ effectiveLook,
811
874
  );
812
875
  // [LAW:one-source-of-truth][LAW:dataflow-not-control-flow] basePalette
813
876
  // is derived from the same effective theme resolved above — so a theme
@@ -869,7 +932,8 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
869
932
  // in place. Cells are cheap (already computed during the render);
870
933
  // the per-segment ANSI serialization happens lazily inside the
871
934
  // debug handler so normal renders pay no extra serializer cost.
872
- entry.state.lastRenderCellsBySegment,
935
+ { perSegmentSink: entry.state.lastRenderCellsBySegment },
936
+ lookKeyByName(entry.state.config.looks, effectiveLook),
873
937
  );
874
938
  }
875
939
  // [LAW:one-source-of-truth] Consume the transient click error written by