baxian 2.0.4 → 2.0.6

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 (107) hide show
  1. package/README.md +4 -3
  2. package/README.zh-CN.md +4 -3
  3. package/dist/agent/branch.d.ts.map +1 -1
  4. package/dist/agent/branch.js +7 -10
  5. package/dist/agent/branch.js.map +1 -1
  6. package/dist/agent/detect/manifest.d.ts +2 -1
  7. package/dist/agent/detect/manifest.d.ts.map +1 -1
  8. package/dist/agent/manager.d.ts.map +1 -1
  9. package/dist/agent/manager.js +49 -29
  10. package/dist/agent/manager.js.map +1 -1
  11. package/dist/agent/net-exec.d.ts +0 -1
  12. package/dist/agent/net-exec.d.ts.map +1 -1
  13. package/dist/agent/net-exec.js +1 -5
  14. package/dist/agent/net-exec.js.map +1 -1
  15. package/dist/agent/preflight.d.ts.map +1 -1
  16. package/dist/agent/preflight.js +2 -3
  17. package/dist/agent/preflight.js.map +1 -1
  18. package/dist/agent/repo-store.d.ts +9 -15
  19. package/dist/agent/repo-store.d.ts.map +1 -1
  20. package/dist/agent/repo-store.js +156 -151
  21. package/dist/agent/repo-store.js.map +1 -1
  22. package/dist/agent/tmux.d.ts +0 -5
  23. package/dist/agent/tmux.d.ts.map +1 -1
  24. package/dist/agent/tmux.js +1 -1
  25. package/dist/agent/tmux.js.map +1 -1
  26. package/dist/cli.js +3 -3
  27. package/dist/cli.js.map +1 -1
  28. package/dist/config/backup.d.ts +0 -1
  29. package/dist/config/backup.d.ts.map +1 -1
  30. package/dist/config/backup.js +27 -18
  31. package/dist/config/backup.js.map +1 -1
  32. package/dist/config/loader.d.ts +2 -5
  33. package/dist/config/loader.d.ts.map +1 -1
  34. package/dist/config/loader.js +22 -35
  35. package/dist/config/loader.js.map +1 -1
  36. package/dist/config/validator.js +1 -1
  37. package/dist/config/validator.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +9 -11
  41. package/dist/index.js.map +1 -1
  42. package/dist/lifecycle/restart-sentinel.d.ts.map +1 -1
  43. package/dist/lifecycle/restart-sentinel.js +2 -1
  44. package/dist/lifecycle/restart-sentinel.js.map +1 -1
  45. package/dist/platform/git-driver.d.ts +0 -2
  46. package/dist/platform/git-driver.d.ts.map +1 -1
  47. package/dist/platform/git-driver.js +2 -2
  48. package/dist/platform/git-driver.js.map +1 -1
  49. package/dist/platform/manifest.d.ts +0 -1
  50. package/dist/platform/manifest.d.ts.map +1 -1
  51. package/dist/platform/manifest.js +0 -12
  52. package/dist/platform/manifest.js.map +1 -1
  53. package/dist/platform/platform-poller.d.ts +2 -1
  54. package/dist/platform/platform-poller.d.ts.map +1 -1
  55. package/dist/platform/plugin-roots.d.ts +1 -1
  56. package/dist/platform/plugin-roots.d.ts.map +1 -1
  57. package/dist/platform/plugin-roots.js +2 -4
  58. package/dist/platform/plugin-roots.js.map +1 -1
  59. package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +1 -1
  60. package/dist/platform/pr-binding.d.ts +2 -1
  61. package/dist/platform/pr-binding.d.ts.map +1 -1
  62. package/dist/platform/pr-conversation-cache.d.ts +0 -5
  63. package/dist/platform/pr-conversation-cache.d.ts.map +1 -1
  64. package/dist/platform/pr-conversation-cache.js +5 -5
  65. package/dist/platform/pr-conversation-cache.js.map +1 -1
  66. package/dist/platform/startup.d.ts +1 -1
  67. package/dist/platform/startup.d.ts.map +1 -1
  68. package/dist/platform/startup.js +3 -3
  69. package/dist/platform/startup.js.map +1 -1
  70. package/dist/platform/verdict-engine.d.ts +3 -2
  71. package/dist/platform/verdict-engine.d.ts.map +1 -1
  72. package/dist/shared/constants.d.ts +1 -6
  73. package/dist/shared/constants.d.ts.map +1 -1
  74. package/dist/shared/constants.js +0 -5
  75. package/dist/shared/constants.js.map +1 -1
  76. package/dist/shared/types.d.ts +3 -3
  77. package/dist/shared/types.d.ts.map +1 -1
  78. package/dist/state/agent-store.d.ts.map +1 -1
  79. package/dist/state/agent-store.js +2 -1
  80. package/dist/state/agent-store.js.map +1 -1
  81. package/dist/state/error-record-store.d.ts.map +1 -1
  82. package/dist/state/error-record-store.js +2 -1
  83. package/dist/state/error-record-store.js.map +1 -1
  84. package/dist/state/lock.d.ts.map +1 -1
  85. package/dist/state/lock.js +2 -1
  86. package/dist/state/lock.js.map +1 -1
  87. package/dist/state/managed-path.d.ts +2 -0
  88. package/dist/state/managed-path.d.ts.map +1 -0
  89. package/dist/state/managed-path.js +11 -0
  90. package/dist/state/managed-path.js.map +1 -0
  91. package/dist/state/pet-store.d.ts.map +1 -1
  92. package/dist/state/pet-store.js +2 -1
  93. package/dist/state/pet-store.js.map +1 -1
  94. package/dist/state/process-lock.d.ts.map +1 -1
  95. package/dist/state/process-lock.js +2 -1
  96. package/dist/state/process-lock.js.map +1 -1
  97. package/dist/state/task-store.d.ts +0 -1
  98. package/dist/state/task-store.d.ts.map +1 -1
  99. package/dist/state/task-store.js +1 -16
  100. package/dist/state/task-store.js.map +1 -1
  101. package/dist/web/assets/{index-CzVzThuT.js → index-CF2C4Fq-.js} +2 -2
  102. package/dist/web/index.html +1 -1
  103. package/package.json +1 -1
  104. package/dist/agent/lineage.d.ts +0 -14
  105. package/dist/agent/lineage.d.ts.map +0 -1
  106. package/dist/agent/lineage.js +0 -33
  107. package/dist/agent/lineage.js.map +0 -1
