@bridge4dev/runner 0.22.1 → 0.27.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.
@@ -179,11 +179,11 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
179
179
  id: string;
180
180
  title: string;
181
181
  }[];
182
- mode?: "ask" | "plan" | "auto" | "full" | undefined;
183
182
  mcp?: {
184
183
  url: string;
185
184
  token: string;
186
185
  } | undefined;
186
+ mode?: "ask" | "plan" | "auto" | "full" | undefined;
187
187
  model?: string | null | undefined;
188
188
  effort?: string | null | undefined;
189
189
  epoch?: number | undefined;
@@ -381,11 +381,11 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
381
381
  id: string;
382
382
  title: string;
383
383
  }[];
384
- mode?: "ask" | "plan" | "auto" | "full" | undefined;
385
384
  mcp?: {
386
385
  url: string;
387
386
  token: string;
388
387
  } | undefined;
388
+ mode?: "ask" | "plan" | "auto" | "full" | undefined;
389
389
  model?: string | null | undefined;
390
390
  effort?: string | null | undefined;
391
391
  epoch?: number | undefined;
@@ -469,11 +469,11 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
469
469
  id: string;
470
470
  title: string;
471
471
  }[];
472
- mode?: "ask" | "plan" | "auto" | "full" | undefined;
473
472
  mcp?: {
474
473
  url: string;
475
474
  token: string;
476
475
  } | undefined;
476
+ mode?: "ask" | "plan" | "auto" | "full" | undefined;
477
477
  model?: string | null | undefined;
478
478
  effort?: string | null | undefined;
479
479
  epoch?: number | undefined;
@@ -695,11 +695,11 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
695
695
  id: string;
696
696
  title: string;
697
697
  }[];
698
- mode?: "ask" | "plan" | "auto" | "full" | undefined;
699
698
  mcp?: {
700
699
  url: string;
701
700
  token: string;
702
701
  } | undefined;
702
+ mode?: "ask" | "plan" | "auto" | "full" | undefined;
703
703
  model?: string | null | undefined;
704
704
  effort?: string | null | undefined;
705
705
  epoch?: number | undefined;
@@ -778,11 +778,11 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
778
778
  id: string;
779
779
  title: string;
780
780
  }[];
781
- mode?: "ask" | "plan" | "auto" | "full" | undefined;
782
781
  mcp?: {
783
782
  url: string;
784
783
  token: string;
785
784
  } | undefined;
785
+ mode?: "ask" | "plan" | "auto" | "full" | undefined;
786
786
  model?: string | null | undefined;
787
787
  effort?: string | null | undefined;
788
788
  epoch?: number | undefined;
@@ -961,16 +961,16 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
961
961
  sessionId: z.ZodOptional<z.ZodString>;
962
962
  args: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
