@bevel-software/platform-core-backend 0.12.0 → 0.12.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 (27) hide show
  1. package/THIRD-PARTY-NOTICES.md +4 -4
  2. package/dist/modules/kb-fs/clone-config.d.ts +40 -2
  3. package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
  4. package/dist/modules/kb-fs/clone-config.js +94 -2
  5. package/dist/modules/kb-fs/clone-config.js.map +1 -1
  6. package/dist/modules/workflow/workflow.service.d.ts +38 -0
  7. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  8. package/dist/modules/workflow/workflow.service.js +112 -6
  9. package/dist/modules/workflow/workflow.service.js.map +1 -1
  10. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
  11. package/dist/modules/workspace/startup/kb-git.js +21 -4
  12. package/dist/modules/workspace/startup/kb-git.js.map +1 -1
  13. package/dist/modules/workspace/workspace.service.d.ts +52 -8
  14. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  15. package/dist/modules/workspace/workspace.service.js +121 -23
  16. package/dist/modules/workspace/workspace.service.js.map +1 -1
  17. package/package.json +5 -5
  18. package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
  19. package/src/modules/kb-fs/clone-config.ts +97 -2
  20. package/src/modules/secrets-vault/secrets-vault.routes.ts +582 -582
  21. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
  22. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
  23. package/src/modules/workflow/workflow.service.ts +118 -6
  24. package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
  25. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
  26. package/src/modules/workspace/startup/kb-git.ts +20 -7
  27. package/src/modules/workspace/workspace.service.ts +132 -25
@@ -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
@@ -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)' : ''),