@@ -10,7 +10,7 @@ const FETCH_THROTTLE_MS = 30_000;
10
10
  const BAXIAN_RUNTIME_DIRS = [
11
11
  '.baxian',
12
12
  ];
13
- export class BaxianRuntimeDirsError extends Error {
13
+ class BaxianRuntimeDirsError extends Error {
14
14
  reason;
15
15
  constructor(reason, message) {
16
16
  super(message);
@@ -38,156 +38,126 @@ export async function ensureBaxianRuntimeDirsSafe(runner, absRepoPath) {
38
38
  }
39
39
  const dirs = BAXIAN_RUNTIME_DIRS.map(dir => `${absRepoPath}/${dir}`);
40
40
  const operands = dirs.map(shellQuote).join(' ');
41
- const q = shellQuote(absRepoPath);
42
- const command = `[ "$(cd -- ${q} 2>/dev/null && pwd -P)" = ${q} ] || exit 9; ` +
43
- `for p in ${operands}; do ` +
44
- `if [ -L "$p" ] || { [ -e "$p" ] && [ ! -d "$p" ]; }; then exit 9; fi; ` +
45
- `done && mkdir -p ${operands} && ` +
46
- `for p in ${operands}; do [ -d "$p" ] && [ ! -L "$p" ] || exit 9; done`;
47
41
  let result;
48
42
  try {
49
- result = await runner.exec(`sh -c ${shellQuote(command)}`);
43
+ result = await runner.exec(`mkdir -p ${operands}`);
50
44
  }
51
45
  catch (err) {
52
46
  const message = err instanceof Error ? err.message : String(err);
53
- throw new BaxianRuntimeDirsError('runtime-path-probe-failed', `Could not verify .baxian runtime paths in ${absRepoPath}: ${message}`);
47
+ throw new BaxianRuntimeDirsError('runtime-path-probe-failed', `Could not prepare .baxian runtime paths in ${absRepoPath}: ${message}`);
54
48
  }
55
- const guardUnknown = execOutcomeUnknown(result);
56
- if (guardUnknown || result.exitCode !== 0) {
57
- if (result.exitCode === 9 && !guardUnknown) {
58
- throw new BaxianRuntimeDirsError('unsafe-runtime-path', `Workdir ${absRepoPath} has an unsafe .baxian runtime path; `
59
- + 'baxian requires real directories contained in the Workdir.');
60
- }
61
- throw new BaxianRuntimeDirsError('runtime-path-probe-failed', `${guardUnknown ? 'Unknown result while verifying' : 'Failed to verify'} .baxian runtime paths in `
49
+ if (execOutcomeUnknown(result) || result.exitCode !== 0) {
50
+ throw new BaxianRuntimeDirsError('runtime-path-probe-failed', `${execOutcomeUnknown(result) ? 'Unknown result while preparing' : 'Failed to prepare'} .baxian runtime paths in `
62
51
  + `${absRepoPath}: ${result.stderr || result.stdout || `exit ${result.exitCode}`}`);
63
52
  }
64
53
  }
65
- function normalizeRoot(root) {
66
- const cleaned = root.replace(/\/+$/, '');
67
- return cleaned === '' ? '/' : cleaned;
68
- }
69
- export function canonicalSelfGuard(path) {
70
- const q = shellQuote(path);
71
- return `[ "$(cd -- ${q} 2>/dev/null && pwd -P)" = ${q} ]`;
54
+ export function trashBatchDirUnder(trashRoot, reason) {
55
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
56
+ return `${trashRoot}/${stamp}-${reason}-${randomUUID().slice(0, 8)}`;
72
57
  }
73
- export function isUnder(root, path) {
74
- const r = normalizeRoot(root);
75
- return r === '/' ? path.startsWith('/') && path !== '/' : path.startsWith(`${r}/`);
58
+ // The batch sits beside its sources inside the agent tree, so mv never crosses filesystems.
59
+ export function trashBatchDir(home, agentId, reason) {
60
+ return trashBatchDirUnder(`${home}/.baxian/agents/${agentId}/.baxian-trash`, reason);
76
61
  }
77
- export function ancestorSymlinkGuard(root, target) {
78
- const rootClean = normalizeRoot(root);
79
- if (!isUnder(root, target)) {
80
- throw new Error(`ancestorSymlinkGuard: ${target} is not under ${rootClean}`);
81
- }
82
- const sliceFrom = rootClean === '/' ? rootClean.length : rootClean.length + 1;
83
- const parts = target.slice(sliceFrom).split('/').filter(Boolean);
84
- const q = shellQuote(rootClean);
85
- const checks = [`[ "$(cd -- ${q} 2>/dev/null && pwd -P)" = ${q} ]`];
86
- let cur = rootClean;
87
- for (const part of parts) {
88
- cur = cur === '/' ? `/${part}` : `${cur}/${part}`;
89
- checks.push(`[ ! -L ${shellQuote(cur)} ]`);
90
- }
91
- return checks.join(' && ');
62
+ const TRASHED = 'BX_TRASHED';
63
+ const TRASH_ABSENT = 'BX_TRASH_ABSENT';
64
+ const MV_PRECHECK = 'BX_MV_PRECHECK';
65
+ // Absent counts as success (force-remove semantics); mkdir/mv failures must surface as a non-zero exit.
66
+ function trashMoveCommand(path, batchDir) {
67
+ const p = shellQuote(path);
68
+ const b = shellQuote(batchDir);
69
+ return (`if [ ! -e ${p} ] && [ ! -L ${p} ]; then printf '%s' ${TRASH_ABSENT}; ` +
70
+ `elif mkdir -p ${b}; then ` +
71
+ `base=$(basename -- ${p}); t=${b}/"$base"; n=0; ` +
72
+ `while [ -e "$t" ] || [ -L "$t" ]; do n=$((n+1)); t=${b}/"$base.$n"; done; ` +
73
+ `mv -- ${p} "$t" && printf '%s' ${TRASHED}; ` +
74
+ `else false; ` +
75
+ `fi`);
92
76
  }
93
- export function guardedRemoveClause(root, target, opts = {}) {
94
- const rm = opts.recursive ? 'rm -rf' : 'rm -f';
95
- return `${ancestorSymlinkGuard(root, target)} && ${rm} -- ${shellQuote(target)}`;
96
- }
97
- const SWEEP_REMOVED = 'BX_SWEEP_REMOVED';
98
- const SWEEP_REFUSED = 'BX_SWEEP_REFUSED';
99
- async function sweepGuardedPath(runner, path, guardClause) {
100
- const rm = `rm -f -- ${shellQuote(path)} && printf '%s' ${shellQuote(SWEEP_REMOVED)}`;
101
- const cmd = guardClause
102
- ? `if ${guardClause}; then ${rm}; else printf '%s' ${shellQuote(SWEEP_REFUSED)}; fi`
103
- : rm;
77
+ async function sweepToTrash(runner, path, batchDir) {
104
78
  let res;
105
79
  try {
106
- res = await runner.exec(cmd);
80
+ res = await runner.exec(trashMoveCommand(path, batchDir));
107
81
  }
108
82
  catch (err) {
109
- console.warn(`[fs] sweep ${path}: outcome UNKNOWN (exec rejected) — target kept, may linger: ${err instanceof Error ? err.message : String(err)}`);
83
+ console.warn(`[fs] trash ${path}: outcome UNKNOWN (exec rejected) — target kept, may linger: ${err instanceof Error ? err.message : String(err)}`);
110
84
  return;
111
85
  }
112
- if (res.exitCode !== 0 || execOutcomeUnknown(res)) {
113
- console.warn(`[fs] sweep ${path}: outcome UNKNOWN (exit ${res.exitCode}: ${res.stderr.trim() || 'no stderr'}) — target kept, may linger`);
86
+ if (execOutcomeUnknown(res)) {
87
+ console.warn(`[fs] trash ${path}: outcome UNKNOWN (exit ${res.exitCode}: ${res.stderr.trim() || 'no stderr'}) — target kept, may linger`);
114
88
  return;
115
89
  }
116
- if (res.stdout.includes(SWEEP_REMOVED))
117
- return;
118
- if (res.stdout.includes(SWEEP_REFUSED)) {
119
- console.warn(`[fs] sweep ${path}: guard refused — target deliberately kept (rebound ancestor; not out-reaching)`);
90
+ if (res.exitCode !== 0) {
91
+ console.warn(`[fs] trash ${path}: move failed (exit ${res.exitCode}: ${res.stderr.trim() || 'no stderr'}) — target kept in place`);
120
92
  return;
121
93
  }
122
- console.warn(`[fs] sweep ${path}: rm reported failure (no marker; exit ${res.exitCode})`);
123
- }
124
- export async function sweepStrayFile(runner, path, guardClause) {
125
- await sweepGuardedPath(runner, path, guardClause);
94
+ if (res.stdout.includes(TRASH_ABSENT) || res.stdout.includes(TRASHED))
95
+ return;
96
+ console.warn(`[fs] trash ${path}: move reported failure (no marker; exit ${res.exitCode}) — target kept in place`);
126
97
  }
127
- function nestedSweepGuard(final, guardRoot) {
128
- const finalIsRealDir = `[ -d ${shellQuote(final)} ] && [ ! -L ${shellQuote(final)} ]`;
129
- return guardRoot ? `${ancestorSymlinkGuard(guardRoot, final)} && ${finalIsRealDir}` : finalIsRealDir;
98
+ export async function sweepStrayFile(runner, path, batchDir) {
99
+ await sweepToTrash(runner, path, batchDir);
130
100
  }
131
- export async function stageFileGuarded(runner, root, tmp, content, opts = {}) {
101
+ export async function stageFile(runner, tmp, content, opts) {
132
102
  const buf = typeof content === 'string' ? Buffer.from(content, 'utf8') : content;
133
- const guard = ancestorSymlinkGuard(root, tmp);
134
103
  const dir = tmp.slice(0, tmp.lastIndexOf('/'));
135
104
  const privateCreate = opts.mode === undefined ? '' : 'umask 077 && ';
136
105
  const enforceMode = opts.mode === undefined
137
106
  ? ''
138
107
  : ` && chmod ${opts.mode.toString(8)} ${shellQuote(tmp)}`;
139
- const cmd = `${guard} && mkdir -p ${shellQuote(dir)} && ${privateCreate}cat > ${shellQuote(tmp)}${enforceMode}`;
108
+ const cmd = `mkdir -p ${shellQuote(dir)} && ${privateCreate}cat > ${shellQuote(tmp)}${enforceMode}`;
140
109
  let res;
141
110
  try {
142
111
  res = await runner.execWithStdin(cmd, buf);
143
112
  }
144
113
  catch (err) {
145
- await sweepStrayFile(runner, tmp, guard);
114
+ await sweepStrayFile(runner, tmp, opts.trashDir);
146
115
  throw new ExecOutcomeUnknownError(`staged write of ${tmp} failed (exec layer): ${err instanceof Error ? err.message : String(err)}`);
147
116
  }
148
117
  if (execOutcomeUnknown(res)) {
149
- await sweepStrayFile(runner, tmp, guard);
118
+ await sweepStrayFile(runner, tmp, opts.trashDir);
150
119
  throw new ExecOutcomeUnknownError(`staged write of ${tmp} outcome unknown (exit ${res.exitCode}): ${res.stderr.trim() || 'no stderr'}`);
151
120
  }
152
121
  if (res.exitCode !== 0) {
153
- await sweepStrayFile(runner, tmp, guard);
154
- throw new Error(`staged write of ${tmp} failed (exit ${res.exitCode}): ${res.stderr.trim() || 'symlink-free path required'}`);
122
+ await sweepStrayFile(runner, tmp, opts.trashDir);
123
+ throw new Error(`staged write of ${tmp} failed (exit ${res.exitCode}): ${res.stderr.trim() || 'no stderr'}`);
155
124
  }
156
125
  }
157
- export async function moveFileIntoPlace(runner, tmp, final, opts = {}) {
126
+ export async function moveFileIntoPlace(runner, tmp, final, opts) {
158
127
  const tmpBase = tmp.slice(tmp.lastIndexOf('/') + 1);
128
+ // mv -f onto a directory target nests instead of replacing: fail closed ahead, verify a plain file landed after.
159
129
  const clauses = [
160
- ...(opts.guardRoot ? [ancestorSymlinkGuard(opts.guardRoot, final)] : []),
161
130
  `[ -f ${shellQuote(tmp)} ]`,
162
131
  `[ ! -L ${shellQuote(tmp)} ]`,
163
132
  `[ ! -d ${shellQuote(final)} ]`,
133
+ `printf '%s' ${MV_PRECHECK}`,
164
134
  `mv -f -- ${shellQuote(tmp)} ${shellQuote(final)}`,
165
135
  `[ -f ${shellQuote(final)} ]`,
166
136
  `[ ! -L ${shellQuote(final)} ]`,
167
137
  ];
168
- const guardCovers = (p) => opts.guardRoot !== undefined && isUnder(opts.guardRoot, p);
169
- const tmpGuard = guardCovers(tmp) ? ancestorSymlinkGuard(opts.guardRoot, tmp) : undefined;
170
- const nestedGuard = nestedSweepGuard(final, guardCovers(final) ? opts.guardRoot : undefined);
171
138
  const nested = `${final}/${tmpBase}`;
172
- const sweepBoth = async () => {
173
- await sweepGuardedPath(runner, tmp, tmpGuard);
174
- await sweepGuardedPath(runner, nested, nestedGuard);
139
+ // The nested path resolves THROUGH a symlink final; sweep it only when the precheck marker proves mv actually ran, else an unrelated same-named file behind the link would be dragged into trash.
140
+ const sweepFailure = async (mvMayHaveRun) => {
141
+ await sweepToTrash(runner, tmp, opts.trashDir);
142
+ if (mvMayHaveRun)
143
+ await sweepToTrash(runner, nested, opts.trashDir);
175
144
  };
176
145
  let res;
177
146
  try {
178
147
  res = await runner.exec(clauses.join(' && '));
179
148
  }
180
149
  catch (err) {
181
- await sweepBoth();
150
+ await sweepFailure(false);
182
151
  throw new ExecOutcomeUnknownError(`atomic replace of ${final} failed (exec layer): ${err instanceof Error ? err.message : String(err)}`);
183
152
  }
153
+ const mvMayHaveRun = res.stdout.includes(MV_PRECHECK);
184
154
  if (execOutcomeUnknown(res)) {
185
- await sweepBoth();
155
+ await sweepFailure(mvMayHaveRun);
186
156
  throw new ExecOutcomeUnknownError(`atomic replace of ${final} outcome unknown (exit ${res.exitCode}): ${res.stderr.trim() || 'no stderr'}`);
187
157
  }
188
158
  if (res.exitCode !== 0) {
189
- await sweepBoth();
190
- throw new Error(`atomic replace of ${final} failed (exit ${res.exitCode}): ${res.stderr.trim() || 'target must be a plain file with a symlink-free path'}`);
159
+ await sweepFailure(mvMayHaveRun);
160
+ throw new Error(`atomic replace of ${final} failed (exit ${res.exitCode}): ${res.stderr.trim() || 'target must be a plain file'}`);
191
161
  }
192
162
  }
193
163
  export class RepoStore {
@@ -301,16 +271,14 @@ export class RepoStore {
301
271
  throw new Error(`Configured Workdir ${absRepoPath} does not exist; ` +
302
272
  `baxian never creates a user-specified Workdir implicitly.`);
303
273
  }
304
- const guardRoot = await this.resolveHome();
305
274
  const parent = absRepoPath.replace(/\/[^/]+$/, '');
306
275
  const staging = `${absRepoPath}.claim-${randomUUID()}`;
307
- const stagingGuard = ancestorSymlinkGuard(guardRoot, staging);
308
- const mk = await this.runner.exec(`${stagingGuard} && mkdir -p ${shellQuote(parent)}`);
276
+ const mk = await this.runner.exec(`mkdir -p ${shellQuote(parent)}`);
309
277
  if (mk.exitCode !== 0)
310
- throw new Error(`Failed to mkdir ${parent} (symlink-safe): ${mk.stderr}`);
278
+ throw new Error(`Failed to mkdir ${parent}: ${mk.stderr}`);
311
279
  const cloneCmd = this.cloneWithGh
312
- ? `${stagingGuard} && ${this.ghCloneHostEnv()}${GIT_NET_ENV} gh repo clone ${shellQuote(repoSlug(this.repo))} ${shellQuote(staging)} --no-upstream`
313
- : `${stagingGuard} && ${GIT_NET_ENV} git clone ${shellQuote(this.repo)} ${shellQuote(staging)}`;
280
+ ? `${this.ghCloneHostEnv()}${GIT_NET_ENV} gh repo clone ${shellQuote(repoSlug(this.repo))} ${shellQuote(staging)} --no-upstream`
281
+ : `${GIT_NET_ENV} git clone ${shellQuote(this.repo)} ${shellQuote(staging)}`;
314
282
  let clone;
315
283
  try {
316
284
  clone = await execNetwork(this.runner, cloneCmd, {
@@ -319,15 +287,15 @@ export class RepoStore {
319
287
  });
320
288
  }
321
289
  catch (err) {
322
- await this.discardStaging(staging, err instanceof Error ? err.message : String(err), ancestorSymlinkGuard(guardRoot, staging));
290
+ await this.trashStaging(staging, err instanceof Error ? err.message : String(err));
323
291
  throw err;
324
292
  }
325
293
  if (clone.exitCode !== 0) {
326
- await this.discardStaging(staging, `clone exit ${clone.exitCode}`, ancestorSymlinkGuard(guardRoot, staging));
294
+ await this.trashStaging(staging, `clone exit ${clone.exitCode}`);
327
295
  const cmd = this.cloneWithGh ? 'gh repo clone' : 'git clone';
328
296
  throw new Error(redactGitCredentials(`${cmd} ${this.repo} failed: ${clone.stderr || clone.stdout}`));
329
297
  }
330
- await this.promoteStaging(staging, absRepoPath, guardRoot);
298
+ await this.promoteStaging(staging, absRepoPath);
331
299
  if (this.isGitHub && parseGitRemote(this.repo) !== null) {
332
300
  return this.syncMatchingOriginUrl(absRepoPath);
333
301
  }
@@ -376,7 +344,11 @@ export class RepoStore {
376
344
  throw new Error(`${absRepoPath} is not a working-tree Git repository.`);
377
345
  }
378
346
  const canonicalTop = await probe(`cd ${shellQuote(top.stdout.trim())} && pwd -P`, 'canonical top');
379
- if (canonicalTop.exitCode !== 0 || canonicalTop.stdout.trim() !== absRepoPath) {
347
+ // Physical-to-physical: the auto Workdir path may route through symlinks (e.g. a relocated ~/.baxian/agents), so the logical path itself never matches the toplevel.
348
+ const canonicalWorkdir = await probe(`cd ${path} && pwd -P`, 'canonical workdir');
349
+ if (canonicalTop.exitCode !== 0 || canonicalWorkdir.exitCode !== 0
350
+ || canonicalTop.stdout.trim() === ''
351
+ || canonicalTop.stdout.trim() !== canonicalWorkdir.stdout.trim()) {
380
352
  throw new Error(`Workdir ${absRepoPath} must be the repository's exact top-level directory, not a subdirectory.`);
381
353
  }
382
354
  const bare = await probe(`git -C ${path} rev-parse --is-bare-repository`, 'bare flag');
@@ -409,11 +381,15 @@ export class RepoStore {
409
381
  }
410
382
  async ensureBaxianExcluded(absRepoPath) {
411
383
  const path = shellQuote(absRepoPath);
412
- const command = `${canonicalSelfGuard(absRepoPath)} && cd ${path} && p="$(git rev-parse --git-path info/exclude)" && ` +
384
+ // Leading slash anchors to the repo root (an unanchored `.baxian` would hide sub/.baxian anywhere); no trailing slash so a user-planted symlink .baxian is still covered.
385
+ const command = `cd ${path} && p="$(git rev-parse --git-path info/exclude)" && ` +
413
386
  `mkdir -p "$(dirname "$p")" && ` +
414
- `{ grep -qxF '.baxian/' "$p" 2>/dev/null || printf '%s\\n' '.baxian/' >> "$p"; } && ` +
415
- `git check-ignore -q -- .baxian/__probe__`;
387
+ `{ grep -qxF '/.baxian' "$p" 2>/dev/null || printf '%s\\n' '/.baxian' >> "$p"; } && ` +
388
+ `git check-ignore -q -- .baxian`;
416
389
  const excluded = await this.runner.exec(`sh -c ${shellQuote(command)}`);
390
+ if (execOutcomeUnknown(excluded)) {
391
+ throw new ExecOutcomeUnknownError(`.baxian exclude probe in ${absRepoPath} outcome unknown (exit ${excluded.exitCode}): ${excluded.stderr.trim() || 'no stderr'}`);
392
+ }
417
393
  if (excluded.exitCode !== 0) {
418
394
  throw new Error(`Failed to make .baxian runtime files invisible to Git in ${absRepoPath}.`);
419
395
  }
@@ -436,12 +412,11 @@ export class RepoStore {
436
412
  return false;
437
413
  if (!accessMethodDiffers(this.repo, originUrl))
438
414
  return false;
439
- const guard = canonicalSelfGuard(absRepoPath);
440
- const unset = await this.probe(`${guard} && git -C ${shellQuote(absRepoPath)} config --unset-all remote.origin.pushurl`, `remote.origin.pushurl removal at ${absRepoPath}`);
415
+ const unset = await this.probe(`git -C ${shellQuote(absRepoPath)} config --unset-all remote.origin.pushurl`, `remote.origin.pushurl removal at ${absRepoPath}`);
441
416
  if (unset.exitCode !== 0 && unset.exitCode !== 5) {
442
417
  throw new Error(redactGitCredentials(`Failed to clear remote.origin.pushurl at ${absRepoPath} (exit ${unset.exitCode}): ${unset.stderr.trim()}`));
443
418
  }
444
- const result = await this.runner.exec(`${guard} && git -C ${shellQuote(absRepoPath)} config --replace-all remote.origin.url ${shellQuote(this.repo)}`);
419
+ const result = await this.runner.exec(`git -C ${shellQuote(absRepoPath)} config --replace-all remote.origin.url ${shellQuote(this.repo)}`);
445
420
  if (result.exitCode !== 0) {
446
421
  throw new Error(redactGitCredentials(`Failed to update origin URL at ${absRepoPath}: ${result.stderr}`));
447
422
  }
@@ -463,91 +438,113 @@ export class RepoStore {
463
438
  return false;
464
439
  throw new Error(`Cannot probe workdir ${absRepoPath} (exit ${probe.exitCode}): ${probe.stderr.trim() || 'exec layer failure'}`);
465
440
  }
466
- async discardStaging(staging, reason, guardClause) {
467
- console.warn(`[repo-store] removing staged clone ${staging}: ${redactGitCredentials(reason)}`);
468
- const rmCmd = guardClause
469
- ? `if ${guardClause}; then rm -rf ${shellQuote(staging)} && echo BX_STAGING_REMOVED; else echo BX_STAGING_REFUSED; fi`
470
- : `rm -rf ${shellQuote(staging)} && echo BX_STAGING_REMOVED`;
441
+ async trashDir(reason) {
442
+ return trashBatchDir(await this.resolveHome(), this.agentId, reason);
443
+ }
444
+ async trashStaging(staging, reason) {
445
+ console.warn(`[repo-store] trashing staged clone ${staging}: ${redactGitCredentials(reason)}`);
446
+ let batchDir;
447
+ try {
448
+ batchDir = await this.trashDir('staging');
449
+ }
450
+ catch (err) {
451
+ console.warn(`[repo-store] cannot resolve trash dir for staged clone ${staging}; kept in place:`, err);
452
+ return;
453
+ }
471
454
  try {
472
- const rm = await this.runner.exec(rmCmd);
473
- if (rm.exitCode !== 0) {
474
- console.warn(`[repo-store] failed to remove staged clone ${staging}: ${rm.stderr.trim() || `exit ${rm.exitCode}`}`);
455
+ const moved = await this.runner.exec(trashMoveCommand(staging, batchDir));
456
+ if (execOutcomeUnknown(moved)) {
457
+ console.warn(`[repo-store] staged clone ${staging} trash outcome UNKNOWN (exit ${moved.exitCode}: ${moved.stderr.trim() || 'no stderr'}); debris may remain`);
475
458
  }
476
- else if (rm.stdout.includes('BX_STAGING_REFUSED')) {
477
- console.warn(`[repo-store] staged clone ${staging} NOT removed: ancestor guard refused (rebound path); debris may remain`);
459
+ else if (moved.exitCode !== 0) {
460
+ console.warn(`[repo-store] failed to trash staged clone ${staging}: ${moved.stderr.trim() || `exit ${moved.exitCode}`}`);
478
461
  }
479
- else if (!rm.stdout.includes('BX_STAGING_REMOVED')) {
480
- console.warn(`[repo-store] staged clone ${staging} removal outcome unknown (no marker); debris may remain`);
462
+ else if (!moved.stdout.includes(TRASHED) && !moved.stdout.includes(TRASH_ABSENT)) {
463
+ console.warn(`[repo-store] staged clone ${staging} trash outcome unknown (no marker); debris may remain`);
481
464
  }
482
465
  }
483
466
  catch (err) {
484
- console.warn(`[repo-store] failed to remove staged clone ${staging}:`, err);
467
+ console.warn(`[repo-store] failed to trash staged clone ${staging}:`, err);
485
468
  }
486
469
  }
487
- async promoteStaging(staging, absRepoPath, guardRoot) {
470
+ async promoteStaging(staging, absRepoPath) {
488
471
  const base = staging.slice(staging.lastIndexOf('/') + 1);
489
472
  const nestedPath = `${absRepoPath}/${base}`;
490
- const stagingGuard = ancestorSymlinkGuard(guardRoot, staging);
491
- const nestedGuard = ancestorSymlinkGuard(guardRoot, nestedPath);
492
- const discardBoth = async (reason) => {
493
- await this.discardStaging(staging, reason, stagingGuard);
494
- await this.discardStaging(nestedPath, reason, nestedGuard);
473
+ const trashBoth = async (reason) => {
474
+ await this.trashStaging(staging, reason);
475
+ await this.trashStaging(nestedPath, reason);
495
476
  };
496
477
  const nonce = randomUUID();
497
478
  const claimRel = '.git/baxian-promote-claim';
498
479
  const stagingClaim = `${staging}/${claimRel}`;
499
480
  const finalClaim = `${absRepoPath}/${claimRel}`;
500
- const markGuard = ancestorSymlinkGuard(guardRoot, staging);
501
- const mark = await this.runner.exec(`${markGuard} && printf %s ${shellQuote(nonce)} > ${shellQuote(stagingClaim)}`);
481
+ const mark = await this.runner.exec(`printf %s ${shellQuote(nonce)} > ${shellQuote(stagingClaim)}`);
482
+ if (execOutcomeUnknown(mark)) {
483
+ await this.trashStaging(staging, `promote-claim write outcome unknown: ${mark.stderr.trim() || `exit ${mark.exitCode}`}`);
484
+ throw new ExecOutcomeUnknownError(`Promote-claim write for ${staging} outcome unknown (exit ${mark.exitCode}): ${mark.stderr.trim() || 'no stderr'}`);
485
+ }
502
486
  if (mark.exitCode !== 0) {
503
- await this.discardStaging(staging, `promote-claim write failed: ${mark.stderr.trim() || `exit ${mark.exitCode}`}`, markGuard);
487
+ await this.trashStaging(staging, `promote-claim write failed: ${mark.stderr.trim() || `exit ${mark.exitCode}`}`);
504
488
  throw new Error(`Cannot stamp staged clone ${staging}: ${mark.stderr.trim() || `exit ${mark.exitCode}`}`);
505
489
  }
506
- const promoteCmd = `${ancestorSymlinkGuard(guardRoot, absRepoPath)} && ` +
507
- `[ ! -e ${shellQuote(absRepoPath)} ] && [ ! -L ${shellQuote(absRepoPath)} ] && ` +
508
- `mv ${shellQuote(staging)} ${shellQuote(absRepoPath)}`;
490
+ const promoteCmd = `[ ! -e ${shellQuote(absRepoPath)} ] && [ ! -L ${shellQuote(absRepoPath)} ] && ` +
491
+ `printf '%s' ${MV_PRECHECK} && mv ${shellQuote(staging)} ${shellQuote(absRepoPath)}`;
509
492
  let promote;
510
493
  try {
511
494
  promote = await this.runner.exec(promoteCmd);
512
495
  }
513
496
  catch (err) {
514
- await this.reconcilePromote(staging, absRepoPath, nestedPath, finalClaim, nonce, guardRoot, `mv exec rejected: ${err instanceof Error ? err.message : String(err)}`);
497
+ await this.reconcilePromote(staging, absRepoPath, nestedPath, finalClaim, nonce, `mv exec rejected: ${err instanceof Error ? err.message : String(err)}`);
498
+ return;
499
+ }
500
+ // Unknown outrank exit 0: a transient-tainted reply proves nothing about whether mv ran.
501
+ if (execOutcomeUnknown(promote)) {
502
+ await this.reconcilePromote(staging, absRepoPath, nestedPath, finalClaim, nonce, `mv outcome unknown (exit ${promote.exitCode}): ${promote.stderr.trim()}`);
515
503
  return;
516
504
  }
517
505
  if (promote.exitCode === 0) {
518
506
  const nested = await this.runner.exec(`test -e ${shellQuote(nestedPath)}`);
519
- if (execOutcomeUnknown(nested) || (nested.exitCode !== 0 && nested.exitCode !== 1)) {
507
+ if (execOutcomeUnknown(nested)) {
508
+ throw new Error(`Cannot verify promoted clone at ${absRepoPath}: nested probe outcome unknown (exit ${nested.exitCode}): ${nested.stderr.trim() || 'no stderr'}; nothing moved.`);
509
+ }
510
+ if (nested.exitCode !== 0 && nested.exitCode !== 1) {
520
511
  const reason = `promote verification failed (exit ${nested.exitCode}): ${nested.stderr.trim() || 'exec layer failure'}`;
521
- await discardBoth(reason);
512
+ await trashBoth(reason);
522
513
  throw new Error(`Cannot verify promoted clone at ${absRepoPath}; ${reason}`);
523
514
  }
524
515
  if (nested.exitCode === 1) {
525
- const cleared = await this.clearPromoteClaim(finalClaim, ancestorSymlinkGuard(guardRoot, finalClaim), nonce);
516
+ const cleared = await this.clearPromoteClaim(finalClaim, nonce);
526
517
  if (cleared !== 'removed') {
527
518
  throw new Error(`Promote to ${absRepoPath} landed but its ownership marker was ${cleared} (replaced/foreign or unverifiable); refusing to adopt. Retry DELETE/clone.`);
528
519
  }
529
520
  return;
530
521
  }
531
- await this.discardStaging(nestedPath, `final path ${absRepoPath} was recreated concurrently`, nestedGuard);
522
+ await this.trashStaging(nestedPath, `final path ${absRepoPath} was recreated concurrently`);
532
523
  throw new Error(`Final path ${absRepoPath} was recreated while promoting; staged clone withdrawn.`);
533
524
  }
534
- if (execOutcomeUnknown(promote)) {
535
- await this.reconcilePromote(staging, absRepoPath, nestedPath, finalClaim, nonce, guardRoot, `mv outcome unknown (exit ${promote.exitCode}): ${promote.stderr.trim()}`);
536
- return;
525
+ // Without the precheck marker mv never started, so final/<base> cannot be ours — only the staging we provably own is withdrawn.
526
+ if (promote.stdout.includes(MV_PRECHECK)) {
527
+ await trashBoth(`promote failed: ${promote.stderr.trim() || `exit ${promote.exitCode}`}`);
528
+ }
529
+ else {
530
+ await this.trashStaging(staging, `promote failed before mv: ${promote.stderr.trim() || `exit ${promote.exitCode}`}`);
537
531
  }
538
- await discardBoth(`promote failed: ${promote.stderr.trim() || `exit ${promote.exitCode}`}`);
539
532
  throw new Error(`Cannot promote staged clone into ${absRepoPath}: ${promote.stderr.trim() || `exit ${promote.exitCode}`}`);
540
533
  }
541
- async reconcilePromote(staging, absRepoPath, nestedPath, finalClaim, nonce, guardRoot, cause) {
534
+ async reconcilePromote(staging, absRepoPath, nestedPath, finalClaim, nonce, cause) {
535
+ // Reconciliation probes are remote execs too: an unknown probe must never drive a move.
542
536
  const stagingExists = await this.runner.exec(`test -e ${shellQuote(staging)}`);
537
+ if (execOutcomeUnknown(stagingExists)) {
538
+ throw new Error(`Cannot reconcile promote to ${absRepoPath}: staging probe outcome unknown (exit ${stagingExists.exitCode}): ${stagingExists.stderr.trim() || 'no stderr'}; nothing moved. ${cause}`);
539
+ }
543
540
  if (stagingExists.exitCode === 0) {
544
- await this.discardStaging(staging, `mv not executed (${cause})`, ancestorSymlinkGuard(guardRoot, staging));
541
+ await this.trashStaging(staging, `mv not executed (${cause})`);
545
542
  throw new Error(`Promote to ${absRepoPath} not executed; staged clone withdrawn. ${cause}`);
546
543
  }
547
544
  if (stagingExists.exitCode === 1) {
548
545
  const marker = await this.runner.exec(`cat ${shellQuote(finalClaim)} 2>/dev/null`);
549
546
  if (marker.exitCode === 0 && marker.stdout.trim() === nonce) {
550
- const cleared = await this.clearPromoteClaim(finalClaim, ancestorSymlinkGuard(guardRoot, finalClaim), nonce);
547
+ const cleared = await this.clearPromoteClaim(finalClaim, nonce);
551
548
  if (cleared !== 'removed') {
552
549
  throw new Error(`Promote to ${absRepoPath} reconciliation: ownership marker was ${cleared} (final replaced after the nonce read); refusing to adopt. ${cause}`);
553
550
  }
@@ -555,27 +552,36 @@ export class RepoStore {
555
552
  return;
556
553
  }
557
554
  const nested = await this.runner.exec(`test -e ${shellQuote(nestedPath)}`);
555
+ if (execOutcomeUnknown(nested)) {
556
+ throw new Error(`Cannot reconcile promote to ${absRepoPath}: nested probe outcome unknown (exit ${nested.exitCode}): ${nested.stderr.trim() || 'no stderr'}; nothing moved. ${cause}`);
557
+ }
558
558
  if (nested.exitCode === 0) {
559
- await this.discardStaging(nestedPath, `mv nested on target race (${cause})`, ancestorSymlinkGuard(guardRoot, nestedPath));
559
+ await this.trashStaging(nestedPath, `mv nested on target race (${cause})`);
560
560
  throw new Error(`Promote to ${absRepoPath} nested on a target race; staged clone withdrawn. ${cause}`);
561
561
  }
562
562
  }
563
563
  console.warn(`[repo-store] promote to ${absRepoPath} outcome UNKNOWN — staging/final/nested all inconclusive, nothing removed (${cause})`);
564
564
  throw new Error(`Cannot reconcile promote to ${absRepoPath}; all locations inconclusive. ${cause}`);
565
565
  }
566
- async clearPromoteClaim(finalClaim, guard, nonce) {
566
+ async clearPromoteClaim(finalClaim, nonce) {
567
567
  try {
568
- const rm = await this.runner.exec(`if ${guard} && [ "$(cat ${shellQuote(finalClaim)} 2>/dev/null)" = ${shellQuote(nonce)} ]; ` +
569
- `then rm -f ${shellQuote(finalClaim)} && echo BX_MARKER_REMOVED; else echo BX_MARKER_REFUSED; fi`);
570
- if (rm.exitCode !== 0) {
571
- console.warn(`[repo-store] failed to clear promote-claim marker ${finalClaim}: ${rm.stderr.trim() || `exit ${rm.exitCode}`}`);
568
+ const batchDir = await this.trashDir('claim');
569
+ // Nonce re-check and mv in ONE command (a separate cat→mv could displace a replaced foreign marker); the echoed marker surfaces a nonce-refused clear that the `if`'s exit 0 would otherwise mask.
570
+ const moved = await this.runner.exec(`if [ "$(cat ${shellQuote(finalClaim)} 2>/dev/null)" = ${shellQuote(nonce)} ]; ` +
571
+ `then mkdir -p ${shellQuote(batchDir)} && mv -- ${shellQuote(finalClaim)} ${shellQuote(batchDir)}/ && echo BX_MARKER_TRASHED; else echo BX_MARKER_REFUSED; fi`);
572
+ if (moved.exitCode !== 0 || execOutcomeUnknown(moved)) {
573
+ console.warn(`[repo-store] failed to clear promote-claim marker ${finalClaim}: ${moved.stderr.trim() || `exit ${moved.exitCode}`}`);
572
574
  return 'failed';
573
575
  }
574
- if (rm.stdout.includes('BX_MARKER_REFUSED')) {
575
- console.warn(`[repo-store] promote-claim marker ${finalClaim} NOT cleared: guard/nonce mismatch (foreign or rebound); left in place`);
576
+ if (moved.stdout.includes('BX_MARKER_REFUSED')) {
577
+ console.warn(`[repo-store] promote-claim marker ${finalClaim} NOT cleared: nonce mismatch (foreign clone); left in place`);
576
578
  return 'refused';
577
579
  }
578
- return 'removed';
580
+ // Only an explicit TRASHED marker proves the move; a bare exit 0 (empty or truncated stdout) is not a positive outcome under the three-state discipline.
581
+ if (moved.stdout.includes('BX_MARKER_TRASHED'))
582
+ return 'removed';
583
+ console.warn(`[repo-store] promote-claim marker ${finalClaim} clear reported no outcome marker; treating as failed`);
584
+ return 'failed';
579
585
  }
580
586
  catch (err) {
581
587
  console.warn(`[repo-store] failed to clear promote-claim marker ${finalClaim}:`, err);
@@ -585,8 +591,7 @@ export class RepoStore {
585
591
  async recoverEmptyLeftoverDir(absRepoPath) {
586
592
  if (this.configuredWorkdir)
587
593
  return false;
588
- const guardRoot = await this.resolveHome();
589
- const undo = await this.probe(`${ancestorSymlinkGuard(guardRoot, absRepoPath)} && rmdir ${shellQuote(absRepoPath)}`, `leftover dir ${absRepoPath}`);
594
+ const undo = await this.probe(`rmdir ${shellQuote(absRepoPath)}`, `leftover dir ${absRepoPath}`);
590
595
  if (undo.exitCode === 0) {
591
596
  console.warn(`[repo-store] removed leftover empty dir ${absRepoPath}; retrying clone`);
592
597
  return true;
@@ -597,7 +602,7 @@ export class RepoStore {
597
602
  const last = this.cache.lastFetchAt.get(cacheKey) ?? 0;
598
603
  if (Date.now() - last < FETCH_THROTTLE_MS)
599
604
  return;
600
- const result = await execNetwork(this.runner, `${canonicalSelfGuard(absRepoPath)} && cd ${shellQuote(absRepoPath)} && ${GIT_NET_ENV} git fetch --all --prune && git remote set-head origin --auto`);
605
+ const result = await execNetwork(this.runner, `cd ${shellQuote(absRepoPath)} && ${GIT_NET_ENV} git fetch --all --prune && git remote set-head origin --auto`);
601
606
  if (result.exitCode !== 0) {
602
607
  throw new Error(`git fetch failed or origin/HEAD could not be refreshed at ${absRepoPath}: ${result.stderr}`);
603
608
  }