@bevel-software/platform-core-backend 0.21.0 → 0.22.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 (177) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +32 -2
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +13 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +51 -5
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/modules/access/access-requests.contract.d.ts +75 -0
  9. package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
  10. package/dist/modules/access/access-requests.contract.js +20 -0
  11. package/dist/modules/access/access-requests.contract.js.map +1 -0
  12. package/dist/modules/access/access-requests.routes.d.ts +35 -0
  13. package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
  14. package/dist/modules/access/access-requests.routes.js +237 -0
  15. package/dist/modules/access/access-requests.routes.js.map +1 -0
  16. package/dist/modules/access/access-requests.service.d.ts +123 -0
  17. package/dist/modules/access/access-requests.service.d.ts.map +1 -0
  18. package/dist/modules/access/access-requests.service.js +337 -0
  19. package/dist/modules/access/access-requests.service.js.map +1 -0
  20. package/dist/modules/access-model/access-grammar.d.ts +12 -0
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +24 -0
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/auth/auth.service.d.ts +26 -1
  25. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  26. package/dist/modules/auth/auth.service.js +11 -2
  27. package/dist/modules/auth/auth.service.js.map +1 -1
  28. package/dist/modules/github-app/github-app.client.d.ts +101 -0
  29. package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
  30. package/dist/modules/github-app/github-app.client.js +222 -0
  31. package/dist/modules/github-app/github-app.client.js.map +1 -0
  32. package/dist/modules/github-app/github-app.connection.d.ts +102 -0
  33. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
  34. package/dist/modules/github-app/github-app.connection.js +185 -0
  35. package/dist/modules/github-app/github-app.connection.js.map +1 -0
  36. package/dist/modules/github-app/github-app.routes.d.ts +59 -0
  37. package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
  38. package/dist/modules/github-app/github-app.routes.js +323 -0
  39. package/dist/modules/github-app/github-app.routes.js.map +1 -0
  40. package/dist/modules/github-app/index.d.ts +4 -0
  41. package/dist/modules/github-app/index.d.ts.map +1 -0
  42. package/dist/modules/github-app/index.js +4 -0
  43. package/dist/modules/github-app/index.js.map +1 -0
  44. package/dist/modules/kb-fs/remote-url.d.ts +22 -0
  45. package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
  46. package/dist/modules/kb-fs/remote-url.js +35 -0
  47. package/dist/modules/kb-fs/remote-url.js.map +1 -0
  48. package/dist/modules/plugins/join-proposals.d.ts +17 -5
  49. package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
  50. package/dist/modules/plugins/join-proposals.js +76 -22
  51. package/dist/modules/plugins/join-proposals.js.map +1 -1
  52. package/dist/modules/plugins/join-requests.service.d.ts +111 -18
  53. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  54. package/dist/modules/plugins/join-requests.service.js +173 -29
  55. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  56. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  57. package/dist/modules/plugins/plugins.routes.js +3 -2
  58. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  59. package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
  60. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  61. package/dist/modules/settings/deployment-settings.service.js +93 -4
  62. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  63. package/dist/modules/settings/managed-repository.d.ts +43 -0
  64. package/dist/modules/settings/managed-repository.d.ts.map +1 -0
  65. package/dist/modules/settings/managed-repository.js +60 -0
  66. package/dist/modules/settings/managed-repository.js.map +1 -0
  67. package/dist/modules/settings/repository-source.d.ts +128 -0
  68. package/dist/modules/settings/repository-source.d.ts.map +1 -0
  69. package/dist/modules/settings/repository-source.js +150 -0
  70. package/dist/modules/settings/repository-source.js.map +1 -0
  71. package/dist/modules/settings/setup.routes.d.ts +27 -4
  72. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  73. package/dist/modules/settings/setup.routes.js +187 -13
  74. package/dist/modules/settings/setup.routes.js.map +1 -1
  75. package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
  76. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
  77. package/dist/modules/skills/skill-access-requests.routes.js +24 -63
  78. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
  79. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  80. package/dist/modules/tool-helpers/tool-context.js +15 -0
  81. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  82. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  83. package/dist/modules/workflow/agent-tools/workflow.tools.js +38 -2
  84. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  85. package/dist/modules/workflow/git/git.service.d.ts +3 -1
  86. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  87. package/dist/modules/workflow/git/git.service.js +17 -4
  88. package/dist/modules/workflow/git/git.service.js.map +1 -1
  89. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  90. package/dist/modules/workflow/git/node-git-runner.js +5 -0
  91. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  92. package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
  93. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  94. package/dist/modules/workflow/git/pull-request.service.js +99 -0
  95. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  96. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  97. package/dist/modules/workflow/workflow.routes.js +3 -1
  98. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  99. package/dist/modules/workflow/workflow.service.d.ts +34 -7
  100. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  101. package/dist/modules/workflow/workflow.service.js +148 -48
  102. package/dist/modules/workflow/workflow.service.js.map +1 -1
  103. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +66 -0
  104. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  105. package/dist/modules/workspace/startup/kb-startup-runner.js +148 -0
  106. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  107. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  108. package/dist/modules/workspace/workspace.service.js +17 -1
  109. package/dist/modules/workspace/workspace.service.js.map +1 -1
  110. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  111. package/dist/modules/workspace/workspace.tools.js +28 -2
  112. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  113. package/dist/shared/domain-errors.d.ts +39 -0
  114. package/dist/shared/domain-errors.d.ts.map +1 -1
  115. package/dist/shared/domain-errors.js +55 -0
  116. package/dist/shared/domain-errors.js.map +1 -1
  117. package/dist/shared/git.contract.d.ts +10 -0
  118. package/dist/shared/git.contract.d.ts.map +1 -1
  119. package/dist/shared/git.contract.js.map +1 -1
  120. package/package.json +3 -3
  121. package/src/core/create-core-server.ts +37 -1
  122. package/src/core/create-core-services.ts +65 -8
  123. package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
  124. package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
  125. package/src/modules/access/access-requests.contract.ts +93 -0
  126. package/src/modules/access/access-requests.routes.ts +286 -0
  127. package/src/modules/access/access-requests.service.ts +420 -0
  128. package/src/modules/access-model/access-grammar.ts +22 -0
  129. package/src/modules/auth/__tests__/auth.service.test.ts +38 -0
  130. package/src/modules/auth/auth.service.ts +28 -1
  131. package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
  132. package/src/modules/github-app/github-app.client.ts +268 -0
  133. package/src/modules/github-app/github-app.connection.ts +204 -0
  134. package/src/modules/github-app/github-app.routes.ts +359 -0
  135. package/src/modules/github-app/index.ts +19 -0
  136. package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
  137. package/src/modules/kb-fs/remote-url.ts +35 -0
  138. package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
  139. package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
  140. package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
  141. package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
  142. package/src/modules/plugins/join-proposals.ts +87 -19
  143. package/src/modules/plugins/join-requests.service.ts +199 -39
  144. package/src/modules/plugins/plugins.routes.ts +3 -2
  145. package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
  146. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +353 -0
  147. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +282 -0
  148. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +233 -0
  149. package/src/modules/settings/deployment-settings.service.ts +109 -3
  150. package/src/modules/settings/managed-repository.ts +68 -0
  151. package/src/modules/settings/repository-source.ts +197 -0
  152. package/src/modules/settings/setup.routes.ts +214 -10
  153. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
  154. package/src/modules/skills/skill-access-requests.routes.ts +25 -75
  155. package/src/modules/tool-helpers/tool-context.ts +15 -0
  156. package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
  157. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
  158. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
  159. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +141 -1
  160. package/src/modules/workflow/agent-tools/workflow.tools.ts +49 -3
  161. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
  162. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
  163. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
  164. package/src/modules/workflow/git/git.service.ts +25 -4
  165. package/src/modules/workflow/git/node-git-runner.ts +6 -0
  166. package/src/modules/workflow/git/pull-request.service.ts +119 -0
  167. package/src/modules/workflow/workflow.routes.ts +3 -1
  168. package/src/modules/workflow/workflow.service.ts +170 -52
  169. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
  170. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +233 -7
  171. package/src/modules/workspace/__tests__/workspace.tools.test.ts +35 -3
  172. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +211 -0
  173. package/src/modules/workspace/startup/kb-startup-runner.ts +159 -0
  174. package/src/modules/workspace/workspace.service.ts +17 -1
  175. package/src/modules/workspace/workspace.tools.ts +35 -0
  176. package/src/shared/domain-errors.ts +58 -0
  177. package/src/shared/git.contract.ts +10 -0
