@bridge4dev/runner 0.13.1 → 0.26.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.
Files changed (49) hide show
  1. package/dist/adapters/claude.d.ts +15 -7
  2. package/dist/adapters/claude.js +1024 -70
  3. package/dist/adapters/codex.d.ts +18 -3
  4. package/dist/adapters/codex.js +224 -65
  5. package/dist/adapters/questions.d.ts +42 -0
  6. package/dist/adapters/questions.js +86 -0
  7. package/dist/adapters/types.d.ts +200 -4
  8. package/dist/attachments.d.ts +8 -1
  9. package/dist/attachments.js +22 -4
  10. package/dist/auth-relay.d.ts +33 -3
  11. package/dist/auth-relay.js +199 -16
  12. package/dist/auto-resume.d.ts +18 -0
  13. package/dist/auto-resume.js +104 -0
  14. package/dist/commit-message.d.ts +51 -0
  15. package/dist/commit-message.js +224 -0
  16. package/dist/config.d.ts +29 -6
  17. package/dist/config.js +15 -0
  18. package/dist/crash-note.d.ts +54 -0
  19. package/dist/crash-note.js +105 -0
  20. package/dist/environment.d.ts +171 -0
  21. package/dist/environment.js +409 -0
  22. package/dist/git.d.ts +81 -0
  23. package/dist/git.js +301 -15
  24. package/dist/gitops.d.ts +489 -12
  25. package/dist/gitops.js +1717 -96
  26. package/dist/index.js +715 -8
  27. package/dist/paths.d.ts +35 -0
  28. package/dist/paths.js +45 -0
  29. package/dist/policy.d.ts +63 -0
  30. package/dist/policy.js +412 -10
  31. package/dist/protocol.d.ts +382 -60
  32. package/dist/protocol.js +104 -1
  33. package/dist/recipe-schema.d.ts +310 -0
  34. package/dist/recipe-schema.js +103 -0
  35. package/dist/recipe.d.ts +94 -0
  36. package/dist/recipe.js +238 -0
  37. package/dist/self-update.d.ts +21 -0
  38. package/dist/self-update.js +73 -1
  39. package/dist/service-unit.d.ts +61 -2
  40. package/dist/service-unit.js +150 -14
  41. package/dist/supervisor.d.ts +108 -1
  42. package/dist/supervisor.js +1045 -57
  43. package/dist/verify-queue.d.ts +17 -0
  44. package/dist/verify-queue.js +100 -0
  45. package/dist/verify.d.ts +203 -0
  46. package/dist/verify.js +788 -0
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +1 -1
package/dist/git.js CHANGED
@@ -2,33 +2,122 @@ import { execFile } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
- import { worktreesDir } from './paths.js';
5
+ import { previewsDir, worktreesDir } from './paths.js';
6
+ import { firstUnreachableAncestor, inspectPath, looksLikeDubiousOwnership, runnerIdentity, safeDirectoryCommand, } from './environment.js';
6
7
  const execFileAsync = promisify(execFile);
7
8
  const GIT_TIMEOUT_MS = 30_000;
8
9
  async function git(cwd, ...args) {
9
10
  const { stdout } = await execFileAsync('git', args, { cwd, timeout: GIT_TIMEOUT_MS });
10
11
  return stdout.trim();
11
12
  }
