agent-dag 3.22.0 → 3.22.3
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 +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-3FWd7g_W.css +0 -1
- package/dist/web/assets/index-BOwtoP02.js +0 -266
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -392
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
package/src/server/deck-home.mjs
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
// Where the deck keeps its OWN things, as opposed to where Claude Code keeps
|
|
2
|
-
// its things.
|
|
3
|
-
//
|
|
4
|
-
// For a year those were one directory. `~/.claude/agent-dag/` held the hook
|
|
5
|
-
// Claude Code reads, the deck's private key, its pairings, its port registry
|
|
6
|
-
// and a hundred megabytes of event log — and the only reason was that the deck
|
|
7
|
-
// already knew that path, because it has to read `~/.claude` to do its job at
|
|
8
|
-
// all.
|
|
9
|
-
//
|
|
10
|
-
// THREE THINGS THAT COST, and none of them is tidiness:
|
|
11
|
-
//
|
|
12
|
-
// A person who deletes ~/.claude to fix Claude Code deletes this deck's
|
|
13
|
-
// identity. The key is what every paired machine has pinned, so it is not a
|
|
14
|
-
// setting that comes back — every colleague has to accept this deck again.
|
|
15
|
-
//
|
|
16
|
-
// CLAUDE_CONFIG_DIR is Claude Code's knob for keeping a second profile, and
|
|
17
|
-
// pointing it somewhere gave the deck a brand new identity and no pairings,
|
|
18
|
-
// silently. That is a footgun with a fifty-machine blast radius and no error
|
|
19
|
-
// message anywhere.
|
|
20
|
-
//
|
|
21
|
-
// The event log is a hundred megabytes of the deck's data inside another
|
|
22
|
-
// application's configuration directory. Nothing about a rotating log is
|
|
23
|
-
// configuration.
|
|
24
|
-
//
|
|
25
|
-
// So the deck's own state moves to where this platform keeps application data,
|
|
26
|
-
// and the two things that CANNOT move stay where they are: `hook.js`, because
|
|
27
|
-
// Claude Code reads it from there, and the per-pid port registry, because a
|
|
28
|
-
// deck of any version has to be able to list the others — see
|
|
29
|
-
// registeredDeckPorts, which reads that directory by name.
|
|
30
|
-
//
|
|
31
|
-
// The layout is env-paths', which is what Node command-line tools converge on,
|
|
32
|
-
// and its rule is the one worth repeating: XDG is a Linux specification, so it
|
|
33
|
-
// applies on Linux and nowhere else. macOS has Application Support and Logs;
|
|
34
|
-
// Windows has LocalAppData. A tool that puts ~/.config on a Mac is a tool that
|
|
35
|
-
// read half the page.
|
|
36
|
-
//
|
|
37
|
-
// NOTHING IS DELETED BY THE MOVE. Every file is COPIED to its new home and the
|
|
38
|
-
// original left where it was, so a deck downgraded to the version before this
|
|
39
|
-
// one finds exactly what it had. See migrateDeckFiles.
|
|
40
|
-
import { homedir } from "node:os";
|
|
41
|
-
import { join, posix as posixPath, win32 as winPath } from "node:path";
|
|
42
|
-
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
43
|
-
// The Windows ladder, not `rename` (#786). MoveFileExW refuses while any other
|
|
44
|
-
// process holds the destination open — a scanner, the indexer, a backup agent —
|
|
45
|
-
// and the file this one renames into place is a private key. Losing that rename
|
|
46
|
-
// to a virus scanner would be a deck with no identity, which is the one outcome
|
|
47
|
-
// this whole module is written to prevent.
|
|
48
|
-
import { renameWithRetry } from "./installer.mjs";
|
|
49
|
-
|
|
50
|
-
/** The name this deck files itself under. No `-nodejs` suffix, which env-paths
|
|
51
|
-
* adds to keep a script from colliding with a native app of the same name —
|
|
52
|
-
* there is no other ccdeck on any of these platforms. */
|
|
53
|
-
const APP = "ccdeck";
|
|
54
|
-
|
|
55
|
-
/** Point the deck's whole home somewhere else in one variable. The escape
|
|
56
|
-
* hatch for a portable install, a test, or anybody who simply wants it
|
|
57
|
-
* elsewhere; it wins over every rule below. */
|
|
58
|
-
export const HOME_ENV = "CCDECK_HOME";
|
|
59
|
-
|
|
60
|
-
/** Where the deck kept everything before this, and where two of its files still
|
|
61
|
-
* live. Also the place a migration reads from. */
|
|
62
|
-
export function legacyDeckDir(env = process.env, home = homedir(), platform = process.platform) {
|
|
63
|
-
const { join } = platform === "win32" ? winPath : posixPath;
|
|
64
|
-
return join(claudeConfigDir(env, home), "agent-dag");
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* The deck's own durable state: its key, its pairings, the accounts it offers.
|
|
69
|
-
*
|
|
70
|
-
* CLAUDE_CONFIG_DIR IS HONOURED RATHER THAN OVERRIDDEN, and that is deliberate.
|
|
71
|
-
* Somebody who set it has asked for a separate Claude profile and has been
|
|
72
|
-
* getting a separate deck along with it for a year — separate identity,
|
|
73
|
-
* separate pairings, separate share list. Moving those people to one
|
|
74
|
-
* machine-wide deck would collapse two profiles into one on upgrade, which is
|
|
75
|
-
* exactly the kind of silent change this file exists to stop. They keep what
|
|
76
|
-
* they have; everybody else moves.
|
|
77
|
-
*/
|
|
78
|
-
export function deckDataDir(platform = process.platform, env = process.env, home = homedir()) {
|
|
79
|
-
// THE TARGET PLATFORM'S SEPARATOR, NOT THE HOST'S — the same reason
|
|
80
|
-
// claudeCliCandidates does it, which is that the only way a Windows answer
|
|
81
|
-
// stays right is if it can be checked from a Mac. `node:path` joins with
|
|
82
|
-
// whatever the machine running the code uses, so a bare `join` here answers
|
|
83
|
-
// `\home\u\Library\...` on Windows for a question about macOS.
|
|
84
|
-
// `resolve` for the same reason as `join` just below it: it is host-bound
|
|
85
|
-
// too, so a posix path resolved on a Windows runner comes back with the
|
|
86
|
-
// runner's drive letter glued to the front of it.
|
|
87
|
-
const { join, resolve } = platform === "win32" ? winPath : posixPath;
|
|
88
|
-
const forced = env[HOME_ENV]?.trim();
|
|
89
|
-
if (forced) return resolve(forced);
|
|
90
|
-
if (env.CLAUDE_CONFIG_DIR?.trim()) return legacyDeckDir(env, home, platform);
|
|
91
|
-
if (platform === "darwin") return join(home, "Library", "Application Support", APP);
|
|
92
|
-
if (platform === "win32") return join(localAppData(env, home, platform), APP, "Data");
|
|
93
|
-
return join(env.XDG_DATA_HOME?.trim() || join(home, ".local", "share"), APP);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Where a log belongs, which is not where a preference belongs.
|
|
98
|
-
*
|
|
99
|
-
* On Linux that is the state directory rather than the data one: XDG separates
|
|
100
|
-
* "data the user would miss" from "state the program can rebuild", and an event
|
|
101
|
-
* log that rotates at 50 MB is the second. macOS and Windows have a named place
|
|
102
|
-
* for logs and this uses it.
|
|
103
|
-
*/
|
|
104
|
-
export function deckLogDir(platform = process.platform, env = process.env, home = homedir()) {
|
|
105
|
-
const { join, resolve } = platform === "win32" ? winPath : posixPath;
|
|
106
|
-
const forced = env[HOME_ENV]?.trim();
|
|
107
|
-
if (forced) return resolve(forced);
|
|
108
|
-
if (env.CLAUDE_CONFIG_DIR?.trim()) return legacyDeckDir(env, home, platform);
|
|
109
|
-
if (platform === "darwin") return join(home, "Library", "Logs", APP);
|
|
110
|
-
if (platform === "win32") return join(localAppData(env, home, platform), APP, "Log");
|
|
111
|
-
return join(env.XDG_STATE_HOME?.trim() || join(home, ".local", "state"), APP);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** %LOCALAPPDATA%, or the path it points at on every Windows since Vista. The
|
|
115
|
-
* fallback is not decoration: a service account or a stripped environment can
|
|
116
|
-
* reach this code with the variable unset, and a deck that throws there is a
|
|
117
|
-
* deck that will not start. */
|
|
118
|
-
function localAppData(env = process.env, home = homedir(), platform = process.platform) {
|
|
119
|
-
const { join } = platform === "win32" ? winPath : posixPath;
|
|
120
|
-
return env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local");
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* WHAT MOVES, and the two things that do not.
|
|
125
|
-
*
|
|
126
|
-
* `hook.js` stays: Claude Code reads it out of ~/.claude/agent-dag and would
|
|
127
|
-
* find nothing if it moved. The per-pid `NNNNN.json` files stay: a deck lists
|
|
128
|
-
* that directory to find the other decks on this machine, and a deck of the
|
|
129
|
-
* previous version reads it by name — move them and two versions running side
|
|
130
|
-
* by side stop seeing each other.
|
|
131
|
-
*
|
|
132
|
-
* `mode` is the file's, not the directory's, and 0600 on the preferences is
|
|
133
|
-
* load-bearing: it is the one file with a private key in it.
|
|
134
|
-
*/
|
|
135
|
-
export const MOVED = Object.freeze([
|
|
136
|
-
Object.freeze({ name: "prefs.json", where: "data", mode: 0o600 }),
|
|
137
|
-
Object.freeze({ name: "events.jsonl", where: "log", mode: 0o644 }),
|
|
138
|
-
Object.freeze({ name: "events.jsonl.1", where: "log", mode: 0o644 }),
|
|
139
|
-
]);
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Bring a deck's files to their new home, once, and never at the cost of one.
|
|
143
|
-
*
|
|
144
|
-
* FOUR RULES, and each one is a way this could have lost somebody's key:
|
|
145
|
-
*
|
|
146
|
-
* A destination that already exists is never touched. That is what makes
|
|
147
|
-
* this safe to run at every boot, and it is also what stops a stale copy in
|
|
148
|
-
* the old directory from overwriting live state later.
|
|
149
|
-
*
|
|
150
|
-
* The copy lands on a temp file in the destination directory and is renamed
|
|
151
|
-
* into place. A copy interrupted half-way is a truncated prefs.json, and a
|
|
152
|
-
* truncated prefs.json is a deck with no key — which is the whole thing this
|
|
153
|
-
* is trying not to do.
|
|
154
|
-
*
|
|
155
|
-
* The original is LEFT WHERE IT IS. Somebody who downgrades finds what they
|
|
156
|
-
* had. It costs four kilobytes and a hundred megabytes of log that was
|
|
157
|
-
* already there, and it buys a move nobody can be hurt by.
|
|
158
|
-
*
|
|
159
|
-
* A failure is reported and swallowed. A read-only home, a full disk, a
|
|
160
|
-
* permission the installer never had — none of those is a reason for the
|
|
161
|
-
* deck not to start, and the caller falls back to reading the old path.
|
|
162
|
-
*
|
|
163
|
-
* Returns what it did, so the caller can say so once rather than every boot.
|
|
164
|
-
*/
|
|
165
|
-
export async function migrateDeckFiles({
|
|
166
|
-
from,
|
|
167
|
-
data,
|
|
168
|
-
log,
|
|
169
|
-
fs,
|
|
170
|
-
/** Separate from `fs` because it is not `fs.rename`: see the import above. */
|
|
171
|
-
rename = renameWithRetry,
|
|
172
|
-
onError,
|
|
173
|
-
} = {}) {
|
|
174
|
-
const moved = [];
|
|
175
|
-
const failed = [];
|
|
176
|
-
for (const file of MOVED) {
|
|
177
|
-
const dir = file.where === "log" ? log : data;
|
|
178
|
-
const src = join(from, file.name);
|
|
179
|
-
const dst = join(dir, file.name);
|
|
180
|
-
if (src === dst) continue;
|
|
181
|
-
try {
|
|
182
|
-
// Both questions asked before anything is written: there is something to
|
|
183
|
-
// move, and there is nothing already there.
|
|
184
|
-
if (!(await exists(fs, src))) continue;
|
|
185
|
-
if (await exists(fs, dst)) continue;
|
|
186
|
-
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
187
|
-
const tmp = `${dst}.${process.pid}.migrating`;
|
|
188
|
-
await fs.copyFile(src, tmp);
|
|
189
|
-
await fs.chmod(tmp, file.mode).catch(() => {});
|
|
190
|
-
await rename(tmp, dst);
|
|
191
|
-
moved.push(file.name);
|
|
192
|
-
} catch (err) {
|
|
193
|
-
failed.push(file.name);
|
|
194
|
-
onError?.(file.name, err);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return { moved, failed };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
async function exists(fs, path) {
|
|
201
|
-
try { await fs.stat(path); return true; } catch { return false; }
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** An hour. A temp file younger than this may belong to a write happening in
|
|
205
|
-
* another deck right now, and deleting one of those turns an atomic write into
|
|
206
|
-
* a lost one. Nothing legitimate is an hour old: the rename that retires a
|
|
207
|
-
* temp file happens within milliseconds of its creation. */
|
|
208
|
-
export const TEMP_STALE_MS = 60 * 60 * 1000;
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* The litter an atomic write leaves when the process is killed between the
|
|
212
|
-
* write and the rename.
|
|
213
|
-
*
|
|
214
|
-
* Ninety-seven of them were found in one directory, the oldest six days old:
|
|
215
|
-
* two waves, one from decks that crashed and one from decks killed by hand
|
|
216
|
-
* mid-write. Nothing has ever swept them, because the code that makes them is
|
|
217
|
-
* the code that is not running any more when they are made.
|
|
218
|
-
*
|
|
219
|
-
* So the sweep belongs at START, where a process that is alive can clean up
|
|
220
|
-
* after the ones that are not — and it is deliberately blind to WHICH deck made
|
|
221
|
-
* a file, because it cannot know: it goes by age instead, which is a fact about
|
|
222
|
-
* the file rather than a guess about a pid.
|
|
223
|
-
*/
|
|
224
|
-
export async function sweepTempFiles({ dirs, fs, now = Date.now(), onError } = {}) {
|
|
225
|
-
let removed = 0;
|
|
226
|
-
for (const dir of dirs ?? []) {
|
|
227
|
-
let names;
|
|
228
|
-
try { names = await fs.readdir(dir); } catch { continue; }
|
|
229
|
-
for (const name of names) {
|
|
230
|
-
if (!name.endsWith(".tmp") && !name.endsWith(".migrating")) continue;
|
|
231
|
-
const path = join(dir, name);
|
|
232
|
-
try {
|
|
233
|
-
const st = await fs.stat(path);
|
|
234
|
-
if (now - st.mtimeMs < TEMP_STALE_MS) continue;
|
|
235
|
-
await fs.unlink(path);
|
|
236
|
-
removed++;
|
|
237
|
-
} catch (err) {
|
|
238
|
-
onError?.(path, err);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
return removed;
|
|
243
|
-
}
|
|
@@ -1,301 +0,0 @@
|
|
|
1
|
-
// The deck's own small settings, and the first of them: whether it may reach
|
|
2
|
-
// the desktop.
|
|
3
|
-
//
|
|
4
|
-
// WHY THIS FILE EXISTS. Everything the deck could be told before this was told
|
|
5
|
-
// through an environment variable read once at boot — AGENTS_DECK_NO_NOTIFY,
|
|
6
|
-
// AGENTS_DECK_NO_INSTALL, AGENTS_DECK_NO_DOWNLOAD. That is the right shape for
|
|
7
|
-
// "never do this on this machine", set by whoever launches the deck, and the
|
|
8
|
-
// wrong shape for a switch a person wants to flip because the notifications are
|
|
9
|
-
// annoying them right now: it means quitting the deck and re-running it with a
|
|
10
|
-
// variable in front, which is a lot to ask of somebody whose complaint is that
|
|
11
|
-
// the deck is being noisy.
|
|
12
|
-
//
|
|
13
|
-
// So there is one preference file, and the notification switch lives in it. The
|
|
14
|
-
// env var is NOT retired and is not a default either — it is an override that
|
|
15
|
-
// wins, because a machine told at launch to stay off the desktop must stay off
|
|
16
|
-
// it whatever a page later posts. See `notificationsOn`.
|
|
17
|
-
//
|
|
18
|
-
// WHY NOT IN THE BROWSER. The desktop notifier runs in the SERVER, on the case
|
|
19
|
-
// where no page exists at all — so a preference kept in localStorage could not
|
|
20
|
-
// reach the code it governs at the moment that code runs. It also has to be one
|
|
21
|
-
// answer per machine rather than one per browser profile: two browsers open on
|
|
22
|
-
// the same deck are one deck, and a switch that meant something different in
|
|
23
|
-
// each would be a switch nobody could reason about.
|
|
24
|
-
//
|
|
25
|
-
// The write is the atomic one browser-watch-store.mjs argues for at length —
|
|
26
|
-
// temp file, rename — because the alternative is a truncated JSON document as
|
|
27
|
-
// the only record of what the user chose, and a corrupt file here silently
|
|
28
|
-
// turns the notifications back on.
|
|
29
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
30
|
-
// The rename, with the Windows retry ladder installer.mjs wrote for exactly
|
|
31
|
-
// this call. See the note over the write below (#786).
|
|
32
|
-
import { renameWithRetry } from "./installer.mjs";
|
|
33
|
-
import { join } from "node:path";
|
|
34
|
-
import { deckDataDir } from "./deck-home.mjs";
|
|
35
|
-
|
|
36
|
-
/** Set to "1" to keep the deck off the desktop whatever the stored preference
|
|
37
|
-
* says. Same sheet of switches as AGENTS_DECK_NO_DOWNLOAD and
|
|
38
|
-
* AGENTS_DECK_NO_INSTALL, and unchanged in meaning by this file. */
|
|
39
|
-
export const OFF_ENV = "AGENTS_DECK_NO_NOTIFY";
|
|
40
|
-
|
|
41
|
-
/* WHERE THIS FILE LIVES, AND WHY IT MOVED. It sat in ~/.claude/agent-dag — the
|
|
42
|
-
directory Claude Code owns — which meant a person clearing Claude Code's
|
|
43
|
-
configuration cleared this deck's private key, and every machine that had
|
|
44
|
-
pinned it had to be told to trust this one again. deck-home.mjs owns the new
|
|
45
|
-
answer and the reasons; what matters here is that the parameter is still a
|
|
46
|
-
DIRECTORY, so every caller that passes one is unchanged. */
|
|
47
|
-
const prefsDir = (home = deckDataDir()) => home;
|
|
48
|
-
export const prefsPath = (home = deckDataDir()) => join(prefsDir(home), "prefs.json");
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Every preference the deck keeps, with the answer it gives when there is no
|
|
52
|
-
* file — which is the answer for every existing install, so it has to be the
|
|
53
|
-
* behaviour those installs already have.
|
|
54
|
-
*
|
|
55
|
-
* `notifications` defaults ON because that is what 3.7.0 shipped and what the
|
|
56
|
-
* release notes describe; a switch that quietly turned an existing feature off
|
|
57
|
-
* on upgrade would be a worse surprise than the noise it is meant to stop.
|
|
58
|
-
*/
|
|
59
|
-
export const DEFAULTS = Object.freeze({
|
|
60
|
-
notifications: true,
|
|
61
|
-
// Whether the deck may update itself: restart into code already on disk once
|
|
62
|
-
// it is idle, and — while nobody is looking — install a newer release and
|
|
63
|
-
// restart into that (auto-update.mjs). ON, because this is the banner's
|
|
64
|
-
// `auto when idle`, which defaulted on as a localStorage key; it moved here so
|
|
65
|
-
// the server can read it with no page open.
|
|
66
|
-
autoUpdate: true,
|
|
67
|
-
// LAN sync, off until somebody turns it on. `passphrase` is the only secret
|
|
68
|
-
// this file has ever held, which is why the write below now names a mode.
|
|
69
|
-
lan: Object.freeze({
|
|
70
|
-
enabled: false, name: "", secret: "", shared: [], manual: [], trusted: [], port: 0,
|
|
71
|
-
// WHO PAIRS WITH WHOM, WITHOUT ANYBODY PRESSING ANYTHING. Both on, so two
|
|
72
|
-
// decks on one network find each other and pair themselves — which is what
|
|
73
|
-
// a person with three of their own machines wants and had to do by hand
|
|
74
|
-
// six times.
|
|
75
|
-
//
|
|
76
|
-
// Read this next to the two switches that gate it. `enabled` above is off,
|
|
77
|
-
// so nothing here happens until somebody deliberately puts this deck on the
|
|
78
|
-
// network; `shared` is empty, so a deck that pairs is offered nothing until
|
|
79
|
-
// somebody ticks a login. These say what happens AFTER both of those, and
|
|
80
|
-
// the dialog that turns the feature on prints them.
|
|
81
|
-
autoAsk: true,
|
|
82
|
-
autoAccept: true,
|
|
83
|
-
// What somebody HERE calls another deck, keyed by its fingerprint. The name
|
|
84
|
-
// a deck gives itself is its owner's to choose; this is the other half.
|
|
85
|
-
aliases: Object.freeze({}),
|
|
86
|
-
}),
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
/** The mode prefs.json is created with.
|
|
90
|
-
*
|
|
91
|
-
* It held nothing but booleans until LAN sync, and a booleans file at the
|
|
92
|
-
* umask default is unremarkable. A group passphrase is not: on a shared
|
|
93
|
-
* machine the default mode hands it to every other account on the box, and
|
|
94
|
-
* from it they can decrypt any credential that crosses the network.
|
|
95
|
-
*
|
|
96
|
-
* Passed to `writeFile` rather than applied with a follow-up chmod, and the
|
|
97
|
-
* difference is the whole point — claude-swap's transfer.py makes the same
|
|
98
|
-
* argument at length: a write-then-chmod leaves the file readable for the
|
|
99
|
-
* window between the two, which is exactly when a secret is in it. */
|
|
100
|
-
export const PREFS_MODE = 0o600;
|
|
101
|
-
|
|
102
|
-
/** The longest name somebody here may give another deck. The same order as a
|
|
103
|
-
* deck's own name, and short enough to stay one line in the panel's column. */
|
|
104
|
-
export const ALIAS_MAX = 48;
|
|
105
|
-
|
|
106
|
-
/** A name somebody typed for another deck, cleaned — or "" for one with
|
|
107
|
-
* nothing left in it, which is how an alias is taken away. Control characters
|
|
108
|
-
* go the way cleanName sends them: this string reaches a terminal as well as a
|
|
109
|
-
* page. */
|
|
110
|
-
export function cleanAlias(raw) {
|
|
111
|
-
if (typeof raw !== "string") return "";
|
|
112
|
-
const flat = raw.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
|
|
113
|
-
return [...flat].slice(0, ALIAS_MAX).join("").trim();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** A fingerprint an alias may be kept under. Real ones are `abc-def-012-345`;
|
|
117
|
-
* what matters here is only that a typed address's placeholder — which has a
|
|
118
|
-
* colon in it and names no deck — can never be one, and that nothing exotic
|
|
119
|
-
* becomes a key in an object this file writes back to disk. */
|
|
120
|
-
export function isAliasKey(fp) {
|
|
121
|
-
return typeof fp === "string" && /^[A-Za-z0-9-]{1,64}$/.test(fp);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** The alias map, coerced. A page writes it; a hand-edited file can hold
|
|
125
|
-
* anything. Capped, because nothing legitimate is anywhere near it. */
|
|
126
|
-
function aliasesFrom(raw) {
|
|
127
|
-
const out = {};
|
|
128
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
|
|
129
|
-
for (const [fp, name] of Object.entries(raw).slice(0, 256)) {
|
|
130
|
-
const clean = cleanAlias(name);
|
|
131
|
-
if (isAliasKey(fp) && clean) out[fp] = clean;
|
|
132
|
-
}
|
|
133
|
-
return out;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** One LAN section, coerced. Unknown keys dropped like everything else here,
|
|
137
|
-
* and the two lists forced to arrays of strings — they arrive from a page and
|
|
138
|
-
* are then compared against account keys and dialled as addresses. */
|
|
139
|
-
function normaliseLan(raw) {
|
|
140
|
-
const src = raw && typeof raw === "object" ? raw : {};
|
|
141
|
-
const strings = v => (Array.isArray(v) ? v.filter(x => typeof x === "string") : []);
|
|
142
|
-
return {
|
|
143
|
-
enabled: typeof src.enabled === "boolean" ? src.enabled : false,
|
|
144
|
-
name: typeof src.name === "string" ? src.name : "",
|
|
145
|
-
// THIS DECK'S PRIVATE KEY, and the only secret this file has ever held —
|
|
146
|
-
// which is why the write below names a mode rather than taking the umask's.
|
|
147
|
-
// It is an X25519 private key in base64 pkcs8; anything else is replaced on
|
|
148
|
-
// the next start rather than refused, because a corrupt key is not a thing
|
|
149
|
-
// anybody can act on and refusing to start would take the feature away.
|
|
150
|
-
secret: typeof src.secret === "string" ? src.secret : "",
|
|
151
|
-
shared: strings(src.shared),
|
|
152
|
-
manual: strings(src.manual),
|
|
153
|
-
// THE DECKS SOMEBODY PRESSED ACCEPT ON. The public key is the load-bearing
|
|
154
|
-
// half: a fingerprint is a hash of it, so an entry without one cannot be
|
|
155
|
-
// checked against whatever answers at an address later, and an entry that
|
|
156
|
-
// cannot be checked is worse than no entry at all.
|
|
157
|
-
trusted: (Array.isArray(src.trusted) ? src.trusted : [])
|
|
158
|
-
.filter(t => t && typeof t.fp === "string" && typeof t.pub === "string")
|
|
159
|
-
.map(t => ({
|
|
160
|
-
fp: t.fp, pub: t.pub, name: typeof t.name === "string" ? t.name : "",
|
|
161
|
-
// When somebody here said yes. Absent on every pin made before this was
|
|
162
|
-
// kept, and absent is drawn as "before" rather than guessed.
|
|
163
|
-
...(Number.isFinite(t.at) && t.at > 0 ? { at: t.at } : {}),
|
|
164
|
-
})),
|
|
165
|
-
aliases: aliasesFrom(src.aliases),
|
|
166
|
-
// The port this deck listened on last time, so an address somebody typed on
|
|
167
|
-
// the other machine still works after a restart. It asked the OS for a new
|
|
168
|
-
// one every start, which is invisible while broadcast works and is exactly
|
|
169
|
-
// what does not work in the case the address field exists for.
|
|
170
|
-
//
|
|
171
|
-
// A PREFERENCE, never a requirement: a port already taken falls through to
|
|
172
|
-
// an OS-chosen one and this is rewritten. 0 means "none yet".
|
|
173
|
-
port: Number.isInteger(src.port) && src.port > 0 && src.port < 65_536 ? src.port : 0,
|
|
174
|
-
// ASK FIRST. A deck heard on the broadcast is sent a pairing request without
|
|
175
|
-
// anybody pressing `ask` — the outbound half, which gives nothing away: the
|
|
176
|
-
// machine on the other end still answers it, by hand or by the switch below.
|
|
177
|
-
autoAsk: typeof src.autoAsk === "boolean" ? src.autoAsk : true,
|
|
178
|
-
// AND SAY YES. Every deck that finishes a handshake and is not already
|
|
179
|
-
// trusted is pinned without anybody being asked — the accept button pressed
|
|
180
|
-
// in advance, and it hands whoever asks a copy of every login this deck
|
|
181
|
-
// shares. Absent means the default above; only a real boolean overrides it,
|
|
182
|
-
// because a truthy string from a hand-edited file is not an answer.
|
|
183
|
-
autoAccept: typeof src.autoAccept === "boolean" ? src.autoAccept : true,
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Coerce whatever is on disk into a whole, known-shaped prefs object.
|
|
188
|
-
*
|
|
189
|
-
* Unknown keys are DROPPED rather than carried: this file is written by the
|
|
190
|
-
* deck and read by the deck, and a key from a newer build that this one does
|
|
191
|
-
* not understand cannot be honoured — keeping it would only mean writing back
|
|
192
|
-
* a setting nothing here can see, which reads as support and is not. */
|
|
193
|
-
export function normalise(raw) {
|
|
194
|
-
const src = raw && typeof raw === "object" ? raw : {};
|
|
195
|
-
return {
|
|
196
|
-
notifications: typeof src.notifications === "boolean" ? src.notifications : DEFAULTS.notifications,
|
|
197
|
-
autoUpdate: typeof src.autoUpdate === "boolean" ? src.autoUpdate : DEFAULTS.autoUpdate,
|
|
198
|
-
lan: normaliseLan(src.lan),
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** What is on disk, or the defaults. A corrupt or absent file is not an error
|
|
203
|
-
* the user can act on mid-session, so it reads as "nothing chosen yet". */
|
|
204
|
-
export async function readPrefs(home = deckDataDir(), deps = {}) {
|
|
205
|
-
const read = deps.readFile ?? readFile;
|
|
206
|
-
try { return normalise(JSON.parse(await read(prefsPath(home), "utf8"))); }
|
|
207
|
-
catch { return { ...DEFAULTS }; }
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
let _chain = Promise.resolve();
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Change some of the preferences, keeping the rest.
|
|
214
|
-
*
|
|
215
|
-
* A PATCH rather than a whole-state write, which is the opposite of the choice
|
|
216
|
-
* browser-watch-store.mjs makes — and deliberately. That store holds a growing
|
|
217
|
-
* archive where a merge would have to decide what wins between two decks; this
|
|
218
|
-
* holds a handful of independent booleans, where "the field I did not mention
|
|
219
|
-
* keeps its value" is the only sane reading and an omitted field erasing a
|
|
220
|
-
* setting would be a bug with no upside.
|
|
221
|
-
*
|
|
222
|
-
* Serialized for the same reason the other store is: two pages toggling two
|
|
223
|
-
* different switches in the same second must not lose one of them.
|
|
224
|
-
*/
|
|
225
|
-
export async function writePrefs(patch, home = deckDataDir(), deps = {}) {
|
|
226
|
-
const job = async () => {
|
|
227
|
-
const mk = deps.mkdir ?? mkdir;
|
|
228
|
-
const write = deps.writeFile ?? writeFile;
|
|
229
|
-
// `renameWithRetry`, not `rename` (#786). MoveFileExW refuses while any
|
|
230
|
-
// handle without FILE_SHARE_DELETE is open on either side, and Defender and
|
|
231
|
-
// the search indexer open a file the instant it is written — so on Windows
|
|
232
|
-
// a bare rename fails on a perfectly healthy machine, `POST /api/prefs`
|
|
233
|
-
// 500s through `guard`, and the notifications switch silently does not
|
|
234
|
-
// stick. POSIX rename(2) has no such rule, which is why this shipped green.
|
|
235
|
-
const mv = deps.rename ?? renameWithRetry;
|
|
236
|
-
const prev = await readPrefs(home, deps);
|
|
237
|
-
// The LAN section merges rather than replaces, so a page toggling the
|
|
238
|
-
// switch does not have to send the passphrase back to keep it — and so
|
|
239
|
-
// nothing has to send a secret it was never given.
|
|
240
|
-
const merged = { ...prev, ...patch, lan: { ...prev.lan, ...(patch?.lan ?? {}) } };
|
|
241
|
-
const next = normalise(merged);
|
|
242
|
-
await mk(prefsDir(home), { recursive: true, mode: 0o700 });
|
|
243
|
-
const tmp = `${prefsPath(home)}.${process.pid}.tmp`;
|
|
244
|
-
await write(tmp, JSON.stringify(next, null, 2) + "\n", { encoding: "utf8", mode: PREFS_MODE });
|
|
245
|
-
await mv(tmp, prefsPath(home));
|
|
246
|
-
return next;
|
|
247
|
-
};
|
|
248
|
-
const started = _chain.then(job, job);
|
|
249
|
-
_chain = started.then(() => {}, () => {});
|
|
250
|
-
return started;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* May the deck raise a desktop notification right now?
|
|
255
|
-
*
|
|
256
|
-
* The env var wins. A machine launched with AGENTS_DECK_NO_NOTIFY=1 has been
|
|
257
|
-
* told by whoever started it to stay off the desktop, and a page posting to
|
|
258
|
-
* /api/prefs must not be able to overrule that — the person at the keyboard and
|
|
259
|
-
* the person who wrote the launch script are not always the same person, and
|
|
260
|
-
* only one of them is making a claim about the machine.
|
|
261
|
-
*/
|
|
262
|
-
/**
|
|
263
|
-
* The preferences as a PAGE may see them.
|
|
264
|
-
*
|
|
265
|
-
* THE PRIVATE KEY NEVER LEAVES THIS PROCESS. `GET /api/prefs` is readable by
|
|
266
|
-
* anything that can reach the loopback port — which is the whole point of the
|
|
267
|
-
* deck's own threat model, and is why the share envelope is not served there
|
|
268
|
-
* either. A page has no use for the key: what it needs is this deck's
|
|
269
|
-
* fingerprint, which is a hash of the PUBLIC half and comes from `/api/lan`.
|
|
270
|
-
*
|
|
271
|
-
* The pinned public keys of trusted decks go the same way, for a smaller
|
|
272
|
-
* reason: they are not secret, and they are forty characters of base64 that no
|
|
273
|
-
* page draws. What a page shows is a name and a fingerprint.
|
|
274
|
-
*/
|
|
275
|
-
export function publicPrefs(prefs) {
|
|
276
|
-
const p = normalise(prefs);
|
|
277
|
-
const { secret, trusted, ...lan } = p.lan;
|
|
278
|
-
return {
|
|
279
|
-
...p,
|
|
280
|
-
lan: { ...lan, trusted: trusted.map(t => ({ fp: t.fp, name: t.name })) },
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
export function notificationsOn(prefs, env = process.env) {
|
|
285
|
-
if (env[OFF_ENV] === "1") return false;
|
|
286
|
-
return normalise(prefs).notifications;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Did the MACHINE veto this, as opposed to the person?
|
|
291
|
-
*
|
|
292
|
-
* Reported separately because the two are different sentences and the UI has to
|
|
293
|
-
* say the right one. Deriving it from `notificationsOn` being false was the
|
|
294
|
-
* first spelling, and the browser caught it immediately: switching the setting
|
|
295
|
-
* off made the menu read "off — set at launch" on a deck launched with no
|
|
296
|
-
* variable at all, telling the user their own press had been overruled by
|
|
297
|
-
* something that had not happened.
|
|
298
|
-
*/
|
|
299
|
-
export function notificationsVetoed(env = process.env) {
|
|
300
|
-
return env[OFF_ENV] === "1";
|
|
301
|
-
}
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
// Is that process a deck, and is it still there — asked from anywhere.
|
|
2
|
-
//
|
|
3
|
-
// These four lived in src/server/index.mjs, which is the whole server: 5,800
|
|
4
|
-
// lines, a dozen watchers, and timers that arm the moment the module is
|
|
5
|
-
// imported. Everything that had to ask one of these questions therefore had to
|
|
6
|
-
// import all of that, and three callers cannot:
|
|
7
|
-
//
|
|
8
|
-
// • running-deck.mjs, read on the boot path before anything else, which for
|
|
9
|
-
// one commit took both of these as PARAMETERS purely to avoid the import.
|
|
10
|
-
// • `ccdeck --stop` and `--status`, one-shot commands that talk to a deck and
|
|
11
|
-
// exit. Starting a server to ask a server to stop is absurd on its face,
|
|
12
|
-
// and on a cold start it is also slower than the thing it is asking for.
|
|
13
|
-
// • the tests, which pin these against hook/hook.js's copy and should cost a
|
|
14
|
-
// millisecond rather than a server boot.
|
|
15
|
-
//
|
|
16
|
-
// So they moved down here, to a leaf that imports two node builtins and nothing
|
|
17
|
-
// else. index.mjs imports them from here and RE-EXPORTS them under the names it
|
|
18
|
-
// always had, so every existing caller and every existing test is untouched —
|
|
19
|
-
// and there is still exactly one spelling of the handshake in the package.
|
|
20
|
-
//
|
|
21
|
-
// hook/hook.js keeps its own duplicate of challengeProof for the one reason
|
|
22
|
-
// that has always justified it: that script is copied out of the package and
|
|
23
|
-
// installed into ~/.claude, where it cannot import from anywhere. A test pins
|
|
24
|
-
// the two spellings against each other, and that test is the only thing
|
|
25
|
-
// standing between a changed hash and a deck that silently stops being told
|
|
26
|
-
// anything.
|
|
27
|
-
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
28
|
-
import { request as httpRequest } from "node:http";
|
|
29
|
-
|
|
30
|
-
// The same deadline hook.js gives a challenge, and for the same reason: a
|
|
31
|
-
// bodyless GET to a loopback port is sub-millisecond when a deck is there and an
|
|
32
|
-
// instant ECONNREFUSED when nothing is.
|
|
33
|
-
export const DECK_CHALLENGE_TIMEOUT_MS = 400;
|
|
34
|
-
|
|
35
|
-
// Signal 0 delivers nothing; it asks whether the pid could be signalled.
|
|
36
|
-
//
|
|
37
|
-
// BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
|
|
38
|
-
// answers EPERM for a process this account may not signal. On Windows
|
|
39
|
-
// `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
|
|
40
|
-
// maps that to EACCES — so a deck started from an elevated terminal, or under
|
|
41
|
-
// another account, read as DEAD to every probe in this repo. What followed was
|
|
42
|
-
// silent: the live deck's discovery file was unlinked on the next hook fire,
|
|
43
|
-
// rewritten five seconds later by keepDiscovery, and its banner went on
|
|
44
|
-
// claiming it was receiving events it had stopped receiving.
|
|
45
|
-
export function isProcessAlive(pid) {
|
|
46
|
-
try { process.kill(pid, 0); return true; }
|
|
47
|
-
catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The proof of knowing `token`, for a nonce the challenger chose.
|
|
52
|
-
*
|
|
53
|
-
* hook/hook.js spells this out a second time — it is installed outside the
|
|
54
|
-
* package and cannot import from here — and a test pins the two against each
|
|
55
|
-
* other. Changing one without the other silently blinds the deck.
|
|
56
|
-
*/
|
|
57
|
-
export function challengeProof(token, nonce) {
|
|
58
|
-
return createHash("sha256").update(`${token}:${nonce}`).digest("hex");
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* One challenge round trip, resolving true only on a correct proof.
|
|
63
|
-
*
|
|
64
|
-
* The compare is constant-time for the reason hook.js's sameProof is: whatever
|
|
65
|
-
* is on that port may not be a deck, and it must not be able to walk the
|
|
66
|
-
* expected proof out of us one byte at a time by timing how long we take to hang
|
|
67
|
-
* up. The nonce is fresh per call, so an answer overheard earlier is worth
|
|
68
|
-
* nothing, and the token itself never leaves this process.
|
|
69
|
-
*/
|
|
70
|
-
export function challengeDeck(port, token) {
|
|
71
|
-
return new Promise(resolve => {
|
|
72
|
-
let settled = false;
|
|
73
|
-
const finish = ok => { if (settled) return; settled = true; resolve(ok); };
|
|
74
|
-
const nonce = randomBytes(16).toString("hex");
|
|
75
|
-
const want = challengeProof(token, nonce);
|
|
76
|
-
const req = httpRequest({
|
|
77
|
-
hostname: "127.0.0.1",
|
|
78
|
-
port,
|
|
79
|
-
path: `/api/hook-challenge?nonce=${nonce}`,
|
|
80
|
-
method: "GET",
|
|
81
|
-
timeout: DECK_CHALLENGE_TIMEOUT_MS,
|
|
82
|
-
}, res => {
|
|
83
|
-
if (res.statusCode !== 200) { res.resume(); return res.on("end", () => finish(false)); }
|
|
84
|
-
let answer = "";
|
|
85
|
-
res.setEncoding("utf8");
|
|
86
|
-
res.on("data", c => {
|
|
87
|
-
answer += c;
|
|
88
|
-
// A deck answers in ~100 bytes. Anything pouring data at us is not one,
|
|
89
|
-
// and must not be allowed to grow this buffer without bound.
|
|
90
|
-
if (answer.length > 4096) { req.destroy(); finish(false); }
|
|
91
|
-
});
|
|
92
|
-
res.on("end", () => {
|
|
93
|
-
if (settled) return;
|
|
94
|
-
let proof;
|
|
95
|
-
try { proof = JSON.parse(answer).proof; } catch { return finish(false); }
|
|
96
|
-
finish(sameProof(proof, want));
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
req.on("error", () => finish(false));
|
|
100
|
-
req.on("timeout", () => req.destroy());
|
|
101
|
-
req.end();
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** hook.js's sameProof, for the same reason it is constant-time there. */
|
|
106
|
-
export function sameProof(got, want) {
|
|
107
|
-
if (typeof got !== "string") return false;
|
|
108
|
-
const a = Buffer.from(got, "utf8");
|
|
109
|
-
const b = Buffer.from(want, "utf8");
|
|
110
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
111
|
-
}
|