@@ -18,6 +18,7 @@ import {
18
18
  type GitFailure,
19
19
  } from '../../../shared/git-failure.js';
20
20
  import { redactSecret, urlQuerySecrets } from '../../../shared/redact-secret.js';
21
+ import { normalizeRepositoryAddress, sameRepository } from '../../kb-fs/remote-url.js';
21
22
 
22
23
  /**
23
24
  * The KB startup phase: run every registered {@link OnServerStart} step, in
@@ -51,6 +52,16 @@ export interface KbStartupRunnerOptions {
51
52
  gitRunner: IGitRunner;
52
53
  kbRepoUrl: () => string;
53
54
  workspacesRoot: string;
55
+ /**
56
+ * Where a working copy of a repository that is no longer the configured
57
+ * one is moved to, under a folder named for the moment it happened — see
58
+ * `reconcileClonesWithConfiguredRepository`. NOT under `workspacesRoot`:
59
+ * the workspace service's orphan sweep removes every folder there that is
60
+ * not a known branch's, and what is set aside has to outlive that sweep.
61
+ * The composition root names a folder under the backups root, which is a
62
+ * persistent volume of its own. Default: a sibling of `workspacesRoot`.
63
+ */
64
+ setAsideRoot?: string;
54
65
  kbDirName: string;