963
963
  }, "strip", z.ZodTypeAny, {
964
+ name: string;
964
965
  type: "command";
965
966
  requestId: string;
966
- name: string;
967
967
  sessionId?: string | undefined;
968
968
  workspaceId?: string | undefined;
969
969
  args?: Record<string, unknown> | undefined;
970
970
  }, {
971
+ name: string;
971
972
  type: "command";
972
973
  requestId: string;
973
- name: string;
974
974
  sessionId?: string | undefined;
975
975
  workspaceId?: string | undefined;
976
976
  args?: Record<string, unknown> | undefined;
@@ -253,6 +253,7 @@ export declare const ProjectRecipeSchema: z.ZodObject<{
253
253
  shaPath?: string | undefined;
254
254
  } | undefined;
255
255
  }, {
256
+ version?: 1 | undefined;
256
257
  preview?: {
257
258
  run: string;
258
259
  url?: string | undefined;
@@ -260,7 +261,6 @@ export declare const ProjectRecipeSchema: z.ZodObject<{
260
261
  timeoutSec?: number | undefined;
261
262
  stop?: string | undefined;
262
263
  } | undefined;
263
- version?: 1 | undefined;
264
264
  notes?: string | undefined;
265
265
  steps?: {
266
266
  verify?: {
@@ -62,6 +62,45 @@ export interface SelfUpdateOptions {
62
62
  * has one above `packages/runner`.
63
63
  */
64
64
  export declare function resolveInstalledPackageDir(entry?: string): string | null;
65
+ /**
66
+ * The npm prefix the RUNNING package was installed into.
67
+ *
68
+ * This is the whole fix for the dedicated-user layout. `npm install -g` obeys
69
+ * npm's *configured* prefix, and `--prefix` is a per-invocation flag that npm
70
+ * persists nowhere — so the install the instructions prescribed for a dedicated
71
+ * user (`npm install -g --prefix ~/.local …`) produced a runner in
72
+ * `~/.local/lib/node_modules` while every later `npm install -g` aimed at
73
+ * `/usr`. Measured on a live server: `npm prefix -g` → `/usr`,
74
+ * `installIsWritable` → true (both checked directories really are the user's),
75
+ * and then npm dies with EACCES *inside* the update — the exact
76
+ * «the permissions to access this file as the current user» the owner pasted.
77
+ *
78
+ * Deriving the prefix from where we physically are is the truthful answer: it
79
+ * cannot disagree with reality, and it costs no subprocess. Returns null for a
80
+ * layout that is not a global npm install (a source checkout, a vendored copy),
81
+ * where the caller should fall back to `npm prefix -g`.
82
+ *
83
+ * `<prefix>/lib/node_modules/@scope/pkg` → `<prefix>`
84
+ * `<prefix>/lib/node_modules/pkg` → `<prefix>`
85
+ */
86
+ export declare function installPrefixFor(packageDir?: string | null): string | null;
87
+ /**
88
+ * Can this user actually replace the installed package?
89
+ *
90
+ * The two legitimate installs part ways here. `npm install -g` run by root puts
91
+ * the package in `/usr/lib/node_modules` owned by root; the daemon then runs as
92
+ * a dedicated user, who cannot write there. The button was offered anyway and
93
+ * failed halfway through npm with «the permissions to access this file as the
94
+ * current user» — a dashboard button that cannot work, and an error in npm's
95
+ * words rather than ours.
96
+ *
97
+ * Three directories matter, because npm rewrites all three: the package itself,
98
+ * the `node_modules` above it, and `<prefix>/bin`, where the command symlink
99
+ * lives. `bin` was missing here and is not hypothetical — a prefix whose
100
+ * `lib/node_modules` was handed over with chown while `bin` stayed root-owned
101
+ * passes the first two and still fails.
102
+ */
103
+ export declare function installIsWritable(packageDir?: string | null): boolean;
65
104
  /**
66
105
  * Is something going to restart us?
67
106
  *
@@ -78,5 +117,21 @@ export declare function isSupervisedProcess(env?: NodeJS.ProcessEnv): boolean;
78
117
  * never be talked into fetching its own replacement over http.
79
118
  */
80
119
  export declare function isTrustedTarballUrl(tarballUrl: string, apiUrl: string): boolean;
120
+ /**
121
+ * The command a person can paste on the server to update this exact machine.
122
+ *
123
+ * Built by the runner rather than by the dashboard because only the runner
124
+ * knows the three things that make it correct: which prefix it lives in, which
125
+ * user it runs as, and that user's uid for `XDG_RUNTIME_DIR`. The dashboard
126
+ * used to assemble a generic `npm install -g <url> && systemctl --user restart`
127
+ * and then patch it with string surgery — which produced a command that could
128
+ * not work on precisely the machines that needed it.
129
+ */
130
+ export declare function manualUpdateCommand(tarballUrl: string, options?: {
131
+ prefix?: string | null;
132
+ user?: string;
133
+ uid?: number;
134
+ packageDir?: string | null;
135
+ }): string;
81
136
  export declare function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdateOutcome>;
82
137
  //# sourceMappingURL=self-update.d.ts.map
@@ -1,5 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { promisify } from 'node:util';
5
6
  import { log } from './log.js';
@@ -54,6 +55,85 @@ export function resolveInstalledPackageDir(entry = process.argv[1] ?? '') {
54
55
  }
55
56
  return null;
56
57
  }
58
+ /**
59
+ * The npm prefix the RUNNING package was installed into.
60
+ *
61
+ * This is the whole fix for the dedicated-user layout. `npm install -g` obeys
62
+ * npm's *configured* prefix, and `--prefix` is a per-invocation flag that npm
63
+ * persists nowhere — so the install the instructions prescribed for a dedicated
64
+ * user (`npm install -g --prefix ~/.local …`) produced a runner in
65
+ * `~/.local/lib/node_modules` while every later `npm install -g` aimed at
66
+ * `/usr`. Measured on a live server: `npm prefix -g` → `/usr`,
67
+ * `installIsWritable` → true (both checked directories really are the user's),
68
+ * and then npm dies with EACCES *inside* the update — the exact
69
+ * «the permissions to access this file as the current user» the owner pasted.
70
+ *
71
+ * Deriving the prefix from where we physically are is the truthful answer: it
72
+ * cannot disagree with reality, and it costs no subprocess. Returns null for a
73
+ * layout that is not a global npm install (a source checkout, a vendored copy),
74
+ * where the caller should fall back to `npm prefix -g`.
75
+ *
76
+ * `<prefix>/lib/node_modules/@scope/pkg` → `<prefix>`
77
+ * `<prefix>/lib/node_modules/pkg` → `<prefix>`
78
+ */
79
+ export function installPrefixFor(packageDir = resolveInstalledPackageDir()) {
80
+ if (!packageDir)
81
+ return null;
82
+ const marker = `${path.sep}node_modules${path.sep}`;
83
+ const at = packageDir.lastIndexOf(marker);
84
+ if (at < 0)
85
+ return null;
86
+ const nodeModules = packageDir.slice(0, at + marker.length - 1);
87
+ const lib = path.dirname(nodeModules);
88
+ // Anything else is not the layout `npm install -g --prefix` produces, and
89
+ // guessing a prefix that npm would not use is worse than not guessing.
90
+ if (path.basename(lib) !== 'lib')
91
+ return null;
92
+ return path.dirname(lib);
93
+ }
94
+ function isWritable(target) {
95
+ try {
96
+ fs.accessSync(target, fs.constants.W_OK);
97
+ return true;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ /**
104
+ * Can this user actually replace the installed package?
105
+ *
106
+ * The two legitimate installs part ways here. `npm install -g` run by root puts
107
+ * the package in `/usr/lib/node_modules` owned by root; the daemon then runs as
108
+ * a dedicated user, who cannot write there. The button was offered anyway and
109
+ * failed halfway through npm with «the permissions to access this file as the
110
+ * current user» — a dashboard button that cannot work, and an error in npm's
111
+ * words rather than ours.
112
+ *
113
+ * Three directories matter, because npm rewrites all three: the package itself,
114
+ * the `node_modules` above it, and `<prefix>/bin`, where the command symlink
115
+ * lives. `bin` was missing here and is not hypothetical — a prefix whose
116
+ * `lib/node_modules` was handed over with chown while `bin` stayed root-owned
117
+ * passes the first two and still fails.
118
+ */
119
+ export function installIsWritable(packageDir = resolveInstalledPackageDir()) {
120
+ if (!packageDir)
121
+ return false;
122
+ const marker = `${path.sep}node_modules${path.sep}`;
123
+ const at = packageDir.lastIndexOf(marker);
124
+ // `<prefix>/lib/node_modules/@scope/pkg` → `<prefix>/lib/node_modules`
125
+ const nodeModules = at >= 0 ? packageDir.slice(0, at + marker.length - 1) : path.dirname(packageDir);
126
+ if (!isWritable(packageDir) || !isWritable(nodeModules))
127
+ return false;
128
+ const prefix = installPrefixFor(packageDir);
129
+ // No `bin` yet is fine — npm creates it. An existing one we cannot write is not.
130
+ if (prefix) {
131
+ const bin = path.join(prefix, 'bin');
132
+ if (fs.existsSync(bin) && !isWritable(bin))
133
+ return false;
134
+ }
135
+ return true;
136
+ }
57
137
  /**
58
138
  * Is something going to restart us?
59
139
  *
@@ -110,17 +190,23 @@ const COMMAND_NAME = 'devbridge-runner';
110
190
  * command — the stable thing, referenced by a unit file we do not control —
111
191
  * points at the new one. So we follow the command.
112
192
  */
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;
193
+ async function commandOwner(exec, installPrefix = null) {
194
+ // Where we actually live outranks what npm is configured to think. Under the
195
+ // dedicated-user layout `npm prefix -g` answers `/usr` — a directory that on
196
+ // such a host holds no runner at all — so asking npm first returned null and
197
+ // took the EEXIST rename-recovery and the unit repair down with it.
198
+ let prefix = installPrefix;
199
+ if (!prefix) {
200
+ try {
201
+ const result = await exec('npm', ['prefix', '-g'], {
202
+ timeout: VERIFY_TIMEOUT_MS,
203
+ env: npmEnv(),
204
+ });
205
+ prefix = result.stdout.trim();
206
+ }
207
+ catch {
208
+ return null;
209
+ }
124
210
  }
125
211
  if (!prefix)
126
212
  return null;
@@ -137,13 +223,55 @@ async function commandOwner(exec) {
137
223
  return null;
138
224
  }
139
225
  }
140
- function installArgs(source) {
226
+ function installArgs(source, prefix) {
141
227
  // `--ignore-scripts` matches the documented install: this package and its whole
142
228
  // tree have no install/postinstall scripts, so nothing legitimate is skipped —
143
229
  // and an update pulled over the network gets no chance to run anything at
144
230
  // install time. `--loglevel=error` because npm's ERESOLVE warning about zod is
145
231
  // expected, harmless and long enough to bury the line that matters.
146
- return ['install', '-g', '--ignore-scripts', '--loglevel=error', source];
232
+ //
233
+ // `--prefix` names the prefix the runner is ALREADY installed in. Without it
234
+ // npm uses its configured global prefix, which on a dedicated-user install is
235
+ // a directory the daemon cannot write — and, on the rarer host where it can,
236
+ // npm cheerfully installs a SECOND copy somewhere the service does not exec,
237
+ // reports success, and the runner restarts on the old version forever.
238
+ return [
239
+ 'install',
240
+ '-g',
241
+ '--ignore-scripts',
242
+ '--loglevel=error',
243
+ ...(prefix ? ['--prefix', prefix] : []),
244
+ source,
245
+ ];
246
+ }
247
+ /**
248
+ * The command a person can paste on the server to update this exact machine.
249
+ *
250
+ * Built by the runner rather than by the dashboard because only the runner
251
+ * knows the three things that make it correct: which prefix it lives in, which
252
+ * user it runs as, and that user's uid for `XDG_RUNTIME_DIR`. The dashboard
253
+ * used to assemble a generic `npm install -g <url> && systemctl --user restart`
254
+ * and then patch it with string surgery — which produced a command that could
255
+ * not work on precisely the machines that needed it.
256
+ */
257
+ export function manualUpdateCommand(tarballUrl, options = {}) {
258
+ const packageDir = options.packageDir === undefined ? resolveInstalledPackageDir() : options.packageDir;
259
+ const prefix = options.prefix === undefined ? installPrefixFor(packageDir) : options.prefix;
260
+ const user = options.user ?? os.userInfo().username;
261
+ const uid = options.uid ?? (typeof process.getuid === 'function' ? process.getuid() : -1);
262
+ const install = ['npm install -g --ignore-scripts --loglevel=error']
263
+ .concat(prefix ? [`--prefix ${prefix}`] : [])
264
+ .concat([tarballUrl])
265
+ .join(' ');
266
+ const restart = 'systemctl --user restart devbridge-runner';
267
+ // Writable by us → one line, run as ourselves. Not writable → the install
268
+ // half needs root and the restart half needs the daemon's own user, and
269
+ // «run it as root» alone is how people ended up with a new package and an
270
+ // old process still running.
271
+ if (installIsWritable(packageDir))
272
+ return `${install} && ${restart}`;
273
+ const runtime = uid >= 0 ? `/run/user/${uid}` : `/run/user/$(id -u ${user})`;
274
+ return `sudo ${install} && sudo -iu ${user} env XDG_RUNTIME_DIR=${runtime} ${restart}`;
147
275
  }
148
276
  /**
149
277
  * Install a global package, clearing the way if a DIFFERENTLY-NAMED build of this
@@ -156,25 +284,22 @@ function installArgs(source) {
156
284
  * one owner instead of two, which is also the only state the NEXT update can
157
285
  * work from.
158
286
  */
159
- async function installGlobal(exec, source) {
287
+ async function installGlobal(exec, source, prefix) {
160
288
  try {
161
- await exec('npm', installArgs(source), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
289
+ await exec('npm', installArgs(source, prefix), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
162
290
  return;
163
291
  }
164
292
  catch (error) {
165
293
  if (!/EEXIST/i.test(describe(error)))
166
294
  throw error;
167
- const owner = await commandOwner(exec);
295
+ const owner = await commandOwner(exec, prefix);
168
296
  if (!owner)
169
297
  throw error;
170
298
  log.warn('self-update: the command belongs to another package — retiring it', {
171
299
  package: owner.name,
172
300
  });
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() });
301
+ await exec('npm', ['uninstall', '-g', '--loglevel=error', ...(prefix ? ['--prefix', prefix] : []), owner.name], { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
302
+ await exec('npm', installArgs(source, prefix), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
178
303
  }
179
304
  }
180
305
  /**
@@ -218,6 +343,20 @@ export async function selfUpdate(options) {
218
343
  if (!packageDir) {
219
344
  return fail('This runner runs from a source checkout, not from an installed package — update it with git instead.');
220
345
  }
346
+ // Installed by one user, run by another — the usual shape being `npm install
347
+ // -g` as root with the daemon under a dedicated user. npm gets far enough to
348
+ // start rewriting the package and then stops with EACCES, so the check has to
349
+ // happen BEFORE anything is touched. Refused with the two commands that work,
350
+ // because «run it as root» alone leaves out the restart, which needs the
351
+ // runner's own user.
352
+ if (!installIsWritable(packageDir)) {
353
+ const user = os.userInfo().username;
354
+ return fail(`The runner package in ${packageDir} belongs to another user, and this daemon runs as ${user}, ` +
355
+ 'so it cannot replace itself. Run this on the server instead:\n ' +
356
+ manualUpdateCommand(options.tarballUrl, { packageDir }));
357
+ }
358
+ // Derived once and threaded through every npm call below.
359
+ const prefix = installPrefixFor(packageDir);
221
360
  // Pack the current version FIRST: without a rollback artefact there is no
222
361
  // honest way back if the new build turns out to be broken.
223
362
  const rollbackDir = path.join(stateDir(), 'rollback');
@@ -242,7 +381,7 @@ export async function selfUpdate(options) {
242
381
  return fail('Could not prepare a rollback copy of the current version — update aborted');
243
382
  }
244
383
  try {
245
- await installGlobal(exec, options.tarballUrl);
384
+ await installGlobal(exec, options.tarballUrl, prefix);
246
385
  }
247
386
  catch (error) {
248
387
  return fail(`Install failed: ${describe(error)}`, { rollbackTarball });
@@ -250,7 +389,7 @@ export async function selfUpdate(options) {
250
389
  // Where the new build actually landed. After a rename `packageDir` is the
251
390
  // directory we just retired, so its manifest would report the OLD version —
252
391
  // and the smoke test below would run code that no longer exists.
253
- const installed = await commandOwner(exec);
392
+ const installed = await commandOwner(exec, prefix);
254
393
  const newPackageDir = installed?.dir ?? packageDir;
255
394
  const toVersion = readVersion(newPackageDir) ?? undefined;
256
395
  // The real test: does the newly installed build start? `--version` loads the
@@ -273,12 +412,12 @@ export async function selfUpdate(options) {
273
412
  // Through the same door as the install above: if the failed update renamed
274
413
  // the package, the command now belongs to the new name and putting the old
275
414
  // one back hits the very same EEXIST.
276
- await installGlobal(exec, rollbackTarball);
415
+ await installGlobal(exec, rollbackTarball, prefix);
277
416
  return fail(`The new version did not start (${detail}). The previous version was restored and the runner keeps working.`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
278
417
  }
279
418
  catch (rollbackError) {
280
419
  return fail(`The new version did not start (${detail}) and the rollback failed too (${describe(rollbackError)}). ` +
281
- `Restore it on the server with: npm install -g ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
420
+ `Restore it on the server with: npm install -g${prefix ? ` --prefix ${prefix}` : ''} ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
282
421
  }
283
422
  }
284
423
  // The service unit may be pinned to a file inside the directory this update
@@ -15,12 +15,24 @@
15
15
  */
16
16
  export declare const SERVICE_NAME = "devbridge-runner";
17
17
  /** `<prefix>/bin/devbridge-runner` for an installed package, else the script. */
18
+ /**
19
+ * A path that will not exist after a reboot.
20
+ *
21
+ * `fnm` (and `nvm`/`volta` in the same spirit) puts the active version's `bin`
22
+ * into a per-shell directory under `/run/user/<uid>/fnm_multishells/…` — tmpfs,
23
+ * created for one shell session. `command -v devbridge-runner` resolves there,
24
+ * so baking it into a unit produces a service that works until the shell that
25
+ * installed it goes away, and then fails with status=127 forever. Observed on a
26
+ * real install, 2026-07-31, on a ROOT install — this is not a dedicated-user
27
+ * problem, it is a per-user node manager problem.
28
+ */
29
+ export declare function isEphemeralPath(target: string): boolean;
18
30
  export declare function unitExecTarget(argv1?: string): {
19
31
  execStart: string;
20
32
  viaCommand: boolean;
21
33
  };
22
34
  export declare function unitPath(home?: string): string;
23
- export declare function buildUnit(execStart?: string): string;
35
+ export declare function buildUnit(execStart?: string, nodeBinary?: string): string;
24
36
  /**
25
37
  * Resource policy for the service, and why it does not live in the unit above.
26
38
  *
@@ -20,10 +20,31 @@ import { systemdUserHome } from './paths.js';
20
20
  export const SERVICE_NAME = 'devbridge-runner';
21
21
  const COMMAND_NAME = 'devbridge-runner';
22
22
  /** `<prefix>/bin/devbridge-runner` for an installed package, else the script. */
23
+ /**
24
+ * A path that will not exist after a reboot.
25
+ *
26
+ * `fnm` (and `nvm`/`volta` in the same spirit) puts the active version's `bin`
27
+ * into a per-shell directory under `/run/user/<uid>/fnm_multishells/…` — tmpfs,
28
+ * created for one shell session. `command -v devbridge-runner` resolves there,
29
+ * so baking it into a unit produces a service that works until the shell that
30
+ * installed it goes away, and then fails with status=127 forever. Observed on a
31
+ * real install, 2026-07-31, on a ROOT install — this is not a dedicated-user
32
+ * problem, it is a per-user node manager problem.
33
+ */
34
+ export function isEphemeralPath(target) {
35
+ // Deliberately narrow: `/run` and `/dev/shm` are tmpfs by definition, and
36
+ // `fnm_multishells` is named because fnm also offers non-tmpfs layouts. `/tmp`
37
+ // is NOT here — a package installed there is odd but survives, and tests build
38
+ // their fake installs in temp directories.
39
+ return (/^\/(run|proc)\//.test(target) ||
40
+ target.startsWith('/dev/shm/') ||
41
+ target.includes('fnm_multishells'));
42
+ }
23
43
  export function unitExecTarget(argv1 = process.argv[1] ?? '') {
24
- // Invoked through the command itself: that is already the stable path.
44
+ // Invoked through the command itself: that is already the stable path —
45
+ // unless it lives in a directory that disappears with the shell.
25
46
  try {
26
- if (fs.lstatSync(argv1).isSymbolicLink()) {
47
+ if (fs.lstatSync(argv1).isSymbolicLink() && !isEphemeralPath(path.resolve(argv1))) {
27
48
  return { execStart: path.resolve(argv1), viaCommand: true };
28
49
  }
29
50
  }
@@ -43,8 +64,9 @@ export function unitExecTarget(argv1 = process.argv[1] ?? '') {
43
64
  let dir = path.dirname(script);
44
65
  for (let i = 0; i < 6; i++) {
45
66
  const candidate = path.join(dir, 'bin', COMMAND_NAME);
46
- if (fs.existsSync(candidate))
67
+ if (fs.existsSync(candidate) && !isEphemeralPath(candidate)) {
47
68
  return { execStart: candidate, viaCommand: true };
69
+ }
48
70
  const parent = path.dirname(dir);
49
71
  if (parent === dir)
50
72
  break;
@@ -56,13 +78,19 @@ export function unitExecTarget(argv1 = process.argv[1] ?? '') {
56
78
  export function unitPath(home = systemdUserHome()) {
57
79
  return path.join(home, '.config', 'systemd', 'user', `${SERVICE_NAME}.service`);
58
80
  }
59
- export function buildUnit(execStart) {
81
+ export function buildUnit(execStart, nodeBinary = process.execPath) {
60
82
  const target = execStart ?? unitExecTarget().execStart;
61
- // The command carries a `#!/usr/bin/env node` shebang, so it is exec'd directly;
62
- // a bare script path needs the interpreter spelled out.
63
- const command = target.endsWith('.js')
64
- ? `${process.execPath} ${target} daemon`
65
- : `${target} daemon`;
83
+ /**
84
+ * The interpreter is ALWAYS spelled out, even for the command symlink.
85
+ *
86
+ * The file carries `#!/usr/bin/env node`, and relying on that shebang means
87
+ * relying on `node` being on the PATH systemd gives the service — which it is
88
+ * not when node came from fnm/nvm/volta. That produced `status=127` and an
89
+ * endless restart loop on a real machine. Node runs a file with a shebang
90
+ * perfectly well (it is a comment to it), so naming the interpreter costs
91
+ * nothing and removes the dependency entirely.
92
+ */
93
+ const command = `${nodeBinary} ${target} daemon`;
66
94
  return ([
67
95
  '[Unit]',
68
96
  'Description=DevBridge Dev Runner',
@@ -206,6 +234,9 @@ export function unitIsBroken(readFile = (p) => fs.readFileSync(p, 'utf8')) {
206
234
  const target = parts[0]?.endsWith('node') ? parts[1] : parts[0];
207
235
  if (!target)
208
236
  return false;
209
- return !fs.existsSync(target);
237
+ // Gone already, or living somewhere that will be gone after a reboot — the
238
+ // second one still runs today, which is exactly why it has to be repaired
239
+ // before the reboot rather than after it.
240
+ return !fs.existsSync(target) || isEphemeralPath(target) || isEphemeralPath(parts[0] ?? '');
210
241
  }
211
242
  //# sourceMappingURL=service-unit.js.map
@@ -12,8 +12,9 @@ import { VerifyRunner, runOneOffCommand, } from './verify.js';
12
12
  import { VerifyReportQueue } from './verify-queue.js';
13
13
  import { applySession, gitBranches, gitCommit, gitDiff, gitLog, gitPush, gitRefs, gitShow, gitStatus, revertApply, gitStage, gitUnstage, gitDiscard, gitPull, gitMergeAbort, updateFromBase, workspaceState, } from './gitops.js';
14
14
  import { fsView } from './fsview.js';
15
- import { agentAuthStatuses, AuthRelay } from './auth-relay.js';
15
+ import { agentAuthStatuses, AuthRelay, clearAgentAuthFailure, noteAgentAuthFailure, } from './auth-relay.js';
16
16
  import { selfUpdate } from './self-update.js';
17
+ import { rememberWorkspacePath } from './environment.js';
17
18
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
18
19
  export class Supervisor {
19
20
  ws;
@@ -297,6 +298,10 @@ export class Supervisor {
297
298
  * would be a lock bought for nothing.
298
299
  */
299
300
  async prepareWorkspace(descriptor) {
301
+ // Also here, not only at bind time: a server paired before this existed has
302
+ // never sent a `validate_path`, and its projects would be invisible to
303
+ // `doctor` until somebody re-bound them.
304
+ rememberWorkspacePath(descriptor.workspace.path);
300
305
  if (descriptor.workMode === 'DIRECT') {
301
306
  return prepareDirectWorkspace(descriptor.workspace.path);
302
307
  }
@@ -794,7 +799,12 @@ export class Supervisor {
794
799
  if (!running.session)
795
800
  return;
796
801
  running.parkRequested = true;
802
+ // `code` is what the dashboard reads to tell «parked» from «your turn»
803
+ // (#124). The prose stays for runners older than 0.23.0, which the
804
+ // dashboard still matches on; delete that fallback once the fleet has
805
+ // moved, not before.
797
806
  this.sendEvent(running, 'system_note', {
807
+ code: 'session_parked',
798
808
  text: 'Session parked — the runner switched to another session. Send a message to resume.',
799
809
  });
800
810
  running.session.stop('session_parked');
@@ -867,6 +877,15 @@ export class Supervisor {
867
877
  });
868
878
  return;
869
879
  }
880
+ // The retry is spent and the agent is still refused — this is the only
881
+ // authority on a login the credentials file cannot see through (a
882
+ // provider-side revocation leaves the file looking perfectly healthy).
883
+ // The panel is told from here, not from a guess (#121).
884
+ if (isAuthCode(event.code)) {
885
+ const refused = relayAgent(String(descriptor.agent).toLowerCase());
886
+ if (refused)
887
+ noteAgentAuthFailure(refused);
888
+ }
870
889
  // Forward the code: the API stores the payload as-is, so the dashboard
871
890
  // can offer "Sign in" instead of a dead error card.
872
891
  this.sendEvent(running, 'error', {
@@ -906,6 +925,13 @@ export class Supervisor {
906
925
  }
907
926
  return;
908
927
  case 'message':
928
+ // The provider answered, so this sign-in works — drop any refusal we
929
+ // are still holding against it (#121).
930
+ if (event.role === 'assistant') {
931
+ const working = relayAgent(String(descriptor.agent).toLowerCase());
932
+ if (working)
933
+ clearAgentAuthFailure(working);
934
+ }
909
935
  this.sendEvent(running, 'message', { role: event.role, text: event.text });
910
936
  return;
911
937
  case 'thinking':
@@ -1214,6 +1240,7 @@ export class Supervisor {
1214
1240
  // Parked session: the follow-up message becomes the resume prompt.
1215
1241
  if (!this.ensureCapacity(running.descriptor.id)) {
1216
1242
  this.sendEvent(running, 'system_note', {
1243
+ code: 'runner_busy',
1217
1244
  text: (this.maxSessions === 1
1218
1245
  ? 'The runner is busy with another session — this one continues as soon as it finishes its turn.'
1219
1246
  : `The runner is busy with ${this.maxSessions} other sessions — this one continues as soon as one of them finishes its turn.`) +
@@ -1579,6 +1606,10 @@ export class Supervisor {
1579
1606
  if (!path)
1580
1607
  return void reply({ ok: false, error: 'path argument is required' });
1581
1608
  const validation = await validateWorkspacePath(path);
1609
+ // Remembered so `devbridge-runner doctor` can check the permissions
1610
+ // of the real projects without being told which they are.
1611
+ if (validation.ok)
1612
+ rememberWorkspacePath(path);
1582
1613
  return void reply({
1583
1614
  ok: validation.ok,
1584
1615
  result: validation,
@@ -2209,6 +2240,9 @@ export class Supervisor {
2209
2240
  if (!agent || !code)
2210
2241
  return void reply({ ok: false, error: 'agent and code are required' });
2211
2242
  const result = await this.authRelay.submitCode(agent, code);
2243
+ // A fresh credential outranks anything we remember about the old one.
2244
+ if (result.ok)
2245
+ clearAgentAuthFailure(agent);
2212
2246
  return void reply({
2213
2247
  ok: result.ok,
2214
2248
  result,
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.22.1";
1
+ export declare const RUNNER_VERSION = "0.27.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.22.1';
2
+ export const RUNNER_VERSION = '0.27.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.22.1",
3
+ "version": "0.27.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",