@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.
- package/README.md +13 -9
- package/bin/cc-candybar +18 -6
- package/dist/index.mjs +89 -86
- package/package.json +6 -22
- package/src/daemon/acquire.ts +148 -12
- package/src/daemon/fork-bomb-breaker.ts +351 -0
- package/src/daemon/parent-watchdog.ts +8 -1
- package/src/daemon/paths.ts +47 -8
- package/src/daemon/process-fingerprint.ts +11 -0
- package/src/daemon/server.ts +73 -18
- package/src/daemon/socket-lease.ts +4 -4
- package/src/index.ts +8 -6
- package/src/install/index.ts +207 -85
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.
|
|
@@ -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
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
);
|
|
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"
|
|
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
|
|
57
|
-
install One-shot setup:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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).
|