55
66
  templateDir: string;
56
67
  defaultBranch: () => string;
@@ -301,6 +312,10 @@ export class KbStartupRunner {
301
312
  );
302
313
  }
303
314
  const heads = await this.ensureRemote();
315
+ // AFTER the remote answered: the configured address is known to be a
316
+ // repository we can reach, so a clone of a different one is stale, not
317
+ // a casualty of a typo in the address.
318
+ await this.reconcileClonesWithConfiguredRepository();
304
319
  const ctx = this.buildContext(heads, handles);
305
320
 
306
321
  for (const step of this.opts.steps) {
@@ -501,6 +516,150 @@ export class KbStartupRunner {
501
516
  };
502
517
  }
503
518
 
519
+ /**
520
+ * Bring every working copy on disk into line with the configured
521
+ * repository, WITHOUT DELETING ANYTHING.
522
+ *
523
+ * A clone fetches and pushes through the address stored in its own
524
+ * `remote.origin.url`, which nothing updates when the configured address
525
+ * changes. After an operator replaced the repository (2026-09-28: the old
526
+ * one deleted, a new one saved on the setup screen) every surviving clone
527
+ * kept asking the old address: Save and Retry failed, and the restart
528
+ * stopped the boot on `repository … not found`, taking the setup screen
529
+ * with it.
530
+ *
531
+ * A clone whose address differs from the configured one is one of two
532
+ * things, and the histories say which:
533
+ *
534
+ * - THE SAME REPOSITORY AT A NEW ADDRESS — moved, renamed, mirrored. The
535
+ * one change the setup screen tells an admin to make ("only change this
536
+ * if the same repository was moved or renamed"). Its history is on the
537
+ * configured remote, so the clone is pointed at the new address and
538
+ * kept, unpushed commits included: they are pushed where they belonged
539
+ * all along.
540
+ * - ANOTHER REPOSITORY. Re-pointing it would push one repository's
541
+ * commits into the other, so it cannot stay where the workspace service
542
+ * would adopt it. It is MOVED to {@link KbStartupRunnerOptions.setAsideRoot}
543
+ * and the log says where. Never removed: when the old repository is gone
544
+ * — as it was that day — these clones are the last copy of the
545
+ * knowledge base that exists, and the unpushed work in them exists
546
+ * nowhere else at all.
547
+ *
548
+ * Every clone under the workspaces root, not only the branches this phase
549
+ * touches: the workspace service adopts whatever it finds there. A clone
550
+ * whose address cannot be read is left where it is — "could not tell" is
551
+ * no reason to move someone's work. One that cannot be set aside stops the
552
+ * boot with the reason: leaving it would fail the boot a step later with
553
+ * git's words about a repository nobody configured.
554
+ */
555
+ private async reconcileClonesWithConfiguredRepository(): Promise<void> {
556
+ const configured = this.opts.kbRepoUrl();
557
+ let entries: import('node:fs').Dirent[];
558
+ try {
559
+ entries = await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true });
560
+ } catch {
561
+ return; // no workspaces yet
562
+ }
563
+ // One folder per run, so what was set aside together stays together.
564
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
565
+ const setAsideRoot =
566
+ this.opts.setAsideRoot ?? path.resolve(this.opts.workspacesRoot, '..', 'replaced-working-copies');
567
+ for (const entry of entries) {
568
+ if (!entry.isDirectory()) continue;
569
+ const repoDir = path.join(this.opts.workspacesRoot, entry.name, this.opts.kbDirName);
570
+ const hasGit = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
571
+ if (!hasGit) continue;
572
+ let origin: string;
573
+ try {
574
+ origin = (await git(this.opts.gitRunner, repoDir, ['config', '--get', 'remote.origin.url'])).trim();
575
+ } catch {
576
+ continue;
577
+ }
578
+ if (origin === '' || sameRepository(origin, configured)) continue;
579
+ // The normalized form carries no userinfo, and the scrub covers the rest.
580
+ const was = this.redact(normalizeRepositoryAddress(origin));
581
+ if (await this.sharesHistoryWithConfigured(repoDir, configured)) {
582
+ await git(this.opts.gitRunner, repoDir, ['remote', 'set-url', 'origin', configured]);
583
+ startupLog.warn(
584
+ `working copy "${entry.name}" was cloned from ${was}, whose history the configured repository holds: ` +
585
+ 'the same repository at a new address. Pointed at the configured address and kept, unpushed work included.',
586
+ );
587
+ continue;
588
+ }
589
+ const kept = path.join(setAsideRoot, stamp, entry.name);
590
+ await this.setAside(repoDir, kept);
591
+ startupLog.warn(
592
+ `working copy "${entry.name}" is a clone of another repository (${was}). Set aside at ${kept}; nothing was ` +
593
+ 'deleted, and it will be cloned fresh from the configured one. Work that was never pushed is in that ' +
594
+ 'folder: `git log` there shows it, and `git push <address> <branch>` from there sends it to a repository.',
595
+ );
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Whether the configured repository holds history this clone has too —
601
+ * asked of git, not guessed from the two addresses. The configured
602
+ * remote's branches are fetched under a namespace of their own and the
603
+ * clone's HEAD is tested for a common ancestor with any of them.
604
+ *
605
+ * "Could not tell" answers false: a fetch that fails, a clone with no
606
+ * commit to compare. False sets the clone aside, which loses nothing, where
607
+ * a wrong true would push a stranger's commits into the configured
608
+ * repository.
609
+ */
610
+ private async sharesHistoryWithConfigured(repoDir: string, configured: string): Promise<boolean> {
611
+ const probe = 'refs/hexis-probe';
612
+ let theirs: string[] = [];
613
+ try {
614
+ await git(this.opts.gitRunner, repoDir, ['fetch', '--no-tags', '--quiet', configured, `+refs/heads/*:${probe}/*`]);
615
+ theirs = (await git(this.opts.gitRunner, repoDir, ['for-each-ref', '--format=%(refname)', `${probe}/`]))
616
+ .split('\n')
617
+ .map((ref) => ref.trim())
618
+ .filter(Boolean);
619
+ if (theirs.length === 0) return false;
620
+ // One question for all of them: a common ancestor of HEAD and ANY of
621
+ // their branches is a common ancestor of HEAD and their merge. Capped,
622
+ // so a remote with thousands of branches cannot outgrow a command line.
623
+ await git(this.opts.gitRunner, repoDir, ['merge-base', 'HEAD', ...theirs.slice(0, 200)]);
624
+ return true;
625
+ } catch {
626
+ return false;
627
+ } finally {
628
+ // The probe leaves nothing behind in a clone that stays in use.
629
+ for (const ref of theirs) {
630
+ await git(this.opts.gitRunner, repoDir, ['update-ref', '-d', ref]).catch(() => undefined);
631
+ }
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Move a working copy out of the workspaces root, whole. A rename where
637
+ * the two folders share a volume; a copy and then a removal where they do
638
+ * not (the backups root is a volume of its own in the shipped compose
639
+ * files), with the original removed only once the copy is complete.
640
+ */
641
+ private async setAside(repoDir: string, dest: string): Promise<void> {
642
+ await fs.mkdir(path.dirname(dest), { recursive: true });
643
+ try {
644
+ await fs.rename(repoDir, dest);
645
+ return;
646
+ } catch {
647
+ // Another volume, or a handle held open on it: copy instead.
648
+ }
649
+ try {
650
+ await fs.cp(repoDir, dest, { recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true });
651
+ } catch (err) {
652
+ await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
653
+ throw new Error(
654
+ `Could not set aside the working copy at ${repoDir}, a clone of a repository that is no longer the ` +
655
+ `configured one, into ${dest}: ${err instanceof Error ? err.message : String(err)}. Nothing was deleted. ` +
656
+ 'Make room there, or move that folder away by hand, and start again.',
657
+ { cause: err },
658
+ );
659
+ }
660
+ await fs.rm(repoDir, { recursive: true, force: true });
661
+ }
662
+
504
663
  /**
505
664
  * The branch's working copy at the runtime layout
506
665
  * (`<workspacesRoot>/<id>/<kbDirName>`), so the workspace service finds it
@@ -649,15 +649,31 @@ export class WorkspaceService implements IWorkspaceService {
649
649
  }
650
650
  this.inFlightBootstraps.set(branch, bootstrap);
651
651
 
652
+ // `mkdir --recursive` answers with the first path it CREATED, or undefined
653
+ // when the directory was already there. That is the only honest signal for
654
+ // what the rollback below may remove: a directory this attempt made is ours
655
+ // to take back, one that predates us belongs to whatever put it there.
656
+ let createdWorkspaceDir: string | undefined;
652
657
  try {
653
658
  // A clone with `-b <branch>` against a never-seeded remote fails
654
659
  // naturally; seeding the remote is the KB startup phase's job, at boot.
655
- await fs.mkdir(workspaceDir, { recursive: true });
660
+ createdWorkspaceDir = await fs.mkdir(workspaceDir, { recursive: true });
656
661
  await this.cloneProcessMapForBranch(workspaceDir, branch);
657
662
  this.registerBranchDir(branch, workspaceDir);
658
663
  resolveBootstrap();
659
664
  } catch (err) {
660
665
  this.branchDirs.delete(branch);
666
+ // A bootstrap that failed leaves NOTHING behind. The clone rolls its own
667
+ // target back, but the workspace directory around it would survive as an
668
+ // empty shell named after the branch — and a directory named after a
669
+ // branch is read as the platform having known that branch: by
670
+ // `hasHeardOfBranch`, which would turn the next attempt's honest 404 into
671
+ // "no longer exists on the remote" (410), and by anyone reading the
672
+ // workspaces root. Best-effort: a rollback that cannot delete must not
673
+ // replace the real failure (the clone's) with its own.
674
+ if (createdWorkspaceDir !== undefined) {
675
+ await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => {});
676
+ }
661
677
  rejectBootstrap(err);
662
678
  throw err;
663
679
  } finally {
@@ -18,6 +18,7 @@ import type { IRoutineWritePolicy } from './routine-write-policy.js';
18
18
  import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
19
19
  import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
20
20
  import { workspaceIdForBranch } from '../../shared/workspace-id.js';
21
+ import { assertBranchProvided } from '../../shared/domain-errors.js';
21
22
  // Leaf-level shared primitive (same exception `workspace.service.ts` already
22
23
  // relies on) — not a workflow service, so this stays inside the module boundary.
23
24
  import { assertValidBranchName } from '../kb-fs/branch-name.js';
@@ -1171,6 +1172,15 @@ export function registerWorkspaceTools(
1171
1172
  proposable?: boolean;
1172
1173
  /** False for a tool that is not a file tool (the shell), which the content rule does not describe. */
1173
1174
  fileTool?: boolean;
1175
+ /**
1176
+ * This tool resolves `branch` ITSELF and must not be pre-checked here.
1177
+ * Only `execute_command` sets it: for an internal session that leaves the
1178
+ * argument off, it falls back to the caller's own focused branch (from its
1179
+ * signed token) rather than refusing. It still answers the same
1180
+ * `branch-required` kind when there is nothing to fall back on — see its
1181
+ * handler. Every other tool takes the check below.
1182
+ */
1183
+ resolvesBranchItself?: boolean;
1174
1184
  handler: ToolHandler;
1175
1185
  }): void => {
1176
1186
  const path = `/api/agent/tools/${spec.name}`;
@@ -1178,6 +1188,12 @@ export function registerWorkspaceTools(
1178
1188
  // tool the one content rule, and every tool a permission can refuse the
1179
1189
  // proposal route — appended once here so no tool (especially the
1180
1190
  // read-only ones a session hits first) can miss them.
1191
+ // Whether a call to this tool MUST name a branch, read off the tool's own
1192
+ // declaration rather than assumed of the family. Every tool mounted here
1193
+ // requires `branch` today; keying on the schema means a tool that declares
1194
+ // it optional (and resolves absence itself, as `list_tool_setup` does on its
1195
+ // own route) is not handed a refusal it never asked for.
1196
+ const requiresBranch = ((spec.inputs as { required?: string[] }).required ?? []).includes('branch');
1181
1197
  const describe = (): string =>
1182
1198
  (typeof spec.description === 'function' ? spec.description() : spec.description) +
1183
1199
  (spec.proposable ? PROPOSAL_ROUTE_NOTE : '') +
@@ -1223,6 +1239,16 @@ export function registerWorkspaceTools(
1223
1239
  // never reached the repository — the whole bug, spelled with a prefix.
1224
1240
  toolHandler(
1225
1241
  async (args, ctx) => {
1242
+ // FIRST, before the path work and before any handler: every tool
1243
+ // mounted here declares `branch` as a required, non-empty string, and
1244
+ // nothing enforced that, so a call that named none was carried down
1245
+ // until `workspaceIdForBranch` made a workspace directory out of the
1246
+ // missing value. Most of these tools would meet the same refusal one
1247
+ // layer down at `getFilesystem`, but not all of them do — `unzip`
1248
+ // hands `branch` straight to the workspace service by id — so the
1249
+ // check belongs on the mount every one of them shares rather than on
1250
+ // the resolver only some of them reach.
1251
+ if (requiresBranch && !spec.resolvesBranchItself) assertBranchProvided(args.branch);
1226
1252
  // BEFORE the normaliser: see `assertToolPathsNotGitInternals`.
1227
1253
  if (spec.fileTool !== false) await assertToolPathsNotGitInternals(args, ctx);
1228
1254
  const normalized = normalizePathArgs(
@@ -2594,6 +2620,9 @@ export function registerWorkspaceTools(
2594
2620
  ONTOLOGY_BOUNDARY_NOTE,
2595
2621
  internalOnly: true,
2596
2622
  fileTool: false,
2623
+ // The one tool the mount's branch check skips: the handler below resolves an
2624
+ // omitted `branch` to the internal caller's focused branch before refusing.
2625
+ resolvesBranchItself: true,
2597
2626
  inputs: {
2598
2627
  type: 'object',
2599
2628
  properties: {
@@ -2650,6 +2679,11 @@ export function registerWorkspaceTools(
2650
2679
  throw new ToolError(
2651
2680
  'execute_command requires a `branch`: pass the branch (draft) whose workspace to run the command in — the one you are currently working on.',
2652
2681
  400,
2682
+ // The same discriminator every other KB tool answers a branch-less
2683
+ // call with, so a client switches on one kind across the surface.
2684
+ // The MESSAGE stays this tool's own: it can name the focused-branch
2685
+ // fallback that only applies here.
2686
+ { kind: 'branch-required' },
2653
2687
  );
2654
2688
  }
2655
2689
  // A stringified absent value. Both are syntactically valid git branch names,
@@ -2662,6 +2696,7 @@ export function registerWorkspaceTools(
2662
2696
  `execute_command got the literal string "${branch}" as \`branch\` — that is a stringified absent value, not a branch. ` +
2663
2697
  'Pass the real branch (draft) whose workspace to run the command in.',
2664
2698
  400,
2699
+ { kind: 'branch-required' },
2665
2700
  );
2666
2701
  }
2667
2702
  // Then the SHAPE, via the one canonical validator every other branch path
@@ -167,6 +167,64 @@ export class PullRebaseConflictError extends WorkflowDomainError {
167
167
  }
168
168
  }
169
169
 
170
+ /**
171
+ * The one sentence a branch-less call is answered with. Names the input and
172
+ * what to pass, and carries NO stringified value of what was actually
173
+ * received: the whole point of this refusal is that the caller sent nothing,
174
+ * and echoing `undefined` back at them is how the branch "undefined" got
175
+ * invented in the first place.
176
+ */
177
+ export const BRANCH_REQUIRED_MESSAGE =
178
+ '`branch` is required: pass the branch (draft) you are working on.';
179
+
180
+ /**
181
+ * The caller named no branch — the input is missing, empty, not a string, or
182
+ * one of the stringified absent values (`"undefined"` / `"null"`) that a
183
+ * client produces by interpolating a variable it never set.
184
+ *
185
+ * Refused, never defaulted. A knowledge-base tool's workspace is NEVER implied
186
+ * by the credential (identity-only) — it always comes from this argument — so
187
+ * falling back to the deployment's default branch would make an omitted
188
+ * argument silently act on the protected branch. And it is refused HERE, at
189
+ * the boundary, because everything downstream treats the value as a real
190
+ * branch name: `workspaceIdForBranch` would turn it into a workspace directory
191
+ * literally named `undefined`, and the clone of that "branch" would fail with
192
+ * a story about a branch that never existed.
193
+ *
194
+ * 400 with kind `branch-required`, so a client tells it apart from the 404
195
+ * (no such branch) and the 410 (the branch was deleted) without reading prose.
196
+ */
197
+ export class BranchRequiredError extends WorkflowDomainError {
198
+ readonly kind = 'branch-required' as const;
199
+ constructor() {
200
+ super(BRANCH_REQUIRED_MESSAGE, 400, { kind: 'branch-required' });
201
+ this.name = 'BranchRequiredError';
202
+ }
203
+ }
204
+
205
+ /**
206
+ * The stringified absent values. Both are syntactically valid git branch
207
+ * names, so the shape validator (`assertValidBranchName`) accepts them
208
+ * happily — accepting one is exactly the bug this guard exists for. They are
209
+ * refused BY NAME, which is why this check cannot be folded into the shape
210
+ * check however tempting that looks.
211
+ */
212
+ const STRINGIFIED_ABSENT_VALUES = new Set(['undefined', 'null']);
213
+
214
+ /**
215
+ * Refuse a call that names no branch, before anything downstream can turn the
216
+ * missing value into a directory name, a clone attempt or a log line.
217
+ *
218
+ * Deliberately NOT a shape check: a well-formed name this platform has never
219
+ * seen is a 404 the caller can act on, and a malformed one is a
220
+ * `BranchNameError` from the canonical validator. This answers only "you sent
221
+ * nothing", which is the one case where naming the branch back is impossible.
222
+ */
223
+ export function assertBranchProvided(branch: unknown): asserts branch is string {
224
+ if (typeof branch !== 'string' || branch.length === 0) throw new BranchRequiredError();
225
+ if (STRINGIFIED_ABSENT_VALUES.has(branch)) throw new BranchRequiredError();
226
+ }
227
+
170
228
  /**
171
229
  * The platform has never heard of this branch: nothing it has cloned, and
172
230
  * nothing any listing of origin's branches has mentioned. Distinct from
@@ -62,6 +62,16 @@ export interface GitCredentials {
62
62
  username(): string;
63
63
  /** The token in effect, or null when the deployment has none configured. */
64
64
  token(): string | null;
65
+ /**
66
+ * Make sure {@link token} answers with a credential that is still good,
67
+ * for one that is not fixed: a token a host issues for an hour has to be
68
+ * renewed by asking the host, which `token` cannot do, being read where
69
+ * nothing can wait. The runner awaits this before every call it makes. A
70
+ * failure here is not the call's failure: git runs with the token in
71
+ * hand, and the host says what it thinks of it. Absent for a credential
72
+ * that does not expire.
73
+ */
74
+ prepare?(): Promise<void>;
65
75
  }
66
76
 
67
77
  /** The username git is given when a deployment names none. */