@promptctl/cc-candybar 1.21.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.
- package/dist/index.mjs +79 -79
- package/package.json +5 -5
- package/src/daemon/fork-bomb-breaker.ts +351 -0
- package/src/daemon/parent-watchdog.ts +8 -1
- package/src/daemon/paths.ts +36 -8
- package/src/daemon/process-fingerprint.ts +11 -0
- package/src/daemon/server.ts +67 -18
- package/src/daemon/socket-lease.ts +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -95,10 +95,10 @@
|
|
|
95
95
|
"mobx": "^6.15.0"
|
|
96
96
|
},
|
|
97
97
|
"optionalDependencies": {
|
|
98
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.
|
|
99
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
100
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
101
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
98
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.22.0",
|
|
99
|
+
"@promptctl/cc-candybar-darwin-x64": "1.22.0",
|
|
100
|
+
"@promptctl/cc-candybar-linux-x64": "1.22.0",
|
|
101
|
+
"@promptctl/cc-candybar-linux-arm64": "1.22.0"
|
|
102
102
|
},
|
|
103
103
|
"pnpm": {
|
|
104
104
|
"supportedArchitectures": {
|
|
@@ -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
|
-
|
|
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" }
|
package/src/daemon/paths.ts
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
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(
|
|
79
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
74
80
|
|
|
75
|
-
const st = fs.lstatSync(
|
|
81
|
+
const st = fs.lstatSync(dir);
|
|
76
82
|
if (st.isSymbolicLink()) {
|
|
77
|
-
throw new Error(`
|
|
83
|
+
throw new Error(`directory is a symlink: ${dir}`);
|
|
78
84
|
}
|
|
79
85
|
if (!st.isDirectory()) {
|
|
80
|
-
throw new Error(`
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
package/src/daemon/server.ts
CHANGED
|
@@ -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,
|
|
@@ -141,28 +147,23 @@ const BIN_CHECK_INTERVAL_MS = 60 * 1000;
|
|
|
141
147
|
// kill(pid,0), no worse than before the fingerprint existed.
|
|
142
148
|
let myStartTime: string | null = null;
|
|
143
149
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
);
|
|
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;
|
|
162
156
|
|
|
157
|
+
export function runDaemon(): void {
|
|
163
158
|
// Catch-alls log + exit so the supervisor (the next client) can restart us.
|
|
164
159
|
// [LAW:no-defensive-null-guards] These are *trust boundaries* — we are
|
|
165
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.
|
|
166
167
|
process.on("uncaughtException", (err) => {
|
|
167
168
|
dlog("error", `uncaughtException: ${err.stack || err.message}`);
|
|
168
169
|
shutdown(1);
|
|
@@ -178,6 +179,42 @@ export function runDaemon(): void {
|
|
|
178
179
|
});
|
|
179
180
|
}
|
|
180
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
|
+
|
|
181
218
|
// [LAW:single-enforcer] Same death funnel as the signals and the RSS backstop:
|
|
182
219
|
// the watchdog calls shutdown(0), it never exits on its own. A production
|
|
183
220
|
// daemon has no spawner to outlive (env unset) and arms an inert handle; only
|
|
@@ -515,6 +552,18 @@ function shutdown(code: number): void {
|
|
|
515
552
|
// the live owner's lease on its way out, or the next EADDRINUSE would read
|
|
516
553
|
// `absent` and reclaim the thief's live socket — cascading the theft.
|
|
517
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
|
+
}
|
|
518
567
|
closeLog();
|
|
519
568
|
process.exit(code);
|
|
520
569
|
}
|
|
@@ -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"
|
|
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
|