@bevel-software/platform-core-backend 0.12.0 → 0.13.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.
Files changed (177) hide show
  1. package/THIRD-PARTY-NOTICES.md +9 -7
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +8 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/modules/access/access-control.service.d.ts +58 -2
  8. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  9. package/dist/modules/access/access-control.service.js +174 -33
  10. package/dist/modules/access/access-control.service.js.map +1 -1
  11. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
  12. package/dist/modules/access/admin-locked-commit.js +1 -0
  13. package/dist/modules/access/admin-locked-commit.js.map +1 -1
  14. package/dist/modules/access/synced-groups-committer.js +1 -1
  15. package/dist/modules/access/synced-groups-committer.js.map +1 -1
  16. package/dist/modules/access-model/access-errors.d.ts +11 -0
  17. package/dist/modules/access-model/access-errors.d.ts.map +1 -1
  18. package/dist/modules/access-model/access-errors.js +14 -0
  19. package/dist/modules/access-model/access-errors.js.map +1 -1
  20. package/dist/modules/access-model/access-grammar.d.ts +24 -8
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +64 -3
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
  25. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
  26. package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
  27. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
  28. package/dist/modules/declared-variables/index.d.ts +2 -0
  29. package/dist/modules/declared-variables/index.d.ts.map +1 -0
  30. package/dist/modules/declared-variables/index.js +2 -0
  31. package/dist/modules/declared-variables/index.js.map +1 -0
  32. package/dist/modules/diff/diff.routes.d.ts +1 -1
  33. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  34. package/dist/modules/diff/diff.routes.js +3 -3
  35. package/dist/modules/diff/diff.routes.js.map +1 -1
  36. package/dist/modules/kb-fs/clone-config.d.ts +40 -2
  37. package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
  38. package/dist/modules/kb-fs/clone-config.js +94 -2
  39. package/dist/modules/kb-fs/clone-config.js.map +1 -1
  40. package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
  41. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  42. package/dist/modules/kb-fs/locking-filesystem.js +20 -0
  43. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  44. package/dist/modules/kb-fs/repo-path.d.ts +32 -0
  45. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
  46. package/dist/modules/kb-fs/repo-path.js +54 -0
  47. package/dist/modules/kb-fs/repo-path.js.map +1 -0
  48. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  49. package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
  50. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  51. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
  52. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  53. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
  54. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  55. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  56. package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
  57. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  58. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  59. package/dist/modules/tool-helpers/tool-context.js +1 -0
  60. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  61. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  62. package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
  63. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  64. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  65. package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
  66. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  67. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
  68. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  69. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
  70. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  71. package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
  72. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  73. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  74. package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
  75. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  76. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
  77. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
  78. package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
  79. package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
  80. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
  81. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  82. package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
  83. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  84. package/dist/modules/workflow/git/git.service.d.ts +32 -1
  85. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/git.service.js +70 -5
  87. package/dist/modules/workflow/git/git.service.js.map +1 -1
  88. package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
  89. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  90. package/dist/modules/workflow/git/pull-request.service.js +20 -2
  91. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  92. package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
  93. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  94. package/dist/modules/workflow/pending-commits.worker.js +74 -16
  95. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  96. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  97. package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
  98. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  99. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  100. package/dist/modules/workflow/workflow.routes.js +12 -0
  101. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  102. package/dist/modules/workflow/workflow.service.d.ts +39 -0
  103. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  104. package/dist/modules/workflow/workflow.service.js +116 -6
  105. package/dist/modules/workflow/workflow.service.js.map +1 -1
  106. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
  107. package/dist/modules/workspace/startup/kb-git.js +21 -4
  108. package/dist/modules/workspace/startup/kb-git.js.map +1 -1
  109. package/dist/modules/workspace/workspace.service.d.ts +52 -8
  110. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  111. package/dist/modules/workspace/workspace.service.js +121 -23
  112. package/dist/modules/workspace/workspace.service.js.map +1 -1
  113. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  114. package/dist/modules/workspace/workspace.tools.js +31 -15
  115. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  116. package/kb-template/AGENTS.md +52 -8
  117. package/package.json +6 -5
  118. package/src/core/create-core-server.ts +13 -1
  119. package/src/core/create-core-services.ts +1 -0
  120. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
  121. package/src/modules/access/__tests__/access-groups.test.ts +28 -0
  122. package/src/modules/access/access-control.service.ts +198 -37
  123. package/src/modules/access/admin-locked-commit.ts +1 -0
  124. package/src/modules/access/synced-groups-committer.ts +1 -1
  125. package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
  126. package/src/modules/access-model/access-errors.ts +19 -0
  127. package/src/modules/access-model/access-grammar.ts +67 -3
  128. package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
  129. package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
  130. package/src/modules/declared-variables/index.ts +1 -0
  131. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
  132. package/src/modules/diff/diff.routes.ts +3 -2
  133. package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
  134. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
  135. package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
  136. package/src/modules/kb-fs/clone-config.ts +97 -2
  137. package/src/modules/kb-fs/locking-filesystem.ts +30 -0
  138. package/src/modules/kb-fs/repo-path.ts +56 -0
  139. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
  140. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
  141. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
  142. package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
  143. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
  144. package/src/modules/secrets-vault/secrets-vault.routes.ts +20 -2
  145. package/src/modules/tool-helpers/tool-context.ts +1 -0
  146. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
  147. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
  148. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
  149. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
  150. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
  151. package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
  152. package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
  153. package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
  154. package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
  155. package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
  156. package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
  157. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
  158. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
  159. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
  160. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
  161. package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
  162. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
  163. package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
  164. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
  165. package/src/modules/workflow/git/git.service.ts +73 -6
  166. package/src/modules/workflow/git/pull-request.service.ts +22 -4
  167. package/src/modules/workflow/pending-commits.worker.ts +80 -18
  168. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
  169. package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
  170. package/src/modules/workflow/workflow.routes.ts +12 -0
  171. package/src/modules/workflow/workflow.service.ts +123 -7
  172. package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
  173. package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
  174. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
  175. package/src/modules/workspace/startup/kb-git.ts +20 -7
  176. package/src/modules/workspace/workspace.service.ts +132 -25
  177. package/src/modules/workspace/workspace.tools.ts +35 -15
