@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.
- package/README.md +44 -2
- package/dist/adapters/claude.js +6 -2
- package/dist/agent-auto-update.d.ts +75 -0
- package/dist/agent-auto-update.js +134 -0
- package/dist/agent-binary.d.ts +48 -0
- package/dist/agent-binary.js +55 -0
- package/dist/agent-cleanup.d.ts +78 -0
- package/dist/agent-cleanup.js +184 -0
- package/dist/agent-install.d.ts +140 -0
- package/dist/agent-install.js +475 -0
- package/dist/agent-registry.d.ts +223 -0
- package/dist/agent-registry.js +131 -0
- package/dist/agent-versions.d.ts +93 -0
- package/dist/agent-versions.js +157 -0
- package/dist/auth-relay.d.ts +9 -1
- package/dist/auth-relay.js +3 -1
- package/dist/commit-message.js +5 -0
- package/dist/config.d.ts +44 -4
- package/dist/config.js +43 -0
- package/dist/index.js +46 -12
- package/dist/protocol.d.ts +155 -28
- package/dist/protocol.js +33 -1
- package/dist/recipe-schema.d.ts +12 -12
- package/dist/self-update.d.ts +14 -0
- package/dist/self-update.js +45 -13
- package/dist/supervisor.d.ts +155 -2
- package/dist/supervisor.js +393 -8
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|