13
+ /**
14
+ * Can this runner actually work in this directory — as the user it runs as?
15
+ *
16
+ * Everything here answers with the fix rather than the symptom. Binding a
17
+ * project is the moment the two legitimate install choices (root / dedicated
18
+ * user) start to differ, and until now the second one failed by forwarding
19
+ * git's own words to a dashboard the person may have no shell behind:
20
+ * «git check failed: fatal: detected dubious ownership in repository at
21
+ * '/opt/ids'». That sentence is true and unactionable.
22
+ */
12
23
  export async function validateWorkspacePath(workspacePath) {
13
- if (!fs.existsSync(workspacePath) || !fs.statSync(workspacePath).isDirectory()) {
14
- return { ok: false, exists: false, isGitRepo: false, error: 'Directory does not exist' };
24
+ const me = runnerIdentity();
25
+ const dir = inspectPath(workspacePath);
26
+ // «Cannot look» and «is not there» are opposite instructions to whoever reads
27
+ // this, and both arrive as a thrown `statSync`. A dedicated-user install hits
28
+ // the first one constantly — usually on a directory ABOVE the project, which
29
+ // is why the blocking one is named rather than the one that was asked about.
30
+ if (dir.unreachable) {
31
+ const blocked = firstUnreachableAncestor(workspacePath) ?? workspacePath;
32
+ return {
33
+ ok: false,
34
+ exists: true,
35
+ isGitRepo: false,
36
+ error: `The runner runs as ${me.user} and is not allowed into ${blocked}, so it cannot reach ${workspacePath}. ` +
37
+ `Grant that user access to the directory (for example \`chmod o+x ${blocked}\`, ` +
38
+ `or \`setfacl -m u:${me.user}:x ${blocked}\`), then try again.`,
39
+ };
40
+ }
41
+ if (!dir.exists || !dir.isDirectory) {
42
+ return {
43
+ ok: false,
44
+ exists: dir.exists,
45
+ isGitRepo: false,
46
+ error: dir.exists
47
+ ? `${workspacePath} is not a directory`
48
+ : `${workspacePath} does not exist on this server`,
49
+ };
50
+ }
51
+ // A directory we cannot even enter: as a non-root runner this is the most
52
+ // common outcome of pointing at somebody else's project, and git's error for
53
+ // it says nothing about permissions.
54
+ if (!dir.readable) {
55
+ return {
56
+ ok: false,
57
+ exists: true,
58
+ isGitRepo: false,
59
+ error: `The runner runs as ${me.user} and cannot read ${workspacePath}. ` +
60
+ `Give that user access (for example \`setfacl -R -m u:${me.user}:rX ${workspacePath}\`, ` +
61
+ `or \`chown -R ${me.user} ${workspacePath}\` if the directory is meant to be theirs), then try again.`,
62
+ };
15
63
  }
16
64
  try {
17
65
  const inside = await git(workspacePath, 'rev-parse', '--is-inside-work-tree');
18
66
  if (inside !== 'true') {
19
67
  return { ok: false, exists: true, isGitRepo: false, error: 'Not a git work tree' };
20
68
  }
21
- const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
22
- return { ok: true, exists: true, isGitRepo: true, branch };
23
69
  }
24
70
  catch (error) {
71
+ const message = String(error instanceof Error ? error.message : error);
72
+ if (looksLikeDubiousOwnership(message)) {
73
+ // The single most common failure of a dedicated-user install, and the one
74
+ // whose fix is one line — which is why we print the line.
75
+ const owner = dir.ownerUid >= 0 ? ` (it belongs to uid ${dir.ownerUid})` : '';
76
+ return {
77
+ ok: false,
78
+ exists: true,
79
+ isGitRepo: false,
80
+ error: `Git refuses to use ${workspacePath} because the runner runs as ${me.user} and does not own it${owner}. ` +
81
+ `Run this on the server as ${me.user}: ${safeDirectoryCommand(workspacePath)} — ` +
82
+ `or run \`devbridge-runner doctor --fix\`, which does it for every bound project. ` +
83
+ `Note: if you instead hand the directory over with \`chown\`, add the same line for its previous owner, ` +
84
+ `who will otherwise lose git in that repository.`,
85
+ };
86
+ }
25
87
  return {
26
88
  ok: false,
27
89
  exists: true,
28
90
  isGitRepo: false,
29
- error: `git check failed: ${String(error instanceof Error ? error.message : error).slice(0, 300)}`,
91
+ error: `git check failed: ${message.slice(0, 300)}`,
30
92
  };
31
93
  }
94
+ // Sessions write here: a worktree registers itself inside `.git/worktrees`,
95
+ // and a DIRECT-mode session commits in the tree itself. Read-only access
96
+ // binds fine and then fails on the first session, which is the worst place
97
+ // to learn about it.
98
+ const gitDir = await resolveGitDir(workspacePath);
99
+ const gitAccess = gitDir ? inspectPath(gitDir) : null;
100
+ if (gitAccess && gitAccess.exists && !gitAccess.writable) {
101
+ return {
102
+ ok: false,
103
+ exists: true,
104
+ isGitRepo: true,
105
+ error: `The runner runs as ${me.user} and cannot write to ${gitDir}, so it could not create a branch or a worktree here. ` +
106
+ `Give that user write access to the repository (for example \`chown -R ${me.user} ${workspacePath}\`), then try again.`,
107
+ };
108
+ }
109
+ const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
110
+ return { ok: true, exists: true, isGitRepo: true, branch };
111
+ }
112
+ /** The real `.git` directory of a work tree (a linked worktree has a file there). */
113
+ async function resolveGitDir(workspacePath) {
114
+ try {
115
+ const common = await git(workspacePath, 'rev-parse', '--git-common-dir');
116
+ return path.resolve(workspacePath, common);
117
+ }
118
+ catch {
119
+ return null;
120
+ }
32
121
  }
33
122
  export function sessionShortId(sessionId) {
34
123
  return sessionId.replace(/-/g, '').slice(0, 8);
@@ -92,7 +181,8 @@ export async function ensureSessionWorktree(workspacePath, sessionId, branchHint
92
181
  // A ticket group gets a branch named after its tickets so the work reads as
93
182
  // one thing in git history. The worktree DIRECTORY stays id-derived — it is
94
183
  // the collision guard below, and two sessions must never share one.
95
- const branch = sanitizeBranch(branchHint) ?? `devbridge/s-${short}`;
184
+ const planned = options.plan ? sanitizeBranch(options.plan.branch) : null;
185
+ const branch = planned ?? sanitizeBranch(branchHint) ?? `devbridge/s-${short}`;
96
186
  const worktreePath = sessionWorktreePath(sessionId);
97
187
  if (fs.existsSync(path.join(worktreePath, '.git'))) {
98
188
  // Runner restart — reuse, but verify the worktree really is ours: a
@@ -109,19 +199,198 @@ export async function ensureSessionWorktree(workspacePath, sessionId, branchHint
109
199
  const branchExists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', branch)
110
200
  .then(() => true)
111
201
  .catch(() => false);
202
+ // With a plan, both mismatches are failures worth stopping for. Without one
203
+ // (an API from before session 13) the historical guesswork is kept exactly as
204
+ // it was, so an old server does not start failing after a runner update.
205
+ const plan = planned ? options.plan : undefined;
206
+ if (plan?.source === 'NEW' && branchExists) {
207
+ throw new Error(`branch ${branch} already exists in ${workspacePath} — a new session must not silently continue somebody else's work`);
208
+ }
209
+ if (plan?.source === 'CONTINUE' && !branchExists) {
210
+ throw new Error(`branch ${branch} no longer exists in ${workspacePath}`);
211
+ }
112
212
  if (branchExists) {
213
+ // Git refuses to check one branch out in two worktrees. Saying which
214
+ // worktree holds it beats the raw «is already checked out» from git.
215
+ const holder = await worktreeHolding(workspacePath, branch);
216
+ if (holder) {
217
+ throw new Error(`branch ${branch} is already checked out in ${holder}`);
218
+ }
113
219
  await git(workspacePath, 'worktree', 'add', worktreePath, branch);
220
+ return { branch, worktreePath };
114
221
  }
115
- else {
116
- if (options.requireExistingBranch) {
117
- // The caller is restoring a session that already produced work. Creating
118
- // a fresh branch off HEAD here would look like success while quietly
119
- // hiding every commit the agent made — fail loudly instead.
120
- throw new Error(`session branch ${branch} no longer exists in ${workspacePath}`);
222
+ if (options.requireExistingBranch) {
223
+ // The caller is restoring a session that already produced work. Creating
224
+ // a fresh branch off HEAD here would look like success while quietly
225
+ // hiding every commit the agent made — fail loudly instead.
226
+ throw new Error(`session branch ${branch} no longer exists in ${workspacePath}`);
227
+ }
228
+ // Fork point: the sha the API pinned, then the branch it named, then HEAD.
229
+ // A pinned sha that is not in this repository is not worth guessing around —
230
+ // branching off the wrong commit is the failure this whole plan removes.
231
+ const startPoint = await resolveStartPoint(workspacePath, plan);
232
+ // What HEAD points at, read BEFORE the worktree is added. This is the only
233
+ // chance to learn the fork point for a session created while the runner was
234
+ // offline: the API had nobody to ask, so `branchPlan.baseBranch` is empty and
235
+ // without this the base is never pinned at all — which switches off the drift
236
+ // guard for that session's whole life (QA-107).
237
+ const headBranch = plan?.baseBranch
238
+ ? plan.baseBranch
239
+ : await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD')
240
+ .then((name) => (name && name !== 'HEAD' ? name : undefined))
241
+ .catch(() => undefined);
242
+ await git(workspacePath, 'worktree', 'add', '-b', branch, worktreePath, startPoint);
243
+ const baseSha = await git(workspacePath, 'rev-parse', startPoint).catch(() => undefined);
244
+ return {
245
+ branch,
246
+ worktreePath,
247
+ ...(headBranch ? { baseBranch: headBranch } : {}),
248
+ ...(baseSha ? { baseSha } : {}),
249
+ };
250
+ }
251
+ /**
252
+ * DIRECT mode (session 16): the session's workplace IS the project folder.
253
+ *
254
+ * Nothing is created and nothing is moved. The folder stays on the branch it is
255
+ * on, and that branch is the session's branch — which is the whole point: the
256
+ * work the agent does is already where the person expects to find it, with no
257
+ * «Apply» step in between and nothing to lose if the session is never applied.
258
+ *
259
+ * The two refusals are both about NOT guessing:
260
+ *
261
+ * - not a git work tree — every git surface downstream would fail one call at
262
+ * a time instead of once, here, with a sentence that names the folder;
263
+ * - a detached HEAD — commits would land on no branch at all and be reachable
264
+ * only by sha. A person who checked out a tag to look at something must not
265
+ * discover an agent committed onto it.
266
+ *
267
+ * The path returned is the repository ROOT, not necessarily the folder that was
268
+ * configured. Every path in the Source Control panel is repo-root-relative
269
+ * because that is what `git status` prints, so the root is the only place the
270
+ * paths and the commands agree — and it is also the confinement root layer 1
271
+ * hands the agent.
272
+ */
273
+ export async function prepareDirectWorkspace(workspacePath) {
274
+ const inside = await git(workspacePath, 'rev-parse', '--is-inside-work-tree').catch(() => null);
275
+ if (inside !== 'true') {
276
+ throw new Error(`${workspacePath} is not a git work tree`);
277
+ }
278
+ const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null);
279
+ if (!branch || branch === 'HEAD') {
280
+ throw new Error('The project folder is not on a branch (detached HEAD) — check a branch out there before starting a session');
281
+ }
282
+ const top = await git(workspacePath, 'rev-parse', '--show-toplevel').catch(() => null);
283
+ const baseSha = await git(workspacePath, 'rev-parse', 'HEAD').catch(() => undefined);
284
+ return {
285
+ branch,
286
+ worktreePath: top || workspacePath,
287
+ baseBranch: branch,
288
+ ...(baseSha ? { baseSha } : {}),
289
+ };
290
+ }
291
+ async function resolveStartPoint(workspacePath, plan) {
292
+ if (plan?.baseSha && /^[0-9a-f]{7,64}$/i.test(plan.baseSha)) {
293
+ const exists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', `${plan.baseSha}^{commit}`)
294
+ .then(() => true)
295
+ .catch(() => false);
296
+ if (exists)
297
+ return plan.baseSha;
298
+ throw new Error(`base commit ${plan.baseSha} is not in ${workspacePath}`);
299
+ }
300
+ const baseBranch = plan?.baseBranch ? sanitizeBranch(plan.baseBranch) : null;
301
+ if (baseBranch) {
302
+ const exists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', baseBranch)
303
+ .then(() => true)
304
+ .catch(() => false);
305
+ if (exists)
306
+ return baseBranch;
307
+ throw new Error(`base branch ${baseBranch} is not in ${workspacePath}`);
308
+ }
309
+ return 'HEAD';
310
+ }
311
+ /** Which worktree, if any, currently has `branch` checked out. */
312
+ async function worktreeHolding(workspacePath, branch) {
313
+ const raw = await git(workspacePath, 'worktree', 'list', '--porcelain').catch(() => '');
314
+ let current = null;
315
+ for (const line of raw.split('\n')) {
316
+ if (line.startsWith('worktree '))
317
+ current = line.slice('worktree '.length).trim();
318
+ else if (line.startsWith('branch ') && current) {
319
+ if (line
320
+ .slice('branch '.length)
321
+ .trim()
322
+ .replace(/^refs\/heads\//, '') === branch) {
323
+ return current;
324
+ }
121
325
  }
122
- await git(workspacePath, 'worktree', 'add', '-b', branch, worktreePath, 'HEAD');
123
326
  }
124
- return { branch, worktreePath };
327
+ return null;
328
+ }
329
+ // ─── Preview worktree (session 14) ───────────────────────────────────
330
+ /**
331
+ * One preview checkout per repository, and never the project folder itself.
332
+ *
333
+ * The honest constraint behind «show me branch B while branch A is running»:
334
+ * the docker build context IS the project folder, so a rebuild there replaces
335
+ * the single running copy. A second worktree is the only way to have both — and
336
+ * the runner NEVER switches the branch in the project folder, because that
337
+ * silently moves the base, and the target of «Apply», for every session of the
338
+ * project at once.
339
+ *
340
+ * Checked out DETACHED at a sha rather than on the branch: git refuses to have
341
+ * one branch checked out twice, and a preview is a snapshot of a commit, not a
342
+ * place anybody commits.
343
+ */
344
+ export function previewWorktreePath(workspaceKey) {
345
+ const safe = workspaceKey.replace(/[^A-Za-z0-9_-]/g, '-').slice(-60) || 'preview';
346
+ return path.join(previewsDir(), safe);
347
+ }
348
+ export async function ensurePreviewWorktree(input) {
349
+ const branch = sanitizeBranch(input.branch);
350
+ if (!branch)
351
+ throw new Error('Invalid branch name');
352
+ const sha = await git(input.workspacePath, 'rev-parse', `${branch}^{commit}`).catch(() => null);
353
+ if (!sha)
354
+ throw new Error(`Branch ${branch} is not in this repository`);
355
+ const worktreePath = previewWorktreePath(input.workspaceKey);
356
+ fs.mkdirSync(previewsDir(), { recursive: true, mode: 0o700 });
357
+ await git(input.workspacePath, 'worktree', 'prune').catch(() => undefined);
358
+ if (fs.existsSync(path.join(worktreePath, '.git'))) {
359
+ // Reuse the slot: a preview is a place, not a history. `--detach` keeps it
360
+ // out of the branch namespace, so moving it cannot collide with a session.
361
+ await git(worktreePath, 'checkout', '--detach', sha);
362
+ await git(worktreePath, 'reset', '--hard', sha);
363
+ await git(worktreePath, 'clean', '-fd').catch(() => undefined);
364
+ return { worktreePath, branch, sha };
365
+ }
366
+ await git(input.workspacePath, 'worktree', 'add', '--detach', worktreePath, sha);
367
+ return { worktreePath, branch, sha };
368
+ }
369
+ /** Give the slot back. The branch is untouched — it was never checked out. */
370
+ export async function removePreviewWorktree(workspaceKey) {
371
+ const worktreePath = previewWorktreePath(workspaceKey);
372
+ if (!fs.existsSync(worktreePath))
373
+ return false;
374
+ const gitFile = path.join(worktreePath, '.git');
375
+ let mainRepo = null;
376
+ if (fs.existsSync(gitFile) && fs.statSync(gitFile).isFile()) {
377
+ const pointer = fs
378
+ .readFileSync(gitFile, 'utf8')
379
+ .match(/^gitdir:\s*(.+)$/m)?.[1]
380
+ ?.trim();
381
+ if (pointer) {
382
+ const dotGit = path.resolve(pointer, '..', '..');
383
+ if (path.basename(dotGit) === '.git')
384
+ mainRepo = path.dirname(dotGit);
385
+ }
386
+ }
387
+ if (mainRepo) {
388
+ await git(mainRepo, 'worktree', 'remove', '--force', worktreePath);
389
+ }
390
+ else {
391
+ fs.rmSync(worktreePath, { recursive: true, force: true });
392
+ }
393
+ return true;
125
394
  }
126
395
  /**
127
396
  * Drop a session branch after its worktree is gone. Only ever called when the
@@ -133,6 +402,23 @@ export async function deleteSessionBranch(workspacePath, branch) {
133
402
  const safe = sanitizeBranch(branch);
134
403
  if (!safe)
135
404
  throw new Error(`refusing to delete an unsafe branch name: ${branch}`);
405
+ // Session 16: a DIRECT session's «session branch» is the branch the PERSON is
406
+ // working on, and deleting a session must never be able to delete that. The
407
+ // API already refuses to ask, and git itself refuses to drop a branch that is
408
+ // checked out — but this is the line that would run the command, so it is the
409
+ // line that checks. Three cheap reads against an operation with no undo.
410
+ // The worktree this branch lived in was removed a moment ago; without a prune
411
+ // its stale registration would answer «still checked out» and leave a branch
412
+ // behind on every ordinary purge.
413
+ await git(workspacePath, 'worktree', 'prune').catch(() => undefined);
414
+ const current = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null);
415
+ if (current === safe) {
416
+ throw new Error(`refusing to delete ${safe}: the project folder is on it`);
417
+ }
418
+ const holder = await worktreeHolding(workspacePath, safe);
419
+ if (holder) {
420
+ throw new Error(`refusing to delete ${safe}: it is checked out in ${holder}`);
421
+ }
136
422
  await git(workspacePath, 'branch', '-D', safe);
137
423
  }
138
424
  /**