@bridge4dev/runner 0.11.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude.d.ts +15 -7
- package/dist/adapters/claude.js +1024 -70
- package/dist/adapters/codex.d.ts +18 -3
- package/dist/adapters/codex.js +224 -65
- package/dist/adapters/questions.d.ts +42 -0
- package/dist/adapters/questions.js +86 -0
- package/dist/adapters/types.d.ts +200 -4
- package/dist/attachments.d.ts +8 -1
- package/dist/attachments.js +22 -4
- package/dist/auto-resume.d.ts +18 -0
- package/dist/auto-resume.js +104 -0
- package/dist/commit-message.d.ts +51 -0
- package/dist/commit-message.js +224 -0
- package/dist/config.d.ts +29 -6
- package/dist/config.js +15 -0
- package/dist/crash-note.d.ts +54 -0
- package/dist/crash-note.js +105 -0
- package/dist/git.d.ts +71 -0
- package/dist/git.js +207 -10
- package/dist/gitops.d.ts +489 -12
- package/dist/gitops.js +1717 -96
- package/dist/index.js +435 -32
- package/dist/paths.d.ts +26 -0
- package/dist/paths.js +34 -0
- package/dist/policy.d.ts +63 -0
- package/dist/policy.js +412 -10
- package/dist/protocol.d.ts +382 -60
- package/dist/protocol.js +104 -1
- package/dist/recipe-schema.d.ts +310 -0
- package/dist/recipe-schema.js +103 -0
- package/dist/recipe.d.ts +94 -0
- package/dist/recipe.js +238 -0
- package/dist/self-update.d.ts +7 -0
- package/dist/self-update.js +171 -23
- package/dist/service-unit.d.ts +79 -0
- package/dist/service-unit.js +211 -0
- package/dist/supervisor.d.ts +108 -1
- package/dist/supervisor.js +1010 -56
- package/dist/verify-queue.d.ts +17 -0
- package/dist/verify-queue.js +100 -0
- package/dist/verify.d.ts +203 -0
- package/dist/verify.js +788 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/recipe.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { PROJECT_RECIPE_PATH, canonicalRecipeJson, parseProjectRecipe, recipeSteps, } from './recipe-schema.js';
|
|
5
|
+
import { evaluateRecipeCommand, maskString } from './policy.js';
|
|
6
|
+
/**
|
|
7
|
+
* `.devbridge/project.json` — read, never executed (session 14).
|
|
8
|
+
*
|
|
9
|
+
* The file in a repository is a PROPOSAL. Nothing in this module runs anything:
|
|
10
|
+
* it reads the file, says whether it parses, and hands back a fingerprint. The
|
|
11
|
+
* executable copy lives on the DevBridge side and gets there only when a human
|
|
12
|
+
* with manager rights approves it — because an agent can edit files in its own
|
|
13
|
+
* branch, and «the runner does what the file says» would be arbitrary command
|
|
14
|
+
* execution by diff.
|
|
15
|
+
*/
|
|
16
|
+
/** A recipe file bigger than this is not a recipe. */
|
|
17
|
+
const RECIPE_MAX_BYTES = 64 * 1024;
|
|
18
|
+
/**
|
|
19
|
+
* A stable fingerprint of a recipe.
|
|
20
|
+
*
|
|
21
|
+
* `verify_start` carries it and the runner refuses a run whose recipe does not
|
|
22
|
+
* match: an approval is an approval of specific commands, and «the approved
|
|
23
|
+
* recipe» has to mean the same bytes on both sides.
|
|
24
|
+
*/
|
|
25
|
+
export function recipeFingerprint(recipe) {
|
|
26
|
+
return crypto.createHash('sha256').update(canonicalRecipeJson(recipe)).digest('hex').slice(0, 32);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read the proposal out of a checkout.
|
|
30
|
+
*
|
|
31
|
+
* `lstat` and not `stat`: a symlink at `.devbridge/project.json` pointing at
|
|
32
|
+
* `~/.aws/credentials` would otherwise be read and shown verbatim on the
|
|
33
|
+
* approval screen.
|
|
34
|
+
*
|
|
35
|
+
* The DIRECTORY is checked the same way, and separately (session 15): `lstat`
|
|
36
|
+
* on the file follows every symlink above it, so `.devbridge` itself being a
|
|
37
|
+
* link to somewhere else in the filesystem walked straight past the guard while
|
|
38
|
+
* the guard's own comment said it did not.
|
|
39
|
+
*/
|
|
40
|
+
export function readRecipeProposal(root) {
|
|
41
|
+
const file = path.join(root, PROJECT_RECIPE_PATH);
|
|
42
|
+
const empty = {
|
|
43
|
+
path: file,
|
|
44
|
+
present: false,
|
|
45
|
+
raw: null,
|
|
46
|
+
recipe: null,
|
|
47
|
+
error: null,
|
|
48
|
+
sha: null,
|
|
49
|
+
steps: [],
|
|
50
|
+
commands: [],
|
|
51
|
+
refusals: [],
|
|
52
|
+
};
|
|
53
|
+
// Everything read here is shown to a human and hashed into an approval, so
|
|
54
|
+
// it has to come from inside the checkout — not from wherever a link points.
|
|
55
|
+
const insideCheckout = (() => {
|
|
56
|
+
try {
|
|
57
|
+
const realRoot = fs.realpathSync(root);
|
|
58
|
+
const realDir = fs.realpathSync(path.dirname(file));
|
|
59
|
+
const rel = path.relative(realRoot, realDir);
|
|
60
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// No `.devbridge` directory at all — the ordinary case, and the read
|
|
64
|
+
// below reports it as «no recipe» on its own.
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
if (!insideCheckout) {
|
|
69
|
+
return {
|
|
70
|
+
...empty,
|
|
71
|
+
present: true,
|
|
72
|
+
error: `${PROJECT_RECIPE_PATH} points outside the repository`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
let raw;
|
|
76
|
+
try {
|
|
77
|
+
const stat = fs.lstatSync(file);
|
|
78
|
+
if (!stat.isFile()) {
|
|
79
|
+
return { ...empty, present: true, error: `${PROJECT_RECIPE_PATH} is not a regular file` };
|
|
80
|
+
}
|
|
81
|
+
if (stat.size > RECIPE_MAX_BYTES) {
|
|
82
|
+
return {
|
|
83
|
+
...empty,
|
|
84
|
+
present: true,
|
|
85
|
+
error: `${PROJECT_RECIPE_PATH} is larger than ${Math.round(RECIPE_MAX_BYTES / 1024)} KB`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return empty;
|
|
92
|
+
}
|
|
93
|
+
const masked = maskString(raw).slice(0, RECIPE_MAX_BYTES);
|
|
94
|
+
let parsedJson;
|
|
95
|
+
try {
|
|
96
|
+
// JSONC in the plan's example is illustrative; the file itself is JSON.
|
|
97
|
+
// Comments are stripped rather than refused — a recipe people hand-write is
|
|
98
|
+
// exactly the kind of file that grows a `// why` line.
|
|
99
|
+
parsedJson = JSON.parse(stripJsonComments(raw));
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
...empty,
|
|
104
|
+
present: true,
|
|
105
|
+
raw: masked,
|
|
106
|
+
error: `${PROJECT_RECIPE_PATH} is not valid JSON: ${String(error instanceof Error ? error.message : error).slice(0, 200)}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const parsed = parseProjectRecipe(parsedJson);
|
|
110
|
+
if (!parsed.ok) {
|
|
111
|
+
return { ...empty, present: true, raw: masked, error: parsed.error };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
path: file,
|
|
115
|
+
present: true,
|
|
116
|
+
raw: masked,
|
|
117
|
+
recipe: parsed.recipe,
|
|
118
|
+
error: null,
|
|
119
|
+
sha: recipeFingerprint(parsed.recipe),
|
|
120
|
+
steps: recipeSteps(parsed.recipe),
|
|
121
|
+
commands: recipeCommands(parsed.recipe),
|
|
122
|
+
refusals: recipeRefusals(parsed.recipe),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Every command in the recipe, in the order a person should read them —
|
|
127
|
+
* `preview.run` and `preview.stop` included, exactly as the refusal check
|
|
128
|
+
* already covers them.
|
|
129
|
+
*/
|
|
130
|
+
export function recipeCommands(recipe) {
|
|
131
|
+
const entries = [];
|
|
132
|
+
for (const name of recipeSteps(recipe)) {
|
|
133
|
+
const run = recipe.steps?.[name]?.run;
|
|
134
|
+
if (run)
|
|
135
|
+
entries.push({ step: name, run });
|
|
136
|
+
}
|
|
137
|
+
if (recipe.preview?.run)
|
|
138
|
+
entries.push({ step: 'preview.run', run: recipe.preview.run });
|
|
139
|
+
if (recipe.preview?.stop)
|
|
140
|
+
entries.push({ step: 'preview.stop', run: recipe.preview.stop });
|
|
141
|
+
return entries.map(({ step, run }) => {
|
|
142
|
+
const masked = maskString(run);
|
|
143
|
+
return { step, run: masked, redacted: masked !== run };
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Every command in the recipe that layer 1 would refuse, named by its step.
|
|
148
|
+
*
|
|
149
|
+
* Answered at read time, not at run time. A recipe whose `deploy` step contains
|
|
150
|
+
* `sudo systemctl restart` must fail the person reading the approval screen, not
|
|
151
|
+
* a build twenty minutes into a release.
|
|
152
|
+
*/
|
|
153
|
+
export function recipeRefusals(recipe) {
|
|
154
|
+
const refusals = [];
|
|
155
|
+
// A command a person cannot read is a command they cannot approve. Masking is
|
|
156
|
+
// a rewrite, and the executed copy is the unmasked one — so a redaction here
|
|
157
|
+
// is not a cosmetic detail, it is the gap between what was shown and what
|
|
158
|
+
// will run. Blocking approval closes it, and moving the secret into `env`
|
|
159
|
+
// (which is never a command) is the way out.
|
|
160
|
+
for (const command of recipeCommands(recipe)) {
|
|
161
|
+
if (!command.redacted)
|
|
162
|
+
continue;
|
|
163
|
+
refusals.push({
|
|
164
|
+
step: command.step,
|
|
165
|
+
reason: 'the command contains something that looks like a secret, so it cannot be shown in full — move it into `env` and approve a command you can read',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
for (const name of recipeSteps(recipe)) {
|
|
169
|
+
const run = recipe.steps?.[name]?.run;
|
|
170
|
+
if (!run)
|
|
171
|
+
continue;
|
|
172
|
+
const decision = evaluateRecipeCommand(run);
|
|
173
|
+
if (!decision.allowed)
|
|
174
|
+
refusals.push({ step: name, reason: decision.reason ?? 'refused' });
|
|
175
|
+
}
|
|
176
|
+
if (recipe.preview?.run) {
|
|
177
|
+
const decision = evaluateRecipeCommand(recipe.preview.run);
|
|
178
|
+
if (!decision.allowed) {
|
|
179
|
+
refusals.push({ step: 'preview.run', reason: decision.reason ?? 'refused' });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (recipe.preview?.stop) {
|
|
183
|
+
const decision = evaluateRecipeCommand(recipe.preview.stop, {
|
|
184
|
+
isPreviewStop: true,
|
|
185
|
+
...(recipe.preview.project ? { dockerProject: recipe.preview.project } : {}),
|
|
186
|
+
});
|
|
187
|
+
if (!decision.allowed) {
|
|
188
|
+
refusals.push({ step: 'preview.stop', reason: decision.reason ?? 'refused' });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return refusals;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Drop `//` and `/* *\/` comments without touching what is inside strings.
|
|
195
|
+
*
|
|
196
|
+
* A hand-rolled scanner rather than a regex: a regex that ignores string
|
|
197
|
+
* context eats the `//` in a URL, which is exactly what `health.url` is.
|
|
198
|
+
*/
|
|
199
|
+
export function stripJsonComments(text) {
|
|
200
|
+
let out = '';
|
|
201
|
+
let inString = false;
|
|
202
|
+
let escaped = false;
|
|
203
|
+
for (let i = 0; i < text.length; i++) {
|
|
204
|
+
const ch = text[i];
|
|
205
|
+
if (inString) {
|
|
206
|
+
out += ch;
|
|
207
|
+
if (escaped)
|
|
208
|
+
escaped = false;
|
|
209
|
+
else if (ch === '\\')
|
|
210
|
+
escaped = true;
|
|
211
|
+
else if (ch === '"')
|
|
212
|
+
inString = false;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (ch === '"') {
|
|
216
|
+
inString = true;
|
|
217
|
+
out += ch;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
221
|
+
while (i < text.length && text[i] !== '\n')
|
|
222
|
+
i++;
|
|
223
|
+
out += '\n';
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
227
|
+
i += 2;
|
|
228
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/'))
|
|
229
|
+
i++;
|
|
230
|
+
i++;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
out += ch;
|
|
234
|
+
}
|
|
235
|
+
// A trailing comma is the other thing a hand-written file grows.
|
|
236
|
+
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
237
|
+
}
|
|
238
|
+
//# sourceMappingURL=recipe.js.map
|
package/dist/self-update.d.ts
CHANGED
|
@@ -45,6 +45,13 @@ export interface SelfUpdateOptions {
|
|
|
45
45
|
packageDir?: string | null;
|
|
46
46
|
/** Test seam: is this process supervised (defaults to autodetect). */
|
|
47
47
|
supervised?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Test seam for the resource-limits drop-in. Injected rather than called
|
|
50
|
+
* directly because the real one writes into `$HOME` — a test that exercised
|
|
51
|
+
* the update path would otherwise reconfigure the developer's own service.
|
|
52
|
+
* Returns true when it wrote something (and therefore needs a daemon-reload).
|
|
53
|
+
*/
|
|
54
|
+
writeLimits?: () => boolean;
|
|
48
55
|
}
|
|
49
56
|
/**
|
|
50
57
|
* Directory of the installed runner package, or null when it is running from a
|
package/dist/self-update.js
CHANGED
|
@@ -5,6 +5,7 @@ import { promisify } from 'node:util';
|
|
|
5
5
|
import { log } from './log.js';
|
|
6
6
|
import { stateDir } from './paths.js';
|
|
7
7
|
import { RUNNER_VERSION } from './version.js';
|
|
8
|
+
import { buildUnit, limitsOverridePath, unitIsBroken, unitPath, writeLimitsOverride, LIMITS_VERSION, } from './service-unit.js';
|
|
8
9
|
const execFileAsync = promisify(execFile);
|
|
9
10
|
const NPM_TIMEOUT_MS = 180_000;
|
|
10
11
|
const VERIFY_TIMEOUT_MS = 30_000;
|
|
@@ -82,19 +83,100 @@ export function isTrustedTarballUrl(tarballUrl, apiUrl) {
|
|
|
82
83
|
}
|
|
83
84
|
return target.origin === api.origin && target.pathname.endsWith('.tgz');
|
|
84
85
|
}
|
|
85
|
-
function
|
|
86
|
+
function readManifest(packageDir) {
|
|
86
87
|
try {
|
|
87
|
-
|
|
88
|
-
return typeof parsed.version === 'string' ? parsed.version : null;
|
|
88
|
+
return JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
|
|
89
89
|
}
|
|
90
90
|
catch {
|
|
91
91
|
return null;
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
function readVersion(packageDir) {
|
|
95
|
+
const value = readManifest(packageDir)?.version;
|
|
96
|
+
return typeof value === 'string' ? value : null;
|
|
97
|
+
}
|
|
94
98
|
/** The CLI entry point of an installed package, used to smoke-test the update. */
|
|
95
99
|
function binPath(packageDir) {
|
|
96
100
|
return path.join(packageDir, 'dist', 'index.js');
|
|
97
101
|
}
|
|
102
|
+
/** The command every systemd unit on every user's machine points at. */
|
|
103
|
+
const COMMAND_NAME = 'devbridge-runner';
|
|
104
|
+
/**
|
|
105
|
+
* Which installed package currently owns the `devbridge-runner` command.
|
|
106
|
+
*
|
|
107
|
+
* Asked instead of assumed, because an update may RENAME the package (it did:
|
|
108
|
+
* `@devbridge/runner` → `@bridge4dev/runner`). After that the directory this
|
|
109
|
+
* process is running from belongs to the version being retired, while the
|
|
110
|
+
* command — the stable thing, referenced by a unit file we do not control —
|
|
111
|
+
* points at the new one. So we follow the command.
|
|
112
|
+
*/
|
|
113
|
+
async function commandOwner(exec) {
|
|
114
|
+
let prefix;
|
|
115
|
+
try {
|
|
116
|
+
const result = await exec('npm', ['prefix', '-g'], {
|
|
117
|
+
timeout: VERIFY_TIMEOUT_MS,
|
|
118
|
+
env: npmEnv(),
|
|
119
|
+
});
|
|
120
|
+
prefix = result.stdout.trim();
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
if (!prefix)
|
|
126
|
+
return null;
|
|
127
|
+
const command = path.join(prefix, 'bin', COMMAND_NAME);
|
|
128
|
+
try {
|
|
129
|
+
if (!fs.existsSync(command))
|
|
130
|
+
return null;
|
|
131
|
+
// <dir>/dist/index.js → <dir>
|
|
132
|
+
const dir = path.resolve(path.dirname(fs.realpathSync(command)), '..');
|
|
133
|
+
const name = readManifest(dir)?.name;
|
|
134
|
+
return typeof name === 'string' ? { name, dir, command } : null;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function installArgs(source) {
|
|
141
|
+
// `--ignore-scripts` matches the documented install: this package and its whole
|
|
142
|
+
// tree have no install/postinstall scripts, so nothing legitimate is skipped —
|
|
143
|
+
// and an update pulled over the network gets no chance to run anything at
|
|
144
|
+
// install time. `--loglevel=error` because npm's ERESOLVE warning about zod is
|
|
145
|
+
// expected, harmless and long enough to bury the line that matters.
|
|
146
|
+
return ['install', '-g', '--ignore-scripts', '--loglevel=error', source];
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Install a global package, clearing the way if a DIFFERENTLY-NAMED build of this
|
|
150
|
+
* same runner still owns the command.
|
|
151
|
+
*
|
|
152
|
+
* npm refuses to take over an existing bin symlink (`EEXIST`) — which is exactly
|
|
153
|
+
* what happens when the package is renamed: the new name cannot claim
|
|
154
|
+
* `devbridge-runner` while the old name holds it, so «Update runner» failed with
|
|
155
|
+
* a wall of unrelated zod warnings. Retiring the previous package first leaves
|
|
156
|
+
* one owner instead of two, which is also the only state the NEXT update can
|
|
157
|
+
* work from.
|
|
158
|
+
*/
|
|
159
|
+
async function installGlobal(exec, source) {
|
|
160
|
+
try {
|
|
161
|
+
await exec('npm', installArgs(source), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (!/EEXIST/i.test(describe(error)))
|
|
166
|
+
throw error;
|
|
167
|
+
const owner = await commandOwner(exec);
|
|
168
|
+
if (!owner)
|
|
169
|
+
throw error;
|
|
170
|
+
log.warn('self-update: the command belongs to another package — retiring it', {
|
|
171
|
+
package: owner.name,
|
|
172
|
+
});
|
|
173
|
+
await exec('npm', ['uninstall', '-g', '--loglevel=error', owner.name], {
|
|
174
|
+
timeout: NPM_TIMEOUT_MS,
|
|
175
|
+
env: npmEnv(),
|
|
176
|
+
});
|
|
177
|
+
await exec('npm', installArgs(source), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
98
180
|
/**
|
|
99
181
|
* npm needs PATH/HOME and a writable cache; everything else is stripped, both to
|
|
100
182
|
* keep provider credentials out of a child process that touches the network and
|
|
@@ -160,24 +242,22 @@ export async function selfUpdate(options) {
|
|
|
160
242
|
return fail('Could not prepare a rollback copy of the current version — update aborted');
|
|
161
243
|
}
|
|
162
244
|
try {
|
|
163
|
-
|
|
164
|
-
// whole tree have no install/postinstall scripts, so nothing legitimate is
|
|
165
|
-
// skipped — and an update pulled over the network gets no chance to run
|
|
166
|
-
// anything at install time.
|
|
167
|
-
await exec('npm', ['install', '-g', '--ignore-scripts', options.tarballUrl], {
|
|
168
|
-
timeout: NPM_TIMEOUT_MS,
|
|
169
|
-
env: npmEnv(),
|
|
170
|
-
});
|
|
245
|
+
await installGlobal(exec, options.tarballUrl);
|
|
171
246
|
}
|
|
172
247
|
catch (error) {
|
|
173
248
|
return fail(`Install failed: ${describe(error)}`, { rollbackTarball });
|
|
174
249
|
}
|
|
175
|
-
|
|
250
|
+
// Where the new build actually landed. After a rename `packageDir` is the
|
|
251
|
+
// directory we just retired, so its manifest would report the OLD version —
|
|
252
|
+
// and the smoke test below would run code that no longer exists.
|
|
253
|
+
const installed = await commandOwner(exec);
|
|
254
|
+
const newPackageDir = installed?.dir ?? packageDir;
|
|
255
|
+
const toVersion = readVersion(newPackageDir) ?? undefined;
|
|
176
256
|
// The real test: does the newly installed build start? `--version` loads the
|
|
177
257
|
// whole module graph, so a half-downloaded package or a missing dependency
|
|
178
258
|
// fails here rather than after the restart, when nobody could see it.
|
|
179
259
|
try {
|
|
180
|
-
const probe = await exec(process.execPath, [binPath(
|
|
260
|
+
const probe = await exec(process.execPath, [binPath(newPackageDir), '--version'], {
|
|
181
261
|
timeout: VERIFY_TIMEOUT_MS,
|
|
182
262
|
env: npmEnv(),
|
|
183
263
|
});
|
|
@@ -190,10 +270,10 @@ export async function selfUpdate(options) {
|
|
|
190
270
|
const detail = describe(error);
|
|
191
271
|
log.error('self-update: the new build did not start — rolling back', { error: detail });
|
|
192
272
|
try {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
273
|
+
// Through the same door as the install above: if the failed update renamed
|
|
274
|
+
// the package, the command now belongs to the new name and putting the old
|
|
275
|
+
// one back hits the very same EEXIST.
|
|
276
|
+
await installGlobal(exec, rollbackTarball);
|
|
197
277
|
return fail(`The new version did not start (${detail}). The previous version was restored and the runner keeps working.`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
|
|
198
278
|
}
|
|
199
279
|
catch (rollbackError) {
|
|
@@ -201,6 +281,55 @@ export async function selfUpdate(options) {
|
|
|
201
281
|
`Restore it on the server with: npm install -g ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
|
|
202
282
|
}
|
|
203
283
|
}
|
|
284
|
+
// The service unit may be pinned to a file inside the directory this update
|
|
285
|
+
// just replaced — early versions wrote the resolved script path, and a package
|
|
286
|
+
// rename moves it. Then the restart we are about to ask for would fail with
|
|
287
|
+
// ENOENT and the runner would never come back. Repair it here, while the
|
|
288
|
+
// process is still alive to do it.
|
|
289
|
+
if (installed?.command && unitIsBroken()) {
|
|
290
|
+
try {
|
|
291
|
+
fs.mkdirSync(path.dirname(unitPath()), { recursive: true });
|
|
292
|
+
fs.writeFileSync(unitPath(), buildUnit(installed.command));
|
|
293
|
+
await exec('systemctl', ['--user', 'daemon-reload'], {
|
|
294
|
+
timeout: VERIFY_TIMEOUT_MS,
|
|
295
|
+
env: npmEnv(),
|
|
296
|
+
});
|
|
297
|
+
log.warn('self-update: the service unit pointed at the previous location — rewritten', {
|
|
298
|
+
execStart: installed.command,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
return fail(`The new version is installed, but the service still points at the previous location and ` +
|
|
303
|
+
`could not be repaired (${describe(error)}). Run \`devbridge-runner install-service\` on the server.`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// The resource policy is versioned separately from the code, because the unit
|
|
307
|
+
// is written once at install time and the numbers baked into it before 0.21.0
|
|
308
|
+
// are what killed live sessions (QA-112 BLOCKER-1). An update is the only
|
|
309
|
+
// moment we are guaranteed to be running on the server with the right to fix
|
|
310
|
+
// that, so it happens here — and the restart below is what makes it effective.
|
|
311
|
+
//
|
|
312
|
+
// Deliberately NOT fatal: a runner that refuses to update because it could not
|
|
313
|
+
// improve its own limits is strictly worse than one that updates without the
|
|
314
|
+
// improvement. It is logged loudly and `doctor` reports it.
|
|
315
|
+
try {
|
|
316
|
+
if ((options.writeLimits ?? writeLimitsOverride)()) {
|
|
317
|
+
await exec('systemctl', ['--user', 'daemon-reload'], {
|
|
318
|
+
timeout: VERIFY_TIMEOUT_MS,
|
|
319
|
+
env: npmEnv(),
|
|
320
|
+
});
|
|
321
|
+
log.warn('self-update: resource limits drop-in written', {
|
|
322
|
+
path: limitsOverridePath(),
|
|
323
|
+
version: LIMITS_VERSION,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
log.error('self-update: could not write the resource limits drop-in', {
|
|
329
|
+
error: describe(error),
|
|
330
|
+
hint: 'run `devbridge-runner doctor --fix` on the server',
|
|
331
|
+
});
|
|
332
|
+
}
|
|
204
333
|
log.info('self-update: installed', { fromVersion, toVersion });
|
|
205
334
|
return {
|
|
206
335
|
ok: true,
|
|
@@ -210,12 +339,31 @@ export async function selfUpdate(options) {
|
|
|
210
339
|
rollbackTarball,
|
|
211
340
|
};
|
|
212
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* The reason a child process failed, in 400 characters that are actually about
|
|
344
|
+
* the failure.
|
|
345
|
+
*
|
|
346
|
+
* npm puts its diagnosis at the END of stderr and everything it merely wants to
|
|
347
|
+
* mention at the beginning. Taking the first 400 characters is therefore exactly
|
|
348
|
+
* backwards: a real `npm error code EEXIST` was reported to the owner as a wall
|
|
349
|
+
* of ERESOLVE peer-dependency warnings about zod, which are harmless and always
|
|
350
|
+
* present. So: keep the `npm error` lines when there are any, and otherwise keep
|
|
351
|
+
* the tail rather than the head.
|
|
352
|
+
*/
|
|
213
353
|
function describe(error) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
354
|
+
const raw = error instanceof Error
|
|
355
|
+
? (() => {
|
|
356
|
+
const withStderr = error;
|
|
357
|
+
const stderr = typeof withStderr.stderr === 'string' ? withStderr.stderr.trim() : '';
|
|
358
|
+
return stderr || error.message;
|
|
359
|
+
})()
|
|
360
|
+
: String(error);
|
|
361
|
+
const npmErrors = raw
|
|
362
|
+
.split('\n')
|
|
363
|
+
.filter((line) => /^\s*npm (error|ERR!)/i.test(line))
|
|
364
|
+
.join('\n')
|
|
365
|
+
.trim();
|
|
366
|
+
const meaningful = npmErrors || raw;
|
|
367
|
+
return meaningful.length > 400 ? `…${meaningful.slice(-400)}` : meaningful;
|
|
220
368
|
}
|
|
221
369
|
//# sourceMappingURL=self-update.js.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The systemd user unit, and the one decision inside it that matters: what to
|
|
3
|
+
* exec.
|
|
4
|
+
*
|
|
5
|
+
* The first version baked `realpathSync(process.argv[1])` — the resolved file
|
|
6
|
+
* inside the installed package directory. That pins the service to a directory
|
|
7
|
+
* that a package RENAME deletes: `@devbridge/runner` → `@bridge4dev/runner` moved
|
|
8
|
+
* the file, the unit kept pointing at the old path, and `systemctl restart`
|
|
9
|
+
* failed with ENOENT. The runner simply never came back and the server went
|
|
10
|
+
* offline until someone logged in — the one outcome an update must never produce.
|
|
11
|
+
*
|
|
12
|
+
* So the unit execs the COMMAND (`<prefix>/bin/devbridge-runner`), which npm
|
|
13
|
+
* re-creates on every install whatever the package is called. A source checkout
|
|
14
|
+
* has no such symlink, and there the resolved script is the honest answer.
|
|
15
|
+
*/
|
|
16
|
+
export declare const SERVICE_NAME = "devbridge-runner";
|
|
17
|
+
/** `<prefix>/bin/devbridge-runner` for an installed package, else the script. */
|
|
18
|
+
export declare function unitExecTarget(argv1?: string): {
|
|
19
|
+
execStart: string;
|
|
20
|
+
viaCommand: boolean;
|
|
21
|
+
};
|
|
22
|
+
export declare function unitPath(home?: string): string;
|
|
23
|
+
export declare function buildUnit(execStart?: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Resource policy for the service, and why it does not live in the unit above.
|
|
26
|
+
*
|
|
27
|
+
* The unit is written ONCE, at install time. Until 0.21.0 it carried
|
|
28
|
+
* `CPUQuota=80%` and `MemoryMax=2G` — and those two lines cost a production
|
|
29
|
+
* server every session it was running (QA-112 BLOCKER-1, 2026-07-30):
|
|
30
|
+
*
|
|
31
|
+
* - every agent the runner starts, and every `pnpm build`/`tsc`/`vitest` that
|
|
32
|
+
* agent runs, is a CHILD of this service and therefore inside this cgroup.
|
|
33
|
+
* Three measured `claude` processes are 512/577/646 MB and one workspace
|
|
34
|
+
* `pnpm -r typecheck` peaks at 1571 MB, against a ceiling of 2048 MB;
|
|
35
|
+
* - `OOMPolicy` defaults to `stop`, so the kernel killing ONE of those children
|
|
36
|
+
* tore down the whole service — and with it every other session on the
|
|
37
|
+
* machine. `Restart=always` then brought the daemon back with an empty
|
|
38
|
+
* session map, which is what the user sees as
|
|
39
|
+
* «Runner reconnected. The session was resumed»;
|
|
40
|
+
* - `CPUQuota=80%` is 0.8 of ONE core for all of the above, which is separately
|
|
41
|
+
* what starves the event loop until the gateway's heartbeat gives up on it.
|
|
42
|
+
*
|
|
43
|
+
* So the policy is versioned and shipped as a drop-in instead. A drop-in can
|
|
44
|
+
* RESET a directive the main unit set (`MemoryMax=` with no value clears it),
|
|
45
|
+
* which is the only way to fix the servers that already have the bad numbers
|
|
46
|
+
* baked in — and it never overwrites a unit the operator edited by hand.
|
|
47
|
+
*/
|
|
48
|
+
export declare const LIMITS_VERSION = 2;
|
|
49
|
+
export declare function limitsOverridePath(home?: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* `CPUQuota` worth keeping: enough headroom that a runaway build cannot make the
|
|
52
|
+
* box unreachable, but never so little that ordinary work is throttled.
|
|
53
|
+
*
|
|
54
|
+
* One core is reserved for the system (sshd, journald, the runner's own event
|
|
55
|
+
* loop). Below four cores there is nothing to reserve without crippling the
|
|
56
|
+
* agents, so no quota is set at all — an unusable dev server is a worse failure
|
|
57
|
+
* than a busy one.
|
|
58
|
+
*/
|
|
59
|
+
export declare function cpuQuotaPercent(cpuCount?: number): number | null;
|
|
60
|
+
export declare function buildLimitsOverride(cpuCount?: number): string;
|
|
61
|
+
/**
|
|
62
|
+
* Is the shipped resource policy missing or from an older runner?
|
|
63
|
+
*
|
|
64
|
+
* Deliberately version-based rather than content-based: an operator may add
|
|
65
|
+
* their own directives to our file, and re-writing on every start would fight
|
|
66
|
+
* them. Only the version number decides.
|
|
67
|
+
*/
|
|
68
|
+
export declare function limitsOverrideIsOutdated(readFile?: (p: string) => string, home?: string): boolean;
|
|
69
|
+
/** Write the drop-in. Returns false when nothing needed doing. */
|
|
70
|
+
export declare function writeLimitsOverride(force?: boolean, home?: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Does the installed unit point at something that no longer exists?
|
|
73
|
+
*
|
|
74
|
+
* Used after an update: if the package moved (a rename), a unit pinned to the old
|
|
75
|
+
* directory would kill the runner on the very restart the update asks for. Only a
|
|
76
|
+
* missing target counts — a unit the user edited on purpose is left alone.
|
|
77
|
+
*/
|
|
78
|
+
export declare function unitIsBroken(readFile?: (p: string) => string): boolean;
|
|
79
|
+
//# sourceMappingURL=service-unit.d.ts.map
|