@bridge4dev/runner 0.46.1 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { NATIVE_VERSIONS_DIR, nativeVersionsOnDisk } from './agent-install.js';
5
+ import { log } from './log.js';
6
+ /**
7
+ * Throwing away the Claude versions nobody will run again (Р12, §4.7).
8
+ *
9
+ * Anthropic's native installer keeps **every** version it has ever downloaded
10
+ * as a separate 200–330 MB executable in `~/.local/share/claude/versions/` and
11
+ * deletes none of them; the launcher on PATH is a symlink pointing at one. On
12
+ * this very dev server that was five copies and 1.4 GB with the disk 80 % full
13
+ * (гоча #414) — and stage D now moves the version forward by itself, so what
14
+ * used to grow by a file a month grows by a file a day.
15
+ *
16
+ * Three files are untouchable, and each is a different way to break a machine:
17
+ *
18
+ * 1. **whatever the launcher points at.** Delete it and `claude` on PATH is a
19
+ * dangling symlink: no sessions, no sign-in, and nothing on the card
20
+ * explaining it — `measureAgent` would report a probe failure, which stage 2
21
+ * already knows is a state with no way out from the interface.
22
+ * 2. **the version a rollback would restore.** A failed install puts the
23
+ * previous version back by pointing the launcher at its file, so «keep two»
24
+ * is not an arbitrary number: it is current + the one behind it.
25
+ * 3. **a version some process is still executing.** Claude Code launches its
26
+ * own helpers by its own path, and a session that started two updates ago is
27
+ * running the third file from the end. Unlinking a mapped executable does
28
+ * not kill it on Linux, but it does make every later `exec` of that path
29
+ * fail — and the caller's own gate (sweep only when this runner is tracking
30
+ * no sessions at all) cannot see a process somebody started by hand.
31
+ */
32
+ /** Current + the one a rollback would restore. Р12 says two, and two it is. */
33
+ export const KEEP_VERSIONS = 2;
34
+ /**
35
+ * The versions directory, with every symlink in its path resolved.
36
+ *
37
+ * Everything this module compares against comes back already resolved —
38
+ * `realpathSync` on the launcher, `/proc/<pid>/exe` — so an unresolved path here
39
+ * would fail to match on any machine where a component of `$HOME` is a link, and
40
+ * fail in the direction that deletes files.
41
+ *
42
+ * Falls back to the joined path when the directory does not exist: the caller
43
+ * then simply finds nothing in it.
44
+ */
45
+ function versionsDir(homeDir) {
46
+ const joined = path.join(homeDir, ...NATIVE_VERSIONS_DIR);
47
+ try {
48
+ return fs.realpathSync(joined);
49
+ }
50
+ catch {
51
+ return joined;
52
+ }
53
+ }
54
+ /**
55
+ * The version the launcher on PATH resolves to, or null.
56
+ *
57
+ * Read by resolving the symlink rather than by running `claude --version`: the
58
+ * question is «which FILE», and this runs on a timer where spawning a 300 MB
59
+ * binary would be a poor way to ask. `realpath` follows a chain of links, so a
60
+ * distribution that put its own wrapper in between still answers correctly.
61
+ */
62
+ export function currentNativeVersion(homeDir) {
63
+ const launcher = path.join(homeDir, '.local', 'bin', 'claude');
64
+ try {
65
+ const resolved = fs.realpathSync(launcher);
66
+ // BOTH sides resolved, or the comparison fails open. `realpathSync` on the
67
+ // launcher gives a fully-resolved path, so a plain `path.join` on this side
68
+ // would not match the moment any component of it is a symlink — `/home`
69
+ // pointing elsewhere, or a home directory on a mounted volume. The failure
70
+ // is silent and in the dangerous direction: «not one of ours» means «not
71
+ // protected», and the file the launcher points at gets deleted.
72
+ const dir = versionsDir(homeDir);
73
+ const relative = path.relative(dir, resolved);
74
+ // Inside the versions directory and one level down, or it is not one of
75
+ // these files at all — a `claude` that resolves to /usr/bin is somebody
76
+ // else's install and this sweep has no business near it.
77
+ return relative && !relative.startsWith('..') && !relative.includes(path.sep) ? relative : null;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ /**
84
+ * Version files at least one live process is executing right now, best effort.
85
+ *
86
+ * `/proc/<pid>/exe` is a symlink to the executable's real path, so this is an
87
+ * exact answer for every process this user may look at — and an empty answer on
88
+ * a platform without `/proc`. Unreadable entries are SKIPPED rather than treated
89
+ * as a match: another user's process cannot be running a file out of this user's
90
+ * home, and refusing to sweep whenever `/proc` holds anything opaque would mean
91
+ * never sweeping on a busy machine.
92
+ */
93
+ export function versionsInUse(homeDir) {
94
+ // Resolved for the same reason as above: `/proc/<pid>/exe` reads back a fully
95
+ // resolved path, and comparing it against an unresolved directory would
96
+ // quietly protect nothing.
97
+ const dir = versionsDir(homeDir);
98
+ const busy = new Set();
99
+ let pids;
100
+ try {
101
+ pids = fs.readdirSync('/proc').filter((name) => /^\d+$/.test(name));
102
+ }
103
+ catch {
104
+ return busy;
105
+ }
106
+ for (const pid of pids) {
107
+ let target;
108
+ try {
109
+ target = fs.readlinkSync(path.join('/proc', pid, 'exe'));
110
+ }
111
+ catch {
112
+ continue; // gone between readdir and readlink, or not ours to look at
113
+ }
114
+ // A deleted-but-mapped executable reads back as `<path> (deleted)`.
115
+ const file = target.replace(/ \(deleted\)$/, '');
116
+ if (path.dirname(file) === dir)
117
+ busy.add(path.basename(file));
118
+ }
119
+ return busy;
120
+ }
121
+ /**
122
+ * Sweep, and say what happened.
123
+ *
124
+ * Never throws: this runs on a timer beside live sessions, and a permission
125
+ * error on one file must cost that file, not the daemon.
126
+ */
127
+ export function pruneNativeClaudeVersions(options = {}) {
128
+ const homeDir = options.homeDir ?? process.env['HOME'] ?? os.homedir();
129
+ const keep = options.keep ?? KEEP_VERSIONS;
130
+ const dir = versionsDir(homeDir);
131
+ const result = { removed: [], freedBytes: 0, kept: [] };
132
+ // Newest first, and only names that ARE versions — the directory is the
133
+ // vendor's, and a stray file in it is not ours to delete.
134
+ const versions = nativeVersionsOnDisk(homeDir);
135
+ if (versions.length <= keep)
136
+ return result;
137
+ const current = (options.current ?? currentNativeVersion)(homeDir);
138
+ const busy = (options.inUse ?? versionsInUse)(homeDir);
139
+ const remove = options.remove ?? ((file) => fs.rmSync(file, { force: true }));
140
+ // The keep-list is «the newest N» UNION «the one in use» — computed as a set
141
+ // rather than by slicing, because the launcher can legitimately point at a
142
+ // version that is not the newest on disk (a rollback did exactly that), and
143
+ // slicing alone would then delete the file `claude` resolves to.
144
+ const survivors = new Set(versions.slice(0, keep));
145
+ if (current)
146
+ survivors.add(current);
147
+ for (const version of versions) {
148
+ if (survivors.has(version)) {
149
+ result.kept.push({
150
+ version,
151
+ reason: version === current ? 'the launcher points at it' : 'one of the newest',
152
+ });
153
+ continue;
154
+ }
155
+ if (busy.has(version)) {
156
+ result.kept.push({ version, reason: 'a process is running it' });
157
+ continue;
158
+ }
159
+ const file = path.join(dir, version);
160
+ // Measured BEFORE the unlink — afterwards there is nothing left to ask.
161
+ let size;
162
+ try {
163
+ size = fs.statSync(file).size;
164
+ }
165
+ catch {
166
+ // Vanished under us — the vendor's own installer may have tidied it.
167
+ continue;
168
+ }
169
+ try {
170
+ remove(file);
171
+ result.removed.push(version);
172
+ result.freedBytes += size;
173
+ }
174
+ catch (error) {
175
+ log.warn('agent-cleanup: could not remove an old Claude version', {
176
+ version,
177
+ error: String(error instanceof Error ? error.message : error),
178
+ });
179
+ result.kept.push({ version, reason: 'could not be removed' });
180
+ }
181
+ }
182
+ return result;
183
+ }
184
+ //# sourceMappingURL=agent-cleanup.js.map
@@ -0,0 +1,140 @@
1
+ import { type AgentRuntime } from './agent-registry.js';
2
+ import { type MeasuredAgentVersion } from './agent-versions.js';
3
+ /**
4
+ * Installing and updating the agent CLIs from the dashboard (plan stage B).
5
+ *
6
+ * One command does both: «Install» and «Update» differ only in whether anything
7
+ * was there before. Like `self-update.ts` this runs software on someone else's
8
+ * machine, so it is written around its refusals rather than its happy path —
9
+ * but with three differences from updating the runner, and each one matters:
10
+ *
11
+ * 1. **The server never says WHAT to install.** The frame carries an agent key
12
+ * from a closed list and a plain version triple; the package name, the
13
+ * installer URL and the command are read from the registry compiled into
14
+ * this build. A compromised API can pick the version, never the payload.
15
+ * 2. **The daemon does not restart.** Live sessions keep running on the file
16
+ * they already opened, and the new version takes effect for the next one.
17
+ * This is the whole point of the work: today the only way to move Claude
18
+ * forward is reinstalling the runner, which kills every session on the box.
19
+ * 3. **Success is decided by running the binary, never by npm's exit code.**
20
+ * Гоча #297: an optional platform package that fails to unpack still leaves
21
+ * npm exiting 0, and both agents ship their real binary exactly that way.
22
+ */
23
+ export interface AgentInstallOutcome {
24
+ ok: boolean;
25
+ /** Wire key of the agent, echoed back so a late reply can be attributed. */
26
+ agent: string;
27
+ /** What was installed before, or null when the agent was absent. */
28
+ fromVersion: string | null;
29
+ toVersion?: string;
30
+ detail?: string;
31
+ /** True when the install failed AND the previous version was put back. */
32
+ rolledBack?: boolean;
33
+ }
34
+ export type Exec = (file: string, args: string[], options: {
35
+ timeout: number;
36
+ env?: NodeJS.ProcessEnv;
37
+ }) => Promise<{
38
+ stdout: string;
39
+ stderr: string;
40
+ }>;
41
+ export interface AgentInstallOptions {
42
+ /** Wire key from the closed registry list, e.g. `codex`. */
43
+ agent: string;
44
+ /** Plain `x.y.z`; anything else is refused before any work happens. */
45
+ version: string;
46
+ /**
47
+ * Allow installing a version older than the one on the machine. There is no
48
+ * door to this in the interface — it exists so a bad vendor release can be
49
+ * walked back by hand, and it leaves its own audit line.
50
+ */
51
+ allowDowngrade?: boolean;
52
+ /** Test seam. */
53
+ exec?: Exec;
54
+ /** Test seam: how the agent is measured before and after. */
55
+ measure?: (runtime: AgentRuntime) => Promise<MeasuredAgentVersion>;
56
+ /** Test seam: the installed runner package directory (defaults to autodetect). */
57
+ packageDir?: string | null;
58
+ /** Test seam: the daemon's home directory. */
59
+ homeDir?: string;
60
+ /** Test seam: free bytes on the filesystem a path lives on. */
61
+ freeBytes?: (target: string) => number | null;
62
+ /** Test seam: fetch the vendor's installer script to a local file. */
63
+ download?: (url: string, destination: string) => Promise<void>;
64
+ /** Test seam: re-scan for directories that appeared during the install. */
65
+ refreshPath?: () => void;
66
+ }
67
+ /**
68
+ * Anthropic's native installer keeps every version it has ever downloaded as a
69
+ * separate file here and switches a launcher between them; `claude install
70
+ * <version>` is how you move the launcher, and that is what makes a rollback
71
+ * possible at all for a `script`-kind agent.
72
+ *
73
+ * Deliberately NOT a registry field: the registry is mirrored character for
74
+ * character into `@devbridge/shared`, and today `script` has exactly one member.
75
+ * When a second one arrives (Antigravity ships a script installer too) this
76
+ * becomes a per-agent field and the mirror grows with it.
77
+ */
78
+ export declare const NATIVE_VERSIONS_DIR: string[];
79
+ /**
80
+ * npm and the vendor scripts need PATH, HOME and a writable cache; everything
81
+ * else is stripped so provider credentials never reach a child that touches the
82
+ * network, and a stray `npm_config_prefix` cannot redirect the install.
83
+ */
84
+ declare function installEnv(homeDir: string): NodeJS.ProcessEnv;
85
+ /**
86
+ * `--include=optional` is not a copy-paste slip next to C5, which takes the flag
87
+ * OFF the runner's own install. The two say the same thing about different
88
+ * packages: the runner no longer wants Claude's optional platform binary, while
89
+ * `@openai/codex` keeps its 320 MB executable in exactly such an optional
90
+ * package (`@openai/codex-linux-x64`). Dropping it here would install a shim
91
+ * that cannot run, and npm would still exit 0 — гоча #297, second helping.
92
+ *
93
+ * `--ignore-scripts` is safe and checked: neither agent package declares any
94
+ * lifecycle script.
95
+ */
96
+ declare function npmInstallArgs(packageName: string, version: string, prefix: string | null): string[];
97
+ /**
98
+ * The command a human runs when we will not: an agent someone else's package
99
+ * manager owns, or a machine this daemon cannot write to.
100
+ */
101
+ export declare function manualAgentInstallCommand(runtime: AgentRuntime, version: string): string;
102
+ /** Versions the native installer has on disk, newest first. */
103
+ export declare function nativeVersionsOnDisk(homeDir: string): string[];
104
+ /**
105
+ * Fetch the vendor's installer script to a file.
106
+ *
107
+ * `curl` rather than Node's `fetch`, and behind an outbound proxy that is the
108
+ * whole difference between working and not: undici reads no proxy from the
109
+ * environment, so a machine that reaches the internet only through
110
+ * `HTTPS_PROXY` failed here — while the script it was fetching would itself
111
+ * have reached the network perfectly well, because `curl` inside it does read
112
+ * the environment. Fixing one half and not the other would have been an
113
+ * improvement nobody could use.
114
+ *
115
+ * `curl` is not an extra dependency: the vendor's script runs it on the next
116
+ * line, so a machine without it cannot install this agent either way.
117
+ *
118
+ * Downloaded to a file and never piped into a shell, exactly as before — a
119
+ * truncated transfer then fails to parse instead of running half a script.
120
+ * `--fail` turns an HTTP error into a non-zero exit rather than an error page
121
+ * saved as an installer; `--proto =https` refuses a redirect that leaves TLS.
122
+ */
123
+ declare function downloadToFile(url: string, destination: string, exec: Exec, env: NodeJS.ProcessEnv): Promise<void>;
124
+ /**
125
+ * Install or update one agent CLI.
126
+ *
127
+ * Never throws: every failure comes back as an outcome, because the caller is a
128
+ * command handler whose reply is the only thing the dashboard will ever see.
129
+ */
130
+ export declare function installAgent(options: AgentInstallOptions): Promise<AgentInstallOutcome>;
131
+ export declare const AGENT_INSTALL_INTERNALS: {
132
+ INSTALL_TIMEOUT_MS: number;
133
+ MIN_FREE_BYTES: number;
134
+ downloadToFile: typeof downloadToFile;
135
+ installEnv: typeof installEnv;
136
+ nativeVersionsOnDisk: typeof nativeVersionsOnDisk;
137
+ npmInstallArgs: typeof npmInstallArgs;
138
+ };
139
+ export {};
140
+ //# sourceMappingURL=agent-install.d.ts.map