@@ -49,11 +49,12 @@ import type { GitService } from './git/git.service.js';
49
49
  import type { PullRequestService } from './git/pull-request.service.js';
50
50
  import type { IReviewWorkflowService } from './review-workflow/review-workflow.interface.js';
51
51
  import type { WorkspaceService } from '../workspace/workspace.service.js';
52
- import { workspaceIdForBranch } from '../../shared/workspace-id.js';
52
+ import { workspaceIdForBranch, branchForWorkspaceId } from '../../shared/workspace-id.js';
53
53
  import type { IAccessControl } from '../access/access-control.interface.js';
54
54
  import { FileLockService } from './file-lock.service.js';
55
55
  import { PendingCommitsService } from './pending-commits.service.js';
56
56
  import type { WorkflowEventBus } from './event-bus.js';
57
+ import { sanitizeError } from './sanitize-error.js';
57
58
  import type { FileChangeNotifier } from '../kb-fs/file-change-notifier.js';
58
59
  import { WorkflowHooks } from './workflow-hooks.js';
59
60
  import { WorkspaceMutex } from '../kb-fs/mutex.js';
@@ -355,7 +356,7 @@ export class WorkflowService implements IWorkflowService {
355
356
  // to discard.
356
357
 
357
358
  shareCurrentBranch(workspaceId: string, user: AuthUser): Promise<void> {
358
- return this.git.push(workspaceId, user);
359
+ return this.trackedPush(workspaceId, user);
359
360
  }
360
361
 
361
362
  refreshRemotes(workspaceId: string): Promise<void> {
@@ -680,6 +681,7 @@ export class WorkflowService implements IWorkflowService {
680
681
  if (change) {
681
682
  try {
682
683
  await this.git.push(workspaceId, user);
684
+ this.noteGitSyncOk(workspaceId, branch);
683
685
  } catch (err) {
684
686
  // Non-fast-forward recovery on the autosave path: try the
685
687
  // cooperative `pull --rebase` + retry once. Autosave is
@@ -695,12 +697,15 @@ export class WorkflowService implements IWorkflowService {
695
697
  const detail = err instanceof Error ? err.message : String(err);
696
698
  const looksLikeNonFastForward = /non-fast-forward|rejected|fetch first|updates were rejected/i.test(detail);
697
699
  let recovered = false;
700
+ let recoveryError: unknown = null;
698
701
  if (looksLikeNonFastForward) {
699
702
  try {
700
703
  await this.git.pull(workspaceId);
701
704
  await this.git.push(workspaceId, user);
702
705
  recovered = true;
706
+ this.noteGitSyncOk(workspaceId, branch);
703
707
  } catch (recoveryErr) {
708
+ recoveryError = recoveryErr;
704
709
  console.warn(
705
710
  '[workflow] autosave cooperative recovery (pull-rebase) failed; leaving the unpushed commit for the next save / releaseLock to surface:',
706
711
  recoveryErr instanceof Error ? recoveryErr.message : recoveryErr,
@@ -708,6 +713,20 @@ export class WorkflowService implements IWorkflowService {
708
713
  }
709
714
  }
710
715
  if (!recovered) {
716
+ // Still best-effort — we don't fail the autosave. What gets the
717
+ // BANNER is only the class that cannot clear itself: auth or host
718
+ // failures, which no later attempt fixes. A non-fast-forward here —
719
+ // recovery attempted or not — is the routine two-editors race the
720
+ // surrounding flow already handles: the release push retries it,
721
+ // and if THAT fails the user gets the typed agent hand-off plus the
722
+ // banner from `pushWithRecovery`. Bannering it from a background
723
+ // autosave would tell an author mid-keystroke that the deployment
724
+ // is broken over a race that usually settles on its own — while an
725
+ // auth failure swallowed into a log line is how a broken deployment
726
+ // once stacked 136 local commits before anyone found out.
727
+ if (!looksLikeNonFastForward) {
728
+ this.noteGitSyncFailed(workspaceId, branch, recoveryError ?? err);
729
+ }
711
730
  console.warn(
712
731
  '[workflow] push after autosave commit failed (commit landed locally):',
713
732
  detail,
@@ -896,6 +915,87 @@ export class WorkflowService implements IWorkflowService {
896
915
  this.fileChanges?.emit({ workspaceId, branch, paths: [targetPath], byUser: user });
897
916
  }
898
917
 
918
+ /**
919
+ * Workspaces whose last push failed. Purely so the recovery event fires on
920
+ * the transition back to healthy instead of on every subsequent save — a
921
+ * push runs on every write, and the healthy case must stay silent.
922
+ *
923
+ * Process-local and deliberately not persisted: a restart clears it, and the
924
+ * worst that costs is one redundant `git-sync-recovered` on a workspace that
925
+ * was already fine, which clients treat as a no-op.
926
+ */
927
+ private readonly gitSyncFailing = new Set<string>();
928
+
929
+ /**
930
+ * Announce that a push landed. Emits only when this workspace was previously
931
+ * failing — see `gitSyncFailing`.
932
+ */
933
+ private noteGitSyncOk(workspaceId: string, branch: string): void {
934
+ // Callers hand this both spellings of a workspace id — HTTP routes pass
935
+ // the Express-decoded `user/feat`, internal callers (the pending-commits
936
+ // worker, the agent filesystem) the encoded `user%2Ffeat`. Keyed raw, a
937
+ // failure recorded by one caller could never be cleared by the other's
938
+ // success, and the banner would stick. Canonicalize the key AND the
939
+ // emitted id so every consumer sees one spelling.
940
+ const id = branchForWorkspaceId(workspaceId);
941
+ if (!this.gitSyncFailing.delete(id)) return;
942
+ console.log(`[workflow] git sync recovered for workspace=${id} branch=${branch}`);
943
+ this.events?.emit({ kind: 'git-sync-recovered', workspaceId: id, branch });
944
+ }
945
+
946
+ /**
947
+ * Announce that a commit is sitting locally because its push failed.
948
+ *
949
+ * This is the visibility half of the failure paths below; it never changes
950
+ * what they DO. The commit is already safe on disk and the retry / hand-off
951
+ * machinery is unaffected — all this adds is that someone finds out, rather
952
+ * than the deployment looking healthy while nothing reaches the remote.
953
+ */
954
+ private noteGitSyncFailed(workspaceId: string, branch: string, err: unknown): void {
955
+ // Same canonicalization as `noteGitSyncOk` — the pair must agree on keys.
956
+ const id = branchForWorkspaceId(workspaceId);
957
+ this.gitSyncFailing.add(id);
958
+ this.events?.emit({
959
+ kind: 'git-sync-failed',
960
+ workspaceId: id,
961
+ branch,
962
+ // Git stderr can quote a credentialed URL; the banner is user-facing and
963
+ // the string also lands in client logs, so sanitize before it leaves.
964
+ reason: sanitizeError(err),
965
+ });
966
+ }
967
+
968
+ /**
969
+ * A bare push wrapped in the sync tracker: a failure raises the banner, a
970
+ * success clears it. For the workflow paths that push directly — sharing a
971
+ * branch, publishing a CR's source, update-from-base, the decline revert,
972
+ * the roles.yaml restore — rather than through `pushWithRecovery`'s
973
+ * cooperative ladder. Behaviour is otherwise unchanged: the error still
974
+ * propagates to the caller exactly as the bare push's did. Without this,
975
+ * those pushes could fail invisibly (no banner) and, worse, a successful
976
+ * share could not CLEAR a banner an earlier save had raised.
977
+ *
978
+ * The branch is derived from the workspace id (they are the same string,
979
+ * URL-encoding aside) so call sites cannot pass a mismatched pair.
980
+ */
981
+ private async trackedPush(
982
+ workspaceId: string,
983
+ user: AuthUser,
984
+ opts?: { systemAuthorized?: boolean },
985
+ ): Promise<void> {
986
+ const branch = branchForWorkspaceId(workspaceId);
987
+ try {
988
+ // Preserve the exact call shape of the bare pushes this replaces — an
989
+ // explicit `undefined` third argument is a different call signature to
990
+ // every spy that asserts on it.
991
+ await (opts ? this.git.push(workspaceId, user, opts) : this.git.push(workspaceId, user));
992
+ this.noteGitSyncOk(workspaceId, branch);
993
+ } catch (err) {
994
+ this.noteGitSyncFailed(workspaceId, branch, err);
995
+ throw err;
996
+ }
997
+ }
998
+
899
999
  /**
900
1000
  * Shared implementation of "push, and on non-fast-forward try a
901
1001
  * cooperative pull-rebase + retry, then hand off to the agent if that
@@ -911,20 +1011,24 @@ export class WorkflowService implements IWorkflowService {
911
1011
  ): Promise<void> {
912
1012
  try {
913
1013
  await this.git.push(workspaceId, user, opts);
1014
+ this.noteGitSyncOk(workspaceId, branch);
914
1015
  } catch (firstPushErr) {
915
1016
  const firstDetail = firstPushErr instanceof Error ? firstPushErr.message : String(firstPushErr);
916
1017
  const looksLikeNonFastForward = /non-fast-forward|rejected|fetch first|updates were rejected/i.test(firstDetail);
917
1018
  let recovered = false;
918
1019
  let recoveryDetail = '(cooperative path not attempted)';
1020
+ let recoveryError: unknown = null;
919
1021
  if (looksLikeNonFastForward) {
920
1022
  try {
921
1023
  await this.git.pull(workspaceId);
922
1024
  await this.git.push(workspaceId, user, opts);
923
1025
  recovered = true;
1026
+ this.noteGitSyncOk(workspaceId, branch);
924
1027
  console.log(
925
1028
  `[workflow] non-fast-forward push recovered via pull --rebase for workspace=${workspaceId} branch=${branch} path=${targetPath}`,
926
1029
  );
927
1030
  } catch (recoveryErr) {
1031
+ recoveryError = recoveryErr;
928
1032
  recoveryDetail = recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr);
929
1033
  console.warn(
930
1034
  `[workflow] cooperative recovery (pull-rebase) failed for workspace=${workspaceId} user=${user.id}; handing off to agent:`,
@@ -933,6 +1037,14 @@ export class WorkflowService implements IWorkflowService {
933
1037
  }
934
1038
  }
935
1039
  if (!recovered) {
1040
+ // Banner first, then the typed throw. The throw only reaches a UI that
1041
+ // is still mounted and listening — the release fired as an editor
1042
+ // unmounts has nowhere to surface it — whereas the event reaches every
1043
+ // session on the branch regardless of what triggered the push.
1044
+ // When recovery RAN, its error is the current state of the world (the
1045
+ // first rejection may be a stale non-fast-forward the pull already
1046
+ // cured); when it was skipped, the first error is all there is.
1047
+ this.noteGitSyncFailed(workspaceId, branch, recoveryError ?? firstPushErr);
936
1048
  console.warn(
937
1049
  `[workflow] push failed for workspace=${workspaceId} user=${user.id}; throwing PushNeedsAgentResolutionError so the frontend can hand off to the agent:`,
938
1050
  firstDetail,
@@ -1129,7 +1241,7 @@ export class WorkflowService implements IWorkflowService {
1129
1241
 
1130
1242
  getChangeRequestDetail(
1131
1243
  number: number,
1132
- opts?: { fresh?: boolean; workspaceId?: string; viewerEmail?: string },
1244
+ opts?: { fresh?: boolean; workspaceId?: string; viewerEmail?: string; patches?: boolean },
1133
1245
  ): Promise<ChangeRequestDetail | null> {
1134
1246
  return this.prs.getPrDetail(number, opts);
1135
1247
  }
@@ -1214,7 +1326,7 @@ export class WorkflowService implements IWorkflowService {
1214
1326
  // Push the source — `gh pr create` against an unpushed branch returns
1215
1327
  // "head branch does not exist on remote". Push *after* the auto-merge
1216
1328
  // so the remote sees the merge commit too.
1217
- await this.git.push(workspaceId, user);
1329
+ await this.trackedPush(workspaceId, user);
1218
1330
 
1219
1331
  const workspacePath = await this.workspaceService.getWorkspacePath(workspaceId);
1220
1332
  const cwd = path.join(workspacePath, this.kbDirName);
@@ -1367,7 +1479,7 @@ export class WorkflowService implements IWorkflowService {
1367
1479
  // If a new merge commit landed, push so the CR picks it up. When
1368
1480
  // already up to date there's nothing to share — short-circuit the push.
1369
1481
  if (!outcome.alreadyUpToDate) {
1370
- await this.git.push(workspaceId, user);
1482
+ await this.trackedPush(workspaceId, user);
1371
1483
  }
1372
1484
  this.prs.invalidateDetailCache(number);
1373
1485
  const refreshed = await this.prs.getPrDetail(number, {
@@ -1556,7 +1668,7 @@ export class WorkflowService implements IWorkflowService {
1556
1668
  `Revert ${repoRelPath} (declined in change request #${number})`,
1557
1669
  true, // skipValidator — this restores an already-validated base version
1558
1670
  );
1559
- await this.git.push(ws.id, user);
1671
+ await this.trackedPush(ws.id, user);
1560
1672
  } finally {
1561
1673
  // Committed inline — drop the lock row directly rather than enqueueing
1562
1674
  // a duplicate commit through releaseLock.
@@ -1784,6 +1896,10 @@ export class WorkflowService implements IWorkflowService {
1784
1896
  fresh: true,
1785
1897
  workspaceId,
1786
1898
  viewerEmail: user.email,
1899
+ // Only `files[].path` is read below; a patch per file would be one
1900
+ // git subprocess each, generated for nothing, on every reject by a
1901
+ // non-author.
1902
+ patches: false,
1787
1903
  });
1788
1904
  const paths = detail?.files.map((f) => f.path) ?? [];
1789
1905
  if (paths.length > 0) {
@@ -2105,7 +2221,7 @@ export class WorkflowService implements IWorkflowService {
2105
2221
  'roles.yaml restore produced no commit while origin still diverges from base — source workspace out of sync; refusing to merge',
2106
2222
  );
2107
2223
  }
2108
- await this.git.push(ws.id, user);
2224
+ await this.trackedPush(ws.id, user);
2109
2225
  this.accessControl.invalidate(ws.id);
2110
2226
  return true;
2111
2227
  } finally {
@@ -454,7 +454,7 @@ describe('WorkspaceService.sweepOrphanedWorkspaces', () => {
454
454
 
455
455
  beforeEach(async () => {
456
456
  root = await mkTmpRoot();
457
- // The fake `.git` in seedBranchWorkspace makes normalizeCloneTracking's
457
+ // The fake `.git` in seedBranchWorkspace makes normalizeCloneConfig's
458
458
  // git calls fail; that path only warns, which is noise here.
459
459
  vi.spyOn(console, 'warn').mockImplementation(() => {});
460
460
  });
@@ -1065,3 +1065,48 @@ describe('branch is a required parameter in the tool contract', () => {
1065
1065
  }
1066
1066
  });
1067
1067
  });
1068
+
1069
+ /**
1070
+ * The tools are rooted at the WORKSPACE dir, one level above the git clone, so
1071
+ * a path has to start with the clone folder to reach git at all. Every place an
1072
+ * agent reads before choosing a path says so, in the `path` input itself rather
1073
+ * than only in prose it may not read: the root listing (where the folder is
1074
+ * discoverable) and each content-writing tool.
1075
+ */
1076
+ describe('path inputs tell the agent about the repository folder', () => {
1077
+ // `toolDef` wraps a tool's inputs under a single `body` property.
1078
+ const inputDescription = (def: { inputs?: unknown } | undefined, ...keys: string[]): string => {
1079
+ let node = (def?.inputs ?? {}) as Record<string, unknown>;
1080
+ for (const key of ['body', ...keys]) {
1081
+ node = ((node.properties as Record<string, unknown> | undefined)?.[key] ?? {}) as Record<string, unknown>;
1082
+ if (key === 'files') node = (node.items ?? {}) as Record<string, unknown>;
1083
+ }
1084
+ return typeof node.description === 'string' ? node.description : '';
1085
+ };
1086
+
1087
+ it('every content-writing tool says paths start with `knowledge-base/`, so a repo-relative path is never guessed', async () => {
1088
+ await start();
1089
+ const tools = await toolRegistry.listInternal();
1090
+ const byName = (name: string) => {
1091
+ const def = tools.find((t) => t.name === name);
1092
+ expect(def, name).toBeDefined();
1093
+ return def;
1094
+ };
1095
+ for (const name of ['write_file', 'edit_file', 'mkdir', 'delete_file', 'unzip']) {
1096
+ expect(inputDescription(byName(name), 'path'), name).toContain(`\`${KB_DIR}/\``);
1097
+ }
1098
+ expect(inputDescription(byName('write_files'), 'files', 'path'), 'write_files').toContain(`\`${KB_DIR}/\``);
1099
+ for (const name of ['move_file', 'copy_file']) {
1100
+ expect(inputDescription(byName(name), 'dest'), name).toContain(`\`${KB_DIR}/\``);
1101
+ }
1102
+ });
1103
+
1104
+ it('the root listing names the clone folder, so an agent that lists first learns the prefix', async () => {
1105
+ await start();
1106
+ const tools = await toolRegistry.listInternal();
1107
+ const list = tools.find((t) => t.name === 'list_files');
1108
+ expect(list).toBeDefined();
1109
+ expect(list!.description).toContain(`\`${KB_DIR}/\``);
1110
+ expect(inputDescription(list, 'path')).toContain(`\`${KB_DIR}/\``);
1111
+ });
1112
+ });
@@ -5,6 +5,7 @@ import fs from 'node:fs/promises';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { KbStartupRunner } from '../kb-startup-runner.js';
8
+ import { WorkspaceService } from '../../workspace.service.js';
8
9
  import { redactSecret } from '../kb-git.js';
9
10
  import type { OnServerStart, ServerStartContext, StepResult } from '../on-server-start.js';
10
11
 
@@ -493,3 +494,143 @@ describe('redactSecret', () => {
493
494
  expect(redactSecret('https://example.com/kb.git')).toBe('https://example.com/kb.git');
494
495
  });
495
496
  });
497
+
498
+ describe('KbStartupRunner credentials', () => {
499
+ // The phase clones the default branch into the very directory
500
+ // `WorkspaceService` adopts, and `GitService.push` later runs a bare
501
+ // `git push` from it. Position of the credential config is what decides
502
+ // whether that works: `git -c … clone` authenticates the clone and leaves
503
+ // the repo with nothing, so every later push prompts for a username, finds
504
+ // no tty, and dies — commits pile up locally and reach the remote never.
505
+ // Only real git tells the two spellings apart, so this drives real git.
506
+ const ORIGINAL = process.env.GITHUB_TOKEN;
507
+ afterEach(() => {
508
+ if (ORIGINAL === undefined) delete process.env.GITHUB_TOKEN;
509
+ else process.env.GITHUB_TOKEN = ORIGINAL;
510
+ });
511
+
512
+
513
+ /** Run the phase far enough to put the default branch's clone on disk. */
514
+ async function materializeDefaultBranchClone(): Promise<string> {
515
+ let repoDir = '';
516
+ await makeRunner([
517
+ step('touch', async (ctx) => {
518
+ repoDir = await (await ctx.defaultBranch()).repoDir();
519
+ return { outcome: 'ok' };
520
+ }),
521
+ ]).runAll();
522
+ expect(repoDir).toBe(path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base'));
523
+ return repoDir;
524
+ }
525
+
526
+ it('leaves the boot clone able to authenticate a push of its own', async () => {
527
+ process.env.GITHUB_TOKEN = 'ghp_boot';
528
+ await populatedUpstream();
529
+ // The branch handle clones lazily, so a step has to actually reach for the
530
+ // repo before there is anything on disk to inspect.
531
+ const repo = await materializeDefaultBranchClone();
532
+ const helper = (await git(repo, ['config', '--local', '--get', 'credential.helper'])).trim();
533
+ expect(helper).toContain('username=x-access-token');
534
+ // The literal, not the value — the helper resolves it per invocation, so
535
+ // the secret never lands in a config file we don't control the lifetime of.
536
+ expect(helper).toContain('password=$GITHUB_TOKEN');
537
+ expect(helper).not.toContain('ghp_boot');
538
+
539
+ // Exactly one value: a re-stamp must collapse, never accumulate, or git
540
+ // errors with "cannot overwrite multiple values".
541
+ const all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
542
+ expect(all.split('\n')).toHaveLength(1);
543
+ });
544
+
545
+ it('adopting an existing clone collapses an accumulated helper back to one value', async () => {
546
+ // The boot-time assertion above sees a FRESH clone, which only ever has
547
+ // one value by construction. The collapse guarantee lives in the adoption
548
+ // path (`--replace-all`), so drift a second value in out of band and let
549
+ // WorkspaceService adopt the clone — that is the code the guarantee is
550
+ // about.
551
+ process.env.GITHUB_TOKEN = 'ghp_boot';
552
+ await populatedUpstream();
553
+ const repo = await materializeDefaultBranchClone();
554
+ // App-shaped drift: a second stamp of OURS with a stale username — the
555
+ // realistic accumulation (an operator's own helper is a different case,
556
+ // covered below, and must NOT be collapsed).
557
+ await git(repo, ['config', '--add', 'credential.helper',
558
+ '!f() { echo "username=stale-user"; echo "password=$GITHUB_TOKEN"; }; f']);
559
+
560
+ const ws = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
561
+ await ws.getOrCreateForBranch(DEFAULT_BRANCH);
562
+
563
+ const all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
564
+ expect(all.split('\n')).toHaveLength(1);
565
+ expect(all).toContain('username=x-access-token');
566
+ expect(all).not.toContain('stale-user');
567
+ });
568
+
569
+ it('an operator-configured helper survives both the stamp and the token-removal unset', async () => {
570
+ // git chains every configured helper, so an operator's clone-local
571
+ // `cache`/`store`/custom helper coexists with ours — and losing it on a
572
+ // token change would break the very fallback auth they set up.
573
+ process.env.GITHUB_TOKEN = 'ghp_boot';
574
+ await populatedUpstream();
575
+ const repo = await materializeDefaultBranchClone();
576
+ await git(repo, ['config', '--add', 'credential.helper', 'cache --timeout=300']);
577
+
578
+ const ws = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
579
+ await ws.getOrCreateForBranch(DEFAULT_BRANCH); // stamp with token
580
+ let all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
581
+ expect(all).toContain('cache --timeout=300');
582
+ expect(all).toContain('password=$GITHUB_TOKEN');
583
+
584
+ delete process.env.GITHUB_TOKEN;
585
+ const ws2 = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
586
+ await ws2.getOrCreateForBranch(DEFAULT_BRANCH); // unset OUR helper only
587
+ all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
588
+ expect(all).toContain('cache --timeout=300');
589
+ expect(all).not.toContain('password=$GITHUB_TOKEN');
590
+ });
591
+
592
+ it('adopting a clone after the token was removed unsets the stale helper', async () => {
593
+ // A deployment that lost its token must not keep a clone-local helper
594
+ // answering with an empty password — it would shadow whatever fallback
595
+ // auth the operator switched to.
596
+ process.env.GITHUB_TOKEN = 'ghp_boot';
597
+ await populatedUpstream();
598
+ const repo = await materializeDefaultBranchClone(); // stamped
599
+ delete process.env.GITHUB_TOKEN;
600
+
601
+ const ws = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
602
+ await ws.getOrCreateForBranch(DEFAULT_BRANCH);
603
+
604
+ await expect(git(repo, ['config', '--local', '--get', 'credential.helper']))
605
+ .rejects.toMatchObject({ code: 1 });
606
+ });
607
+
608
+ it('a token added after the branch is cached reaches the clone without a restart', async () => {
609
+ // The rotation gap: the setup screen can supply a token AFTER a branch
610
+ // was first opened, and the cached fast path returns before the adoption
611
+ // re-stamp. The fingerprint check must catch the change there.
612
+ delete process.env.GITHUB_TOKEN;
613
+ await populatedUpstream();
614
+ const repo = await materializeDefaultBranchClone(); // no helper
615
+
616
+ const ws = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
617
+ await ws.getOrCreateForBranch(DEFAULT_BRANCH); // adopt, tokenless
618
+ process.env.GITHUB_TOKEN = 'ghp_late';
619
+ await ws.getOrCreateForBranch(DEFAULT_BRANCH); // cached fast path
620
+
621
+ const helper = (await git(repo, ['config', '--local', '--get', 'credential.helper'])).trim();
622
+ expect(helper).toContain('password=$GITHUB_TOKEN');
623
+ expect(helper).not.toContain('ghp_late');
624
+ });
625
+
626
+ it('clones without a helper when the deployment has no token — an open remote still boots', async () => {
627
+ delete process.env.GITHUB_TOKEN;
628
+ await populatedUpstream();
629
+ const repo = await materializeDefaultBranchClone();
630
+ // `--get` exits 1 when the key is unset. Assert on that exit code rather
631
+ // than on "it threw": a missing clone directory throws too, which would
632
+ // pass this test while proving nothing.
633
+ await expect(git(repo, ['config', '--local', '--get', 'credential.helper']))
634
+ .rejects.toMatchObject({ code: 1 });
635
+ });
636
+ });
@@ -3,6 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
+ import { cloneCredentialArgs, credentialHelperValue } from '../../kb-fs/clone-config.js';
6
7
 
7
8
  const execFileAsync = promisify(execFile);
8
9
 
@@ -36,18 +37,30 @@ export function redactSecret(text: string): string {
36
37
  */
37
38
  function credArgs(gitUsername: string): string[] {
38
39
  const args = ['-c', 'core.longpaths=true'];
39
- if (process.env.GITHUB_TOKEN) {
40
- args.push(
41
- '-c',
42
- `credential.helper=!f() { echo "username=${gitUsername}"; echo "password=$GITHUB_TOKEN"; }; f`,
43
- );
44
- }
40
+ const helper = credentialHelperValue(gitUsername);
41
+ if (helper) args.push('-c', `credential.helper=${helper}`);
45
42
  return args;
46
43
  }
47
44
 
45
+ /**
46
+ * `credArgs` authenticates the invocation and nothing more — the `-c` pairs sit
47
+ * BEFORE the subcommand, so git applies them to this process and forgets them.
48
+ * That is right for every command here except `clone`, whose product is a
49
+ * repository other code pushes from later: the phase clones the default branch
50
+ * into `<workspacesRoot>/<id>/<kbDirName>`, exactly where `WorkspaceService`
51
+ * adopts it, and `GitService.push` then runs a bare `git push` expecting the
52
+ * clone to carry its own credentials. Persist them with `clone --config` (which
53
+ * only means "write into the new repo" after the subcommand) so it does.
54
+ */
55
+ function withPersistedCloneConfig(gitUsername: string, args: string[]): string[] {
56
+ if (args[0] !== 'clone') return args;
57
+ return [args[0], ...cloneCredentialArgs(gitUsername), ...args.slice(1)];
58
+ }
59
+
48
60
  export async function git(cwd: string, gitUsername: string, args: string[]): Promise<string> {
49
61
  try {
50
- const { stdout } = await execFileAsync('git', [...credArgs(gitUsername), ...args], {
62
+ const argv = [...credArgs(gitUsername), ...withPersistedCloneConfig(gitUsername, args)];
63
+ const { stdout } = await execFileAsync('git', argv, {
51
64
  cwd,
52
65
  // A stalled remote must FAIL the phase, not hang the boot forever —
53
66
  // fail-closed (and KB_SAFE_BOOT's demotion) can only engage on an error