@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
@@ -9,7 +9,12 @@ import { BevelIgnoreStack } from './bevel-ignore.js';
9
9
  import { workspaceIdForBranch, branchForWorkspaceId } from '../../shared/workspace-id.js';
10
10
  import { WorkflowDomainError } from '../../shared/domain-errors.js';
11
11
  import { assertValidBranchName } from '../kb-fs/branch-name.js';
12
- import { cloneTrackingConfigArgs, SAFE_IMPLICIT_FETCH_ARGS } from '../kb-fs/clone-config.js';
12
+ import {
13
+ cloneCredentialArgs,
14
+ cloneCredentialConfigArgs,
15
+ cloneTrackingConfigArgs,
16
+ SAFE_IMPLICIT_FETCH_ARGS,
17
+ } from '../kb-fs/clone-config.js';
13
18
  import type { IDiffService } from '../diff/diff.interface.js';
14
19
 
15
20
  /**
@@ -155,9 +160,13 @@ export class WorkspaceService implements IWorkspaceService {
155
160
  this.onWorkspaceCloned = listener;
156
161
  }
157
162
 
158
- /** Build git clone args that inject credentials via -c headers so the token never appears in the repo URL. */
163
+ /**
164
+ * Build git clone args. Credentials go in as `clone --config`, which both
165
+ * authenticates this clone and leaves the helper in the new repo's config for
166
+ * every later push — and keeps the token itself out of the repo URL, out of
167
+ * argv, and out of `.git/config` (the helper reads it from the environment).
168
+ */
159
169
  private gitCloneArgs(targetDir: string, branch: string, referenceRepo?: string): string[] {
160
- const token = process.env.GITHUB_TOKEN;
161
170
  // core.longpaths=true: KB has paths >260 chars; without this the clone silently drops those files on Windows.
162
171
  const args = ['clone', '-c', 'core.longpaths=true', '-b', branch];
163
172
  // Borrow the object database of an existing sibling clone. Every commit
@@ -171,14 +180,10 @@ export class WorkspaceService implements IWorkspaceService {
171
180
  if (referenceRepo) {
172
181
  args.push('--reference', referenceRepo, '--dissociate');
173
182
  }
174
- if (token) {
175
- // The helper script reads from GITHUB_TOKEN at runtime — the token value never appears in args.
176
- // Username is provider-specific (GitHub `x-access-token`, GitLab `oauth2`, …); the token is
177
- // always the Basic-auth password, which every major host accepts.
178
- args.push(
179
- '-c', `credential.helper=!f() { echo "username=${this.gitUsername()}"; echo "password=$GITHUB_TOKEN"; }; f`,
180
- );
181
- }
183
+ // Persisted into the new repo's config (`clone --config`), not just applied
184
+ // to this invocation — every later push from this clone depends on finding
185
+ // the helper there. See `cloneCredentialArgs`.
186
+ args.push(...cloneCredentialArgs(this.gitUsername()));
182
187
  args.push(this.kbRepoUrl(), targetDir);
183
188
  return args;
184
189
  }
@@ -288,6 +293,11 @@ export class WorkspaceService implements IWorkspaceService {
288
293
  // back a half-cloned workspace.
289
294
  if (this.branchDirs.has(branch)) {
290
295
  await this.awaitBootstrapOrEvict(branch);
296
+ // A token or username set/changed through the setup screen after this
297
+ // branch was first opened has to reach the already-cached clone, or its
298
+ // pushes keep failing — the fast path is exactly where that rotation is
299
+ // otherwise missed.
300
+ await this.refreshCredentialHelperIfStale(branch, repoDir);
291
301
  return this.buildWorkspaceInfo(branch, workspaceDir);
292
302
  }
293
303
 
@@ -301,6 +311,7 @@ export class WorkspaceService implements IWorkspaceService {
301
311
  // single-flight wait the cached fast path already uses.
302
312
  await this.awaitBootstrapOrEvict(branch);
303
313
  if (this.branchDirs.has(branch)) {
314
+ await this.refreshCredentialHelperIfStale(branch, repoDir);
304
315
  return this.buildWorkspaceInfo(branch, workspaceDir);
305
316
  }
306
317
 
@@ -313,7 +324,7 @@ export class WorkspaceService implements IWorkspaceService {
313
324
  // (duplicate fetch refspec / merge ref) is repaired before anything
314
325
  // pulls it. Once per branch per process — the cached paths above return
315
326
  // before reaching here.
316
- await this.normalizeCloneTracking(repoDir, branch);
327
+ await this.normalizeCloneConfig(repoDir, branch);
317
328
  this.branchDirs.set(branch, workspaceDir);
318
329
  return this.buildWorkspaceInfo(branch, workspaceDir);
319
330
  } catch {
@@ -396,17 +407,26 @@ export class WorkspaceService implements IWorkspaceService {
396
407
  }
397
408
 
398
409
  /**
399
- * Collapse the clone's tracking config to a single fetch refspec and a single
400
- * upstream ref for `branch` (see `kb-fs/clone-config.ts`). A clone that
401
- * accumulated a second `remote.origin.fetch` refspec or `branch.<b>.merge`
402
- * value makes git refuse to refresh it — "Cannot rebase onto multiple
403
- * branches" — which is how a post-merge pull of the target branch fails.
410
+ * Bring an adopted clone's config back to what a fresh clone would carry.
411
+ *
412
+ * Tracking: collapse it to a single fetch refspec and a single upstream ref
413
+ * for `branch` (see `kb-fs/clone-config.ts`). A clone that accumulated a
414
+ * second `remote.origin.fetch` refspec or `branch.<b>.merge` value makes git
415
+ * refuse to refresh it — "Cannot rebase onto multiple branches" — which is
416
+ * how a post-merge pull of the target branch fails.
417
+ *
418
+ * Credentials: re-stamp the helper. This is the repair for clones that never
419
+ * got one — anything created by a build whose clone authenticated only its
420
+ * own invocation, or by a deployment that had no token configured until
421
+ * after the clone existed. Without it those clones are permanently
422
+ * unpushable: nothing re-clones a directory that is already on disk, so no
423
+ * restart, re-pull or settings change would ever fix them.
404
424
  *
405
425
  * Never throws: it only ever touches `.git/config`, so a failure leaves the
406
426
  * workspace exactly as usable as it was, and the git layer self-heals the
407
- * same keys before it pulls.
427
+ * tracking keys before it pulls.
408
428
  */
409
- private async normalizeCloneTracking(repoDir: string, branch: string): Promise<void> {
429
+ private async normalizeCloneConfig(repoDir: string, branch: string): Promise<void> {
410
430
  try {
411
431
  for (const args of cloneTrackingConfigArgs(branch)) {
412
432
  await execFileAsync('git', ['-C', repoDir, ...args]);
@@ -415,12 +435,99 @@ export class WorkspaceService implements IWorkspaceService {
415
435
  // One line per branch, not per key: every key writes to the same
416
436
  // `.git/config`, so what fails for one fails for all.
417
437
  console.warn(
418
- `[workspace] could not normalize the tracking config of the "${branch}" clone:`,
438
+ `[workspace] could not normalize the config of the "${branch}" clone:`,
439
+ redactError(err),
440
+ );
441
+ }
442
+ await this.stampCredentialHelper(repoDir, branch);
443
+ }
444
+
445
+ /**
446
+ * Fingerprint of the credential state a clone's helper is derived from: the
447
+ * username (baked into the helper literal) and whether a token exists at all
448
+ * (the helper is present-or-absent on that). The token VALUE is deliberately
449
+ * excluded — the helper reads `$GITHUB_TOKEN` at call time, so rotating the
450
+ * token needs no re-stamp; only a username change or a token appearing /
451
+ * disappearing does.
452
+ */
453
+ private credentialFingerprint(): string {
454
+ return `${this.gitUsername()}::${process.env.GITHUB_TOKEN ? '1' : '0'}`;
455
+ }
456
+
457
+ /** Last credential fingerprint stamped into each branch's clone, this process. */
458
+ private readonly stampedCredentialFingerprint = new Map<string, string>();
459
+
460
+ /**
461
+ * Bring `repoDir`'s credential helper into line with the current deployment
462
+ * config (stamp when a token is set, unset when none is), and record the
463
+ * fingerprint so the cached fast path can skip a no-op re-stamp. Runs the
464
+ * config args tolerantly and one at a time: `--unset-all` on a clone that
465
+ * never had a helper exits non-zero, and that must neither abort the rest
466
+ * nor read as a failure.
467
+ */
468
+ private async stampCredentialHelper(repoDir: string, branch: string): Promise<void> {
469
+ let argLists: string[][];
470
+ try {
471
+ argLists = cloneCredentialConfigArgs(this.gitUsername());
472
+ } catch (err) {
473
+ // `credentialHelperValue` fails closed on a username outside its safe
474
+ // charset. Normally unreachable (CoreConfig and the settings service
475
+ // validate first), but if a fourth, unvalidated path ever appears the
476
+ // throw must not escape here: in the disk-adoption block it would land
477
+ // in a catch that reads every error as "not on disk" and kick off a
478
+ // re-clone that fails on the same validation — masking the real cause.
479
+ // Contain it as a loud non-stamp instead; `normalizeCloneConfig`'s
480
+ // never-throws contract stays true.
481
+ console.warn(
482
+ `[workspace] refusing to stamp the credential helper of the "${branch}" clone:`,
419
483
  redactError(err),
420
484
  );
485
+ this.stampedCredentialFingerprint.delete(branch);
486
+ return;
487
+ }
488
+ let stamped = true;
489
+ for (const args of argLists) {
490
+ try {
491
+ await execFileAsync('git', ['-C', repoDir, ...args]);
492
+ } catch (err) {
493
+ // `--unset-all` with no matching value (exit 5) is the expected no-op
494
+ // for a clone that never carried an app helper; anything else is a
495
+ // real failure.
496
+ const code = (err as { code?: number } | null)?.code;
497
+ if (!(args.includes('--unset-all') && code === 5)) {
498
+ stamped = false;
499
+ console.warn(
500
+ `[workspace] could not stamp the credential helper of the "${branch}" clone:`,
501
+ redactError(err),
502
+ );
503
+ }
504
+ }
505
+ }
506
+ if (stamped) {
507
+ this.stampedCredentialFingerprint.set(branch, this.credentialFingerprint());
508
+ } else {
509
+ // Recording the fingerprint after a FAILED write would make every later
510
+ // cached open skip the retry as "already up to date" — the clone would
511
+ // keep pushing with stale or missing credentials until a restart.
512
+ // Dropping the entry keeps the fast path retrying until a stamp lands.
513
+ this.stampedCredentialFingerprint.delete(branch);
421
514
  }
422
515
  }
423
516
 
517
+ /**
518
+ * Re-stamp a cached clone's credential helper when the deployment's
519
+ * credential config changed since we last stamped it — the rotation case the
520
+ * disk-adoption path in `getOrCreateForBranch` never reaches, because the
521
+ * fast path returns before it. Without this, a token added (or a username
522
+ * changed) through the setup screen after a branch was first opened would
523
+ * never reach that branch's clone until a process restart, and its pushes
524
+ * would keep failing exactly as in the original incident.
525
+ */
526
+ private async refreshCredentialHelperIfStale(branch: string, repoDir: string): Promise<void> {
527
+ if (this.stampedCredentialFingerprint.get(branch) === this.credentialFingerprint()) return;
528
+ await this.stampCredentialHelper(repoDir, branch);
529
+ }
530
+
424
531
  private async cloneProcessMapForBranch(workspaceDir: string, branch: string): Promise<void> {
425
532
  const targetDir = path.join(workspaceDir, this.kbDirName);
426
533
 
@@ -465,10 +572,10 @@ export class WorkspaceService implements IWorkspaceService {
465
572
  }
466
573
  if (alreadyCloned) {
467
574
  // We don't auto-pull because that could clobber another user's
468
- // in-progress lock-held edits. The tracking config IS re-stamped —
469
- // it never touches the working tree, and a drifted config is what
470
- // breaks the next pull.
471
- await this.normalizeCloneTracking(targetDir, branch);
575
+ // in-progress lock-held edits. The CONFIG is re-stamped though — it
576
+ // never touches the working tree, and a clone with drifted tracking
577
+ // or a missing credential helper is one that can't pull or push.
578
+ await this.normalizeCloneConfig(targetDir, branch);
472
579
  return;
473
580
  }
474
581
  // Half-built dir — wipe and re-clone.
@@ -492,7 +599,7 @@ export class WorkspaceService implements IWorkspaceService {
492
599
  // this branch. `git clone -b` already produces that shape; stamping it
493
600
  // explicitly means the shape is asserted rather than assumed, and the
494
601
  // same call is what repairs an existing clone that drifted.
495
- await this.normalizeCloneTracking(targetDir, branch);
602
+ await this.normalizeCloneConfig(targetDir, branch);
496
603
  console.log(
497
604
  `[workspace] Cloned ${this.kbDirName} for branch "${branch}"` +
498
605
  (reference ? ' (referenced a sibling clone)' : ''),
@@ -20,6 +20,7 @@ import { workspaceIdForBranch } from '../../shared/workspace-id.js';
20
20
  // Leaf-level shared primitive (same exception `workspace.service.ts` already
21
21
  // relies on) — not a workflow service, so this stays inside the module boundary.
22
22
  import { assertValidBranchName } from '../kb-fs/branch-name.js';
23
+ import { assertInsideRepo } from '../kb-fs/repo-path.js';
23
24
  import type { ISessionSink } from './session-sink.js';
24
25
  import type { IAccessControl } from '../access/access-control.interface.js';
25
26
  import { toKbRelative, resolveReadableMap } from '../access-model/kb-read-filter.js';
@@ -111,6 +112,22 @@ const int = (description: string): JsonSchema => ({ type: 'integer', description
111
112
 
112
113
  const str = (description: string): JsonSchema => ({ type: 'string', description });
113
114
 
115
+ /**
116
+ * A path input that names the clone folder. The tools are rooted at the
117
+ * WORKSPACE dir, one level above the git clone, so a path only reaches git
118
+ * when it starts with that folder; an agent that reads `KnowledgeBase/Foo.md`
119
+ * in a URL or a doc and passes it verbatim would otherwise write beside the
120
+ * repository. Saying so in the input itself, not only in prose, is what the
121
+ * agent actually sees when it fills the argument. `refused` is false for the
122
+ * inputs that may legitimately name a stray (a source to rescue, a file to
123
+ * remove).
124
+ */
125
+ const wsPath = (kbDirName: string, what: string, refused = true): JsonSchema =>
126
+ str(
127
+ `${what}: starts with \`${kbDirName}/\` (e.g. \`${kbDirName}/KnowledgeBase/Foo.md\`).` +
128
+ (refused ? ' A path without that prefix is outside the repository and is refused.' : ''),
129
+ );
130
+
114
131
  function asText(content: string | Buffer): string {
115
132
  return typeof content === 'string' ? content : content.toString('utf8');
116
133
  }
@@ -394,7 +411,7 @@ export function registerWorkspaceTools(
394
411
  type: 'object',
395
412
  properties: {
396
413
  branch: BRANCH_INPUT,
397
- path: str('Workspace-relative path, or a `__tool_chain_spill__/…` ref from a truncated `call_tool_chain`.'),
414
+ path: str(`Path to read, starting with \`${kbDirName}/\` (e.g. \`${kbDirName}/KnowledgeBase/Foo.md\`), or a \`__tool_chain_spill__/…\` ref from a truncated \`call_tool_chain\`.`),
398
415
  offset: int('Start character index (default 0).'),
399
416
  limit: int('Max characters to return from `offset`.'),
400
417
  sessionId: SESSION_ID_INPUT,
@@ -453,13 +470,13 @@ export function registerWorkspaceTools(
453
470
  mount({
454
471
  name: 'list_files',
455
472
  description:
456
- 'List a directory. Returns `{ path, entries: [{ name, type, size? }] }`. Omit `path` for the workspace root.' +
473
+ `List a directory. Returns \`{ path, entries: [{ name, type, size? }] }\`. Omit \`path\` for the workspace root, which holds the repository as the \`${kbDirName}/\` folder: every content path starts with it (e.g. \`${kbDirName}/KnowledgeBase\`).` +
457
474
  ONTOLOGY_BOUNDARY_NOTE,
458
475
  inputs: {
459
476
  type: 'object',
460
477
  properties: {
461
478
  branch: BRANCH_INPUT,
462
- path: str('Workspace-relative directory (default: root).'),
479
+ path: str(`Directory to list, starting with \`${kbDirName}/\` (default: the workspace root, where the repository is the \`${kbDirName}/\` folder).`),
463
480
  sessionId: SESSION_ID_INPUT,
464
481
  },
465
482
  required: ['branch'],
@@ -501,7 +518,7 @@ export function registerWorkspaceTools(
501
518
  type: 'object',
502
519
  properties: {
503
520
  branch: BRANCH_INPUT,
504
- path: str('Workspace-relative path.'),
521
+ path: wsPath(kbDirName, 'Path'),
505
522
  sessionId: SESSION_ID_INPUT,
506
523
  },
507
524
  required: ['branch', 'path'],
@@ -610,7 +627,7 @@ export function registerWorkspaceTools(
610
627
  type: 'object',
611
628
  properties: {
612
629
  branch: BRANCH_INPUT,
613
- path: str('Workspace-relative path.'),
630
+ path: wsPath(kbDirName, 'Path'),
614
631
  content: str('Full file content.'),
615
632
  sessionId: SESSION_ID_INPUT,
616
633
  },
@@ -657,7 +674,7 @@ export function registerWorkspaceTools(
657
674
  description: 'Files to write; each created or overwritten.',
658
675
  items: {
659
676
  type: 'object',
660
- properties: { path: str('Workspace-relative path.'), content: str('Full file content.') },
677
+ properties: { path: wsPath(kbDirName, 'Path'), content: str('Full file content.') },
661
678
  required: ['path', 'content'],
662
679
  additionalProperties: false,
663
680
  },
@@ -705,7 +722,7 @@ export function registerWorkspaceTools(
705
722
  type: 'object',
706
723
  properties: {
707
724
  branch: BRANCH_INPUT,
708
- path: str('Workspace-relative path.'),
725
+ path: wsPath(kbDirName, 'Path'),
709
726
  old_string: str('Exact text to replace (include enough context to be unique).'),
710
727
  new_string: str('Replacement text.'),
711
728
  replace_all: { type: 'boolean', description: 'Replace every occurrence instead of requiring a unique match.' },
@@ -750,7 +767,7 @@ export function registerWorkspaceTools(
750
767
  type: 'object',
751
768
  properties: {
752
769
  branch: BRANCH_INPUT,
753
- path: str('Workspace-relative path.'),
770
+ path: wsPath(kbDirName, 'Path to the file', false),
754
771
  sessionId: SESSION_ID_INPUT,
755
772
  },
756
773
  required: ['branch', 'path'],
@@ -781,7 +798,7 @@ export function registerWorkspaceTools(
781
798
  type: 'object',
782
799
  properties: {
783
800
  branch: BRANCH_INPUT,
784
- path: str('Workspace-relative directory.'),
801
+ path: wsPath(kbDirName, 'Directory to create'),
785
802
  sessionId: SESSION_ID_INPUT,
786
803
  },
787
804
  required: ['branch', 'path'],
@@ -808,8 +825,8 @@ export function registerWorkspaceTools(
808
825
  type: 'object',
809
826
  properties: {
810
827
  branch: BRANCH_INPUT,
811
- src: str('Source path.'),
812
- dest: str('Destination path.'),
828
+ src: wsPath(kbDirName, 'Source path', false),
829
+ dest: wsPath(kbDirName, 'Destination path'),
813
830
  sessionId: SESSION_ID_INPUT,
814
831
  },
815
832
  required: ['branch', 'src', 'dest'],
@@ -842,8 +859,8 @@ export function registerWorkspaceTools(
842
859
  type: 'object',
843
860
  properties: {
844
861
  branch: BRANCH_INPUT,
845
- src: str('Source path.'),
846
- dest: str('Destination path.'),
862
+ src: wsPath(kbDirName, 'Source path', false),
863
+ dest: wsPath(kbDirName, 'Destination path'),
847
864
  sessionId: SESSION_ID_INPUT,
848
865
  },
849
866
  required: ['branch', 'src', 'dest'],
@@ -877,8 +894,8 @@ export function registerWorkspaceTools(
877
894
  type: 'object',
878
895
  properties: {
879
896
  branch: BRANCH_INPUT,
880
- path: str('Workspace-relative .zip path.'),
881
- destination: str("Directory to extract into (default: the zip's parent)."),
897
+ path: wsPath(kbDirName, 'Path to the .zip archive', false),
898
+ destination: wsPath(kbDirName, "Directory to extract into (default: the zip's parent)"),
882
899
  sessionId: SESSION_ID_INPUT,
883
900
  },
884
901
  required: ['branch', 'path'],
@@ -916,6 +933,9 @@ export function registerWorkspaceTools(
916
933
  // extension policy applies per entry too, so a restricted run can't unzip a
917
934
  // `.md` into the graph.
918
935
  (wsRelPath) => {
936
+ // An entry that would land beside the repository is skipped with the
937
+ // corrected-path reason, like any other refused entry.
938
+ assertInsideRepo(wsRelPath, kbDirName);
919
939
  writePolicy.assertPathWritable(ctx.sessionId, wsRelPath);
920
940
  return assertOntologyWriteAllowed(sessionOntologyGate, ctx, wsRelPath);
921
941
  },