@loucompanion/forge-bridge 0.1.3-dev.d1ca67076aa9 → 0.1.3-dev.e7b62d8fdd33

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 (170) hide show
  1. package/README.md +127 -0
  2. package/dist/bridge/bridge.base.d.ts +8 -1
  3. package/dist/bridge/bridge.service.d.ts +93 -3
  4. package/dist/bridge/bridge.service.js +1358 -47
  5. package/dist/bridge/bridge.types.d.ts +202 -1
  6. package/dist/bridge/runner-host-capabilities.d.ts +15 -0
  7. package/dist/bridge/runner-host-capabilities.js +93 -0
  8. package/dist/cleanup/cleanup.service.d.ts +14 -2
  9. package/dist/cleanup/cleanup.service.js +496 -3
  10. package/dist/cleanup/cleanup.types.d.ts +10 -0
  11. package/dist/cleanup/index.d.ts +1 -1
  12. package/dist/cleanup/index.js +1 -1
  13. package/dist/cleanup/internals.d.ts +2 -2
  14. package/dist/cleanup/internals.js +1 -1
  15. package/dist/cli/cli.help.js +47 -1
  16. package/dist/cli/cli.service.d.ts +45 -2
  17. package/dist/cli/cli.service.js +378 -26
  18. package/dist/cli/local-config.d.ts +7 -0
  19. package/dist/cli/local-config.js +51 -3
  20. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  21. package/dist/runtime/aspnet-runtime-prebuild.js +340 -0
  22. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  23. package/dist/runtime/attempt-node-runtime-launcher.js +105 -0
  24. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  25. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  26. package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
  27. package/dist/runtime/attempt-process-supervisor.js +386 -0
  28. package/dist/runtime/docker-engine-probe.d.ts +83 -0
  29. package/dist/runtime/docker-engine-probe.js +300 -0
  30. package/dist/runtime/generated-output-reset.d.ts +10 -0
  31. package/dist/runtime/generated-output-reset.js +103 -0
  32. package/dist/runtime/host-listener-inspector.d.ts +46 -0
  33. package/dist/runtime/host-listener-inspector.js +349 -0
  34. package/dist/runtime/host-port-broker.d.ts +76 -0
  35. package/dist/runtime/host-port-broker.js +333 -0
  36. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  37. package/dist/runtime/host-runtime-contract.js +700 -0
  38. package/dist/runtime/host-runtime-lease.d.ts +197 -0
  39. package/dist/runtime/host-runtime-lease.js +2203 -0
  40. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  41. package/dist/runtime/host-runtime-promotion.js +159 -0
  42. package/dist/runtime/memory-capacity.d.ts +68 -0
  43. package/dist/runtime/memory-capacity.js +121 -0
  44. package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
  45. package/dist/runtime/node-host-runtime-adapter.js +284 -0
  46. package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
  47. package/dist/runtime/playwright-host-runtime-adapter.js +231 -0
  48. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  49. package/dist/runtime/runtime-isolation-lease.js +158 -0
  50. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  51. package/dist/runtime/workspace-resource-estimator.js +174 -0
  52. package/dist/sandbox/command-runner.d.ts +4 -0
  53. package/dist/sandbox/command-runner.js +47 -0
  54. package/dist/sandbox/contract.d.ts +5 -0
  55. package/dist/sandbox/contract.js +276 -0
  56. package/dist/sandbox/event-budget.d.ts +11 -0
  57. package/dist/sandbox/event-budget.js +27 -0
  58. package/dist/sandbox/image-policy.d.ts +35 -0
  59. package/dist/sandbox/image-policy.js +247 -0
  60. package/dist/sandbox/index.d.ts +5 -0
  61. package/dist/sandbox/index.js +5 -0
  62. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  63. package/dist/sandbox/lease-watchdog.js +67 -0
  64. package/dist/sandbox/preflight.d.ts +84 -0
  65. package/dist/sandbox/preflight.js +521 -0
  66. package/dist/sandbox/process.d.ts +20 -0
  67. package/dist/sandbox/process.js +209 -0
  68. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  69. package/dist/sandbox/provider-auth/codex.js +212 -0
  70. package/dist/sandbox/repository-lock.d.ts +5 -0
  71. package/dist/sandbox/repository-lock.js +121 -0
  72. package/dist/sandbox/runtime.d.ts +297 -0
  73. package/dist/sandbox/runtime.js +2338 -0
  74. package/dist/sandbox/sandbox.types.d.ts +93 -0
  75. package/dist/sandbox/sandbox.types.js +1 -0
  76. package/dist/sandbox/smoke.d.ts +76 -0
  77. package/dist/sandbox/smoke.js +380 -0
  78. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  79. package/dist/sandbox/worker-supervisor.js +36 -0
  80. package/dist/sandbox/worker.d.ts +1 -0
  81. package/dist/sandbox/worker.js +225 -0
  82. package/dist/sandbox/workspace.service.d.ts +13 -0
  83. package/dist/sandbox/workspace.service.js +72 -0
  84. package/dist/session/managed-attempt-tools.d.ts +21 -0
  85. package/dist/session/managed-attempt-tools.js +912 -0
  86. package/dist/session/managed-build-coordinator.d.ts +5 -0
  87. package/dist/session/managed-build-coordinator.js +105 -0
  88. package/dist/session/managed-build-receipt.d.ts +21 -0
  89. package/dist/session/managed-build-receipt.js +335 -0
  90. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  91. package/dist/session/phase-completion.artifacts.js +256 -26
  92. package/dist/session/phase-completion.d.ts +12 -2
  93. package/dist/session/phase-completion.helpers.d.ts +5 -0
  94. package/dist/session/phase-completion.helpers.js +51 -10
  95. package/dist/session/phase-completion.js +14 -3
  96. package/dist/session/phase-completion.prompts.d.ts +3 -3
  97. package/dist/session/phase-completion.prompts.js +24 -8
  98. package/dist/session/phase-completion.runtime.d.ts +13 -1
  99. package/dist/session/phase-completion.runtime.js +189 -8
  100. package/dist/session/phase-completion.schemas.js +117 -4
  101. package/dist/session/phase-completion.validation.d.ts +4 -1
  102. package/dist/session/phase-completion.validation.js +153 -22
  103. package/dist/session/pre-publication-validation.d.ts +46 -0
  104. package/dist/session/pre-publication-validation.js +208 -0
  105. package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
  106. package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
  107. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +3 -2
  108. package/dist/session/provider-cli/provider-cli.artifacts.js +65 -7
  109. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  110. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
  111. package/dist/session/provider-cli/provider-cli.deterministic.js +68 -9
  112. package/dist/session/provider-cli/provider-cli.process.d.ts +26 -0
  113. package/dist/session/provider-cli/provider-cli.process.js +474 -23
  114. package/dist/session/provider-cli/provider-cli.prompts.d.ts +16 -2
  115. package/dist/session/provider-cli/provider-cli.prompts.js +224 -24
  116. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  117. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  118. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  119. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  120. package/dist/session/provider-cli/provider-cli.runtime.js +6 -3
  121. package/dist/session/provider-cli/provider-cli.service.d.ts +26 -3
  122. package/dist/session/provider-cli/provider-cli.service.js +650 -108
  123. package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
  124. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  125. package/dist/session/provider-cli/provider-output-state.js +91 -16
  126. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  127. package/dist/session/provider-cli/provider-usage.js +28 -5
  128. package/dist/session/session-redaction.d.ts +3 -0
  129. package/dist/session/session-redaction.js +38 -0
  130. package/dist/session/session.base.d.ts +6 -1
  131. package/dist/session/session.service.d.ts +8 -2
  132. package/dist/session/session.service.js +549 -58
  133. package/dist/session/session.types.d.ts +124 -1
  134. package/dist/session/session.types.js +231 -4
  135. package/dist/shared/format/format-bytes.d.ts +1 -0
  136. package/dist/shared/format/format-bytes.js +9 -0
  137. package/dist/shared/path/path.d.ts +1 -0
  138. package/dist/shared/path/path.js +21 -1
  139. package/dist/shared/process/dotnet-toolchain.d.ts +14 -0
  140. package/dist/shared/process/dotnet-toolchain.js +179 -0
  141. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  142. package/dist/shared/process/host-process-recovery.js +78 -0
  143. package/dist/shared/process/host-process-supervisor.d.ts +59 -0
  144. package/dist/shared/process/host-process-supervisor.js +330 -0
  145. package/dist/shared/process/process.d.ts +1 -0
  146. package/dist/shared/process/process.js +1 -0
  147. package/dist/shared/process/windows-launch.d.ts +1 -1
  148. package/dist/shared/process/windows-launch.js +6 -3
  149. package/dist/shared/provider-capabilities.d.ts +1 -0
  150. package/dist/shared/provider-capabilities.js +3 -0
  151. package/dist/shared/redaction/redaction.js +16 -4
  152. package/dist/version.d.ts +1 -1
  153. package/dist/version.js +1 -1
  154. package/dist/worktree/artifact-injector.d.ts +3 -2
  155. package/dist/worktree/artifact-injector.js +43 -4
  156. package/dist/worktree/dependency-warmer.d.ts +39 -1
  157. package/dist/worktree/dependency-warmer.js +351 -8
  158. package/dist/worktree/index.d.ts +2 -1
  159. package/dist/worktree/index.js +1 -0
  160. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  161. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  162. package/dist/worktree/worktree-paths.d.ts +4 -0
  163. package/dist/worktree/worktree-paths.js +30 -0
  164. package/dist/worktree/worktree.base.d.ts +3 -0
  165. package/dist/worktree/worktree.service.d.ts +13 -2
  166. package/dist/worktree/worktree.service.js +238 -47
  167. package/dist/worktree/worktree.types.d.ts +5 -1
  168. package/package.json +8 -4
  169. package/runtime/windows-job-supervisor.ps1 +444 -0
  170. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -1,5 +1,498 @@
1
- export class NoopCleanupService {
2
- async run(_request) {
3
- return { removedWorktrees: 0 };
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
6
+ import { GitClient } from "../shared/git/git.js";
7
+ import { isSubPath, resolveExistingDirectoryPath, resolvePathIdentity } from "../shared/path/path.js";
8
+ import { validRuntimeIsolationLease } from "../runtime/runtime-isolation-lease.js";
9
+ import { defaultWorktreeCoordinatorStateRoot, resolveManagedWorktreeRootForRepo, } from "../worktree/worktree-paths.js";
10
+ const staleRuntimeReadinessAgeMs = 30 * 60 * 1000;
11
+ export class ManagedWorktreeCleanupService {
12
+ stateRoot;
13
+ git;
14
+ coordinator;
15
+ constructor(stateRoot = defaultWorktreeCoordinatorStateRoot(), git = new GitClient(), coordinator = new RepositoryCoordinatorLock(stateRoot)) {
16
+ this.stateRoot = stateRoot;
17
+ this.git = git;
18
+ this.coordinator = coordinator;
4
19
  }
20
+ async run(request) {
21
+ const [repoPath, allowedRepoRoot] = await Promise.all([
22
+ resolveExistingDirectoryPath(request.repoRoot),
23
+ request.allowedRepoRoot
24
+ ? resolvePathIdentity(request.allowedRepoRoot)
25
+ : Promise.resolve(undefined),
26
+ ]);
27
+ const worktreeRoot = resolveManagedWorktreeRootForRepo(repoPath, allowedRepoRoot);
28
+ return await this.coordinator.withLock(repoPath, async () => {
29
+ return await this.runLocked(repoPath, worktreeRoot, request);
30
+ });
31
+ }
32
+ async runLocked(repoPath, worktreeRoot, request) {
33
+ if (request.mode === "merged") {
34
+ if (request.force)
35
+ await this.git.run(repoPath, ["worktree", "prune"]);
36
+ return emptyResult(worktreeRoot, request.force);
37
+ }
38
+ const safeRoot = await resolveSafeManagedRoot(worktreeRoot);
39
+ if (!safeRoot) {
40
+ if (request.force)
41
+ await this.git.run(repoPath, ["worktree", "prune"]);
42
+ return emptyResult(worktreeRoot, request.force);
43
+ }
44
+ const [listedWorktrees, runtimeLeases] = await Promise.all([
45
+ this.listWorktrees(repoPath),
46
+ this.readRuntimeLeases(),
47
+ ]);
48
+ const activeRuntimePaths = await Promise.all(runtimeLeases
49
+ .filter(lease => lease.state !== "released")
50
+ .map(lease => resolveRuntimeLeasePath(lease.worktreePath)));
51
+ const metadataRoot = await this.worktreeMetadataRoot(repoPath);
52
+ const staleReadinessPaths = await this.staleRuntimeReadinessPaths(listedWorktrees, activeRuntimePaths);
53
+ const activeWorktrees = activePaths(listedWorktrees)
54
+ .filter(activePath => !staleReadinessPaths.some(stalePath => samePath(stalePath, activePath)));
55
+ if (request.mode === "all") {
56
+ const protectedPaths = uniquePaths([
57
+ ...activeWorktrees.filter(activePath => isSubPath(safeRoot, activePath)),
58
+ ...activeRuntimePaths.filter(activePath => isSubPath(safeRoot, activePath)),
59
+ ]);
60
+ if (protectedPaths.length > 0) {
61
+ throw new Error(`Refusing to remove managed worktree root ${safeRoot}; ${protectedPaths.length} active worktree or runtime lease(s) remain.`);
62
+ }
63
+ const scan = await this.scanManagedCandidates(metadataRoot, safeRoot, activeWorktrees, activeRuntimePaths);
64
+ if (request.force && !scan.rootRemovalSafe) {
65
+ throw new Error(`Refusing to remove managed worktree root ${safeRoot}; unowned content remains (${scan.skippedUnownedDirectories} skipped director${scan.skippedUnownedDirectories === 1 ? "y" : "ies"}).`);
66
+ }
67
+ if (request.force) {
68
+ await quarantineAndRemoveRoot(safeRoot);
69
+ await this.git.run(repoPath, ["worktree", "prune"]);
70
+ }
71
+ return {
72
+ worktreeRoot: safeRoot,
73
+ removedWorktrees: request.force ? scan.candidates.length : 0,
74
+ candidateWorktrees: scan.candidates.length,
75
+ protectedWorktrees: scan.protectedWorktrees,
76
+ skippedUnownedDirectories: scan.skippedUnownedDirectories,
77
+ removedRoot: request.force,
78
+ dryRun: !request.force,
79
+ };
80
+ }
81
+ const scan = await this.scanManagedCandidates(metadataRoot, safeRoot, activeWorktrees, activeRuntimePaths);
82
+ if (!request.force) {
83
+ return {
84
+ worktreeRoot: safeRoot,
85
+ removedWorktrees: 0,
86
+ candidateWorktrees: scan.candidates.length,
87
+ protectedWorktrees: scan.protectedWorktrees,
88
+ skippedUnownedDirectories: scan.skippedUnownedDirectories,
89
+ removedRoot: false,
90
+ dryRun: true,
91
+ };
92
+ }
93
+ let removedWorktrees = 0;
94
+ for (const candidate of scan.candidates) {
95
+ const [currentWorktrees, currentLeases] = await Promise.all([
96
+ this.listWorktrees(repoPath),
97
+ this.readRuntimeLeases(),
98
+ ]);
99
+ const currentRuntimePaths = await Promise.all(currentLeases
100
+ .filter(lease => lease.state !== "released")
101
+ .map(lease => resolveRuntimeLeasePath(lease.worktreePath)));
102
+ const currentStaleReadinessPaths = await this.staleRuntimeReadinessPaths(currentWorktrees, currentRuntimePaths);
103
+ const currentlyProtected = [
104
+ ...activePaths(currentWorktrees)
105
+ .filter(activePath => !currentStaleReadinessPaths.some(stalePath => samePath(stalePath, activePath))),
106
+ ...currentRuntimePaths,
107
+ ];
108
+ if (worktreePathState(candidate, currentlyProtected) !== "none") {
109
+ continue;
110
+ }
111
+ const candidateIdentity = await forgeManagedCandidateIdentity(metadataRoot, candidate);
112
+ if (!candidateIdentity) {
113
+ continue;
114
+ }
115
+ if (await quarantineAndRemoveCandidate(safeRoot, candidate, candidateIdentity)) {
116
+ removedWorktrees += 1;
117
+ }
118
+ }
119
+ await this.git.run(repoPath, ["worktree", "prune"]);
120
+ return {
121
+ worktreeRoot: safeRoot,
122
+ removedWorktrees,
123
+ candidateWorktrees: scan.candidates.length,
124
+ protectedWorktrees: scan.protectedWorktrees,
125
+ skippedUnownedDirectories: scan.skippedUnownedDirectories,
126
+ removedRoot: false,
127
+ dryRun: false,
128
+ };
129
+ }
130
+ async listWorktrees(repoPath) {
131
+ const output = await this.git.run(repoPath, ["worktree", "list", "--porcelain", "-z"]);
132
+ return parseWorktreeListing(repoPath, output);
133
+ }
134
+ async staleRuntimeReadinessPaths(worktrees, runtimePaths) {
135
+ const stale = [];
136
+ for (const worktree of worktrees) {
137
+ if (worktree.prunable || !isRuntimeReadinessWorktree(worktree))
138
+ continue;
139
+ if (worktreePathState(worktree.worktreePath, runtimePaths) !== "none")
140
+ continue;
141
+ const stat = await fs.stat(worktree.worktreePath).catch(error => {
142
+ if (isEnoent(error))
143
+ return null;
144
+ throw error;
145
+ });
146
+ if (stat && Date.now() - stat.mtimeMs >= staleRuntimeReadinessAgeMs) {
147
+ stale.push(worktree.worktreePath);
148
+ }
149
+ }
150
+ return stale;
151
+ }
152
+ async scanManagedCandidates(metadataRoot, worktreeRoot, activeWorktrees, runtimePaths) {
153
+ const candidates = [];
154
+ let protectedWorktrees = 0;
155
+ let skippedUnownedDirectories = 0;
156
+ const visit = async (currentPath, root = false) => {
157
+ const entries = await fs.readdir(currentPath, { withFileTypes: true }).catch(error => {
158
+ if (isEnoent(error))
159
+ return [];
160
+ throw error;
161
+ });
162
+ let containsManagedDirectory = false;
163
+ let containsUnownedContent = false;
164
+ for (const entry of entries) {
165
+ if (entry.name === ".forge-cleanup-quarantine") {
166
+ containsUnownedContent = true;
167
+ continue;
168
+ }
169
+ const entryPath = path.join(currentPath, entry.name);
170
+ const stat = await fs.lstat(entryPath).catch(error => {
171
+ if (isEnoent(error))
172
+ return null;
173
+ throw error;
174
+ });
175
+ if (!stat)
176
+ continue;
177
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
178
+ containsUnownedContent = true;
179
+ continue;
180
+ }
181
+ const realEntry = await fs.realpath(entryPath).catch(error => {
182
+ if (isEnoent(error))
183
+ return null;
184
+ throw error;
185
+ });
186
+ if (!realEntry)
187
+ continue;
188
+ if (!isSubPath(worktreeRoot, realEntry)) {
189
+ throw new Error(`Managed worktree cleanup path escaped ${worktreeRoot}: ${realEntry}.`);
190
+ }
191
+ const activeState = worktreePathState(realEntry, activeWorktrees);
192
+ const runtimeState = worktreePathState(realEntry, runtimePaths);
193
+ if (activeState === "active" || runtimeState === "active") {
194
+ protectedWorktrees += 1;
195
+ containsManagedDirectory = true;
196
+ continue;
197
+ }
198
+ if (activeState === "ancestor" || runtimeState === "ancestor") {
199
+ containsManagedDirectory = await visit(realEntry) || containsManagedDirectory;
200
+ continue;
201
+ }
202
+ if (await forgeManagedCandidateIdentity(metadataRoot, realEntry)) {
203
+ candidates.push(realEntry);
204
+ containsManagedDirectory = true;
205
+ continue;
206
+ }
207
+ if (await visit(realEntry)) {
208
+ containsManagedDirectory = true;
209
+ continue;
210
+ }
211
+ skippedUnownedDirectories += 1;
212
+ containsUnownedContent = true;
213
+ }
214
+ return !containsUnownedContent && (root || containsManagedDirectory);
215
+ };
216
+ const rootRemovalSafe = await visit(worktreeRoot, true);
217
+ return { candidates, protectedWorktrees, skippedUnownedDirectories, rootRemovalSafe };
218
+ }
219
+ async readRuntimeLeases() {
220
+ const ledgerRoot = path.join(this.stateRoot, "runtime-ledger", "host");
221
+ const entries = await fs.readdir(ledgerRoot, { withFileTypes: true }).catch(error => {
222
+ if (isEnoent(error))
223
+ return [];
224
+ throw error;
225
+ });
226
+ const leases = [];
227
+ for (const entry of entries) {
228
+ if (!entry.name.endsWith(".json"))
229
+ continue;
230
+ if (!entry.isFile() || entry.isSymbolicLink()) {
231
+ throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entry.name} is not a regular file.`);
232
+ }
233
+ const target = path.join(ledgerRoot, entry.name);
234
+ const value = await readRuntimeLedger(target, entry.name);
235
+ const runtimeLease = isRecord(value) ? value.runtimeLease : null;
236
+ if (!validRuntimeIsolationLease(runtimeLease) || runtimeLease.mode !== "host") {
237
+ throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entry.name} has no trustworthy host lease.`);
238
+ }
239
+ leases.push(runtimeLease);
240
+ }
241
+ return leases;
242
+ }
243
+ async worktreeMetadataRoot(repoPath) {
244
+ const commonDirRaw = (await this.git.run(repoPath, ["rev-parse", "--git-common-dir"])).trim();
245
+ const commonDirPath = path.isAbsolute(commonDirRaw) ? commonDirRaw : path.resolve(repoPath, commonDirRaw);
246
+ return path.join(await fs.realpath(commonDirPath), "worktrees");
247
+ }
248
+ }
249
+ async function resolveRuntimeLeasePath(leasePath) {
250
+ const resolved = path.resolve(leasePath);
251
+ const before = await fs.lstat(resolved).catch(error => {
252
+ if (isEnoent(error))
253
+ return null;
254
+ throw error;
255
+ });
256
+ if (!before)
257
+ return await resolvePathIdentity(resolved);
258
+ if (before.isSymbolicLink() || !before.isDirectory()) {
259
+ throw new Error(`Refusing managed worktree cleanup because runtime lease path ${resolved} is redirected or is not a directory.`);
260
+ }
261
+ const real = await fs.realpath(resolved);
262
+ const after = await fs.lstat(resolved);
263
+ if (after.isSymbolicLink()
264
+ || !after.isDirectory()
265
+ || (before.dev !== 0 && before.ino !== 0 && after.dev !== 0 && after.ino !== 0
266
+ && (before.dev !== after.dev || before.ino !== after.ino))) {
267
+ throw new Error(`Refusing managed worktree cleanup because runtime lease path ${resolved} changed while being resolved.`);
268
+ }
269
+ return real;
270
+ }
271
+ async function readRuntimeLedger(target, entryName) {
272
+ const pathStat = await fs.lstat(target).catch(error => {
273
+ if (isEnoent(error))
274
+ return null;
275
+ throw error;
276
+ });
277
+ if (!pathStat) {
278
+ throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} disappeared.`);
279
+ }
280
+ validateRuntimeLedgerStat(entryName, pathStat);
281
+ let handle;
282
+ try {
283
+ handle = await fs.open(target, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
284
+ }
285
+ catch (error) {
286
+ if (isEnoent(error) || errorCode(error) === "ELOOP") {
287
+ throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} is not a regular file.`, { cause: error });
288
+ }
289
+ throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} could not be opened safely.`, { cause: error });
290
+ }
291
+ try {
292
+ const openedStat = await handle.stat();
293
+ const reopenedPathStat = await fs.lstat(target);
294
+ validateRuntimeLedgerStat(entryName, openedStat);
295
+ validateRuntimeLedgerStat(entryName, reopenedPathStat);
296
+ validateSameRuntimeLedgerFile(entryName, pathStat, openedStat);
297
+ validateSameRuntimeLedgerFile(entryName, openedStat, reopenedPathStat);
298
+ const content = await handle.readFile("utf8");
299
+ const afterStat = await handle.stat();
300
+ const afterPathStat = await fs.lstat(target);
301
+ validateRuntimeLedgerStat(entryName, afterStat);
302
+ validateRuntimeLedgerStat(entryName, afterPathStat);
303
+ validateSameRuntimeLedgerFile(entryName, openedStat, afterStat);
304
+ validateSameRuntimeLedgerFile(entryName, afterStat, afterPathStat);
305
+ if (openedStat.size !== afterStat.size || Buffer.byteLength(content, "utf8") !== afterStat.size) {
306
+ throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} changed while being read.`);
307
+ }
308
+ try {
309
+ return JSON.parse(content);
310
+ }
311
+ catch (error) {
312
+ throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} is invalid.`, { cause: error });
313
+ }
314
+ }
315
+ finally {
316
+ await handle.close();
317
+ }
318
+ }
319
+ function validateRuntimeLedgerStat(entryName, stat) {
320
+ if (stat.isSymbolicLink() || !stat.isFile()) {
321
+ throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} is not a regular file.`);
322
+ }
323
+ if (stat.size > 256 * 1024) {
324
+ throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} is oversized.`);
325
+ }
326
+ }
327
+ function validateSameRuntimeLedgerFile(entryName, before, after) {
328
+ if (before.dev !== 0 && before.ino !== 0 && after.dev !== 0 && after.ino !== 0
329
+ && (before.dev !== after.dev || before.ino !== after.ino)) {
330
+ throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} changed while being read.`);
331
+ }
332
+ }
333
+ function errorCode(error) {
334
+ return typeof error === "object" && error !== null && "code" in error
335
+ ? String(error.code)
336
+ : undefined;
337
+ }
338
+ function emptyResult(worktreeRoot, force) {
339
+ return {
340
+ worktreeRoot: path.resolve(worktreeRoot),
341
+ removedWorktrees: 0,
342
+ candidateWorktrees: 0,
343
+ protectedWorktrees: 0,
344
+ skippedUnownedDirectories: 0,
345
+ removedRoot: false,
346
+ dryRun: !force,
347
+ };
348
+ }
349
+ async function resolveSafeManagedRoot(worktreeRoot) {
350
+ const rootPath = path.resolve(worktreeRoot);
351
+ const rootStat = await fs.lstat(rootPath).catch(error => {
352
+ if (isEnoent(error))
353
+ return null;
354
+ throw error;
355
+ });
356
+ if (!rootStat)
357
+ return null;
358
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
359
+ throw new Error(`Refusing cleanup for redirected or non-directory managed worktree root ${rootPath}.`);
360
+ }
361
+ const parentPath = path.dirname(rootPath);
362
+ const parentStat = await fs.lstat(parentPath);
363
+ if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
364
+ throw new Error(`Refusing cleanup for redirected managed worktree root parent ${parentPath}.`);
365
+ }
366
+ const [realParent, realRoot] = await Promise.all([fs.realpath(parentPath), fs.realpath(rootPath)]);
367
+ if (!samePath(path.join(realParent, path.basename(rootPath)), realRoot)) {
368
+ throw new Error(`Refusing cleanup for redirected managed worktree root ${rootPath}.`);
369
+ }
370
+ return realRoot;
371
+ }
372
+ async function forgeManagedCandidateIdentity(metadataRoot, candidatePath) {
373
+ const gitFile = path.join(candidatePath, ".git");
374
+ const stat = await fs.lstat(gitFile).catch(error => {
375
+ if (isEnoent(error))
376
+ return null;
377
+ throw error;
378
+ });
379
+ if (!stat || stat.isSymbolicLink() || !stat.isFile() || stat.size > 16 * 1024)
380
+ return null;
381
+ const contents = (await fs.readFile(gitFile, "utf8")).trim();
382
+ if (!contents.toLowerCase().startsWith("gitdir:"))
383
+ return null;
384
+ const gitDir = contents.slice("gitdir:".length).trim();
385
+ if (!gitDir)
386
+ return null;
387
+ const candidateGitDir = path.resolve(candidatePath, gitDir);
388
+ return !samePath(metadataRoot, candidateGitDir) && isSubPath(metadataRoot, candidateGitDir)
389
+ ? contents
390
+ : null;
391
+ }
392
+ async function quarantineAndRemoveCandidate(worktreeRoot, candidatePath, expectedIdentity) {
393
+ const quarantineRoot = path.join(worktreeRoot, ".forge-cleanup-quarantine");
394
+ await ensureSafeQuarantineRoot(worktreeRoot, quarantineRoot);
395
+ const quarantinePath = path.join(quarantineRoot, randomUUID());
396
+ try {
397
+ await fs.rename(candidatePath, quarantinePath);
398
+ }
399
+ catch (error) {
400
+ if (isEnoent(error))
401
+ return false;
402
+ throw error;
403
+ }
404
+ const quarantinedIdentity = await readGitFileIdentity(quarantinePath);
405
+ if (quarantinedIdentity !== expectedIdentity) {
406
+ await fs.rename(quarantinePath, candidatePath).catch(() => undefined);
407
+ throw new Error(`Refusing cleanup because managed worktree ownership changed while quarantining ${candidatePath}.`);
408
+ }
409
+ await fs.rm(quarantinePath, { recursive: true, force: true });
410
+ await fs.rmdir(quarantineRoot).catch(error => {
411
+ if (!isEnoent(error) && error.code !== "ENOTEMPTY")
412
+ throw error;
413
+ });
414
+ return true;
415
+ }
416
+ async function readGitFileIdentity(candidatePath) {
417
+ const gitFile = path.join(candidatePath, ".git");
418
+ const stat = await fs.lstat(gitFile).catch(error => {
419
+ if (isEnoent(error))
420
+ return null;
421
+ throw error;
422
+ });
423
+ if (!stat || stat.isSymbolicLink() || !stat.isFile() || stat.size > 16 * 1024)
424
+ return null;
425
+ return (await fs.readFile(gitFile, "utf8")).trim();
426
+ }
427
+ async function ensureSafeQuarantineRoot(worktreeRoot, quarantineRoot) {
428
+ await fs.mkdir(quarantineRoot, { recursive: true, mode: 0o700 });
429
+ const stat = await fs.lstat(quarantineRoot);
430
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
431
+ throw new Error(`Refusing cleanup through redirected quarantine root ${quarantineRoot}.`);
432
+ }
433
+ const realQuarantine = await fs.realpath(quarantineRoot);
434
+ if (!isSubPath(worktreeRoot, realQuarantine)) {
435
+ throw new Error(`Cleanup quarantine escaped managed worktree root ${worktreeRoot}.`);
436
+ }
437
+ }
438
+ async function quarantineAndRemoveRoot(worktreeRoot) {
439
+ const parent = path.dirname(worktreeRoot);
440
+ const quarantinePath = path.join(parent, `.forge-cleanup-${path.basename(worktreeRoot)}-${randomUUID()}`);
441
+ await fs.rename(worktreeRoot, quarantinePath);
442
+ await fs.rm(quarantinePath, { recursive: true, force: true });
443
+ }
444
+ function parseWorktreeListing(repoPath, output) {
445
+ const worktrees = [];
446
+ let current = null;
447
+ for (const field of output.split("\0")) {
448
+ if (field.startsWith("worktree ")) {
449
+ if (current)
450
+ worktrees.push(current);
451
+ const rawPath = field.slice("worktree ".length);
452
+ current = {
453
+ worktreePath: path.resolve(path.isAbsolute(rawPath) ? rawPath : path.join(repoPath, rawPath)),
454
+ prunable: false,
455
+ branchName: null,
456
+ };
457
+ }
458
+ else if (field.startsWith("prunable") && current) {
459
+ current.prunable = true;
460
+ }
461
+ else if (field.startsWith("branch ") && current) {
462
+ current.branchName = field.slice("branch ".length).trim();
463
+ }
464
+ }
465
+ if (current)
466
+ worktrees.push(current);
467
+ return worktrees;
468
+ }
469
+ function isRuntimeReadinessWorktree(worktree) {
470
+ return worktree.branchName?.startsWith("refs/heads/runtime-readiness-") === true
471
+ || path.basename(worktree.worktreePath).startsWith("runtime-readiness-");
472
+ }
473
+ function activePaths(worktrees) {
474
+ return worktrees.filter(entry => !entry.prunable).map(entry => entry.worktreePath);
475
+ }
476
+ function worktreePathState(candidate, activePathsToCheck) {
477
+ if (activePathsToCheck.some(activePath => samePath(activePath, candidate)))
478
+ return "active";
479
+ if (activePathsToCheck.some(activePath => isSubPath(candidate, activePath)))
480
+ return "ancestor";
481
+ return "none";
482
+ }
483
+ function uniquePaths(values) {
484
+ return values.filter((value, index) => values.findIndex(candidate => samePath(candidate, value)) === index);
485
+ }
486
+ function samePath(left, right) {
487
+ const normalizedLeft = path.resolve(left);
488
+ const normalizedRight = path.resolve(right);
489
+ return process.platform === "win32"
490
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
491
+ : normalizedLeft === normalizedRight;
492
+ }
493
+ function isEnoent(error) {
494
+ return error?.code === "ENOENT";
495
+ }
496
+ function isRecord(value) {
497
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
498
  }
@@ -1,6 +1,16 @@
1
+ export type CleanupMode = "stale" | "merged" | "all";
1
2
  export interface CleanupRequest {
2
3
  readonly repoRoot: string;
4
+ readonly allowedRepoRoot?: string;
5
+ readonly mode: CleanupMode;
6
+ readonly force: boolean;
3
7
  }
4
8
  export interface CleanupResult {
9
+ readonly worktreeRoot: string;
5
10
  readonly removedWorktrees: number;
11
+ readonly candidateWorktrees: number;
12
+ readonly protectedWorktrees: number;
13
+ readonly skippedUnownedDirectories: number;
14
+ readonly removedRoot: boolean;
15
+ readonly dryRun: boolean;
6
16
  }
@@ -1,2 +1,2 @@
1
- export { NoopCleanupService } from "./cleanup.service.js";
1
+ export { ManagedWorktreeCleanupService } from "./cleanup.service.js";
2
2
  export type { CleanupService } from "./cleanup.base.js";
@@ -1 +1 @@
1
- export { NoopCleanupService } from "./cleanup.service.js";
1
+ export { ManagedWorktreeCleanupService } from "./cleanup.service.js";
@@ -1,3 +1,3 @@
1
- export { NoopCleanupService } from "./cleanup.service.js";
1
+ export { ManagedWorktreeCleanupService } from "./cleanup.service.js";
2
2
  export type { CleanupService } from "./cleanup.base.js";
3
- export type { CleanupRequest, CleanupResult } from "./cleanup.types.js";
3
+ export type { CleanupMode, CleanupRequest, CleanupResult } from "./cleanup.types.js";
@@ -1 +1 @@
1
- export { NoopCleanupService } from "./cleanup.service.js";
1
+ export { ManagedWorktreeCleanupService } from "./cleanup.service.js";
@@ -16,9 +16,11 @@ export function helpText() {
16
16
  " connect * Start the runner bridge and wait for Forge dispatches",
17
17
  " pair * Pair this machine with Forge and save runner credentials",
18
18
  " status * Show config, runner identity, repos, provider CLIs, and updates",
19
+ " server * Show or change the saved Forge server URL",
19
20
  " add-repo * Map a Forge repo label/name to a local checkout path",
20
21
  " remove-repo * Remove a saved repo mapping",
21
22
  " cleanup * Prune managed bridge worktrees",
23
+ " sandbox * Initialize, diagnose, smoke-test, or reap Docker sandboxes",
22
24
  " version * Print the installed bridge package version",
23
25
  " help * Display help for command",
24
26
  " run-once * Internal: run one dispatch packet from JSON",
@@ -29,6 +31,7 @@ export function helpText() {
29
31
  " forge-bridge pair 1234-5678 --server https://srv1493013.tail66073d.ts.net:8443 --repo-root \"%USERPROFILE%\\ForgeRepos\"",
30
32
  " forge-bridge connect",
31
33
  " forge-bridge status --check-updates",
34
+ " forge-bridge server set http://localhost:5208",
32
35
  " forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
33
36
  " forge-bridge cleanup --stale",
34
37
  "",
@@ -99,6 +102,23 @@ export function commandHelpText(command) {
99
102
  " forge-bridge status --check-updates",
100
103
  "",
101
104
  ].join("\n");
105
+ case "server":
106
+ return [
107
+ "Forge Bridge — server",
108
+ "Show or change the saved Forge server URL.",
109
+ "",
110
+ "Usage:",
111
+ " forge-bridge server show",
112
+ " forge-bridge server set <url>",
113
+ "",
114
+ "The saved runner credentials are retained. Pair again when switching to a different Forge deployment.",
115
+ "FORGE_URL overrides the saved server when that environment variable is set.",
116
+ "",
117
+ "Examples:",
118
+ " forge-bridge server set http://localhost:5208",
119
+ " forge-bridge server set https://srv1493013.tail66073d.ts.net:8443",
120
+ "",
121
+ ].join("\n");
102
122
  case "add-repo":
103
123
  return [
104
124
  "Forge Bridge — add-repo",
@@ -133,6 +153,32 @@ export function commandHelpText(command) {
133
153
  " forge-bridge cleanup --all --force",
134
154
  "",
135
155
  ].join("\n");
156
+ case "sandbox":
157
+ return [
158
+ "Forge Bridge — sandbox",
159
+ "Configure local Docker sandbox placement or inspect a repository-owned sandbox contract.",
160
+ "",
161
+ "Usage:",
162
+ " forge-bridge sandbox on [--allow-open-public]",
163
+ " forge-bridge sandbox off",
164
+ " forge-bridge sandbox status",
165
+ " forge-bridge sandbox init [--repo <path>]",
166
+ " forge-bridge sandbox doctor [--repo <path>] [--require-pinned-base]",
167
+ " forge-bridge sandbox smoke [--repo <path>] [--report <path>] [--require-pinned-base] [--probe-loopback-port]",
168
+ " forge-bridge sandbox reap",
169
+ "",
170
+ "`sandbox on` keeps unrestricted public networking denied unless the explicit",
171
+ "developer-only --allow-open-public option is supplied. The local policy is",
172
+ "saved in bridge.json and takes effect after the connected bridge restarts.",
173
+ "Environment variables remain higher-priority operator overrides.",
174
+ "",
175
+ "The smoke command builds the repository Dockerfile, runs a disposable hardened",
176
+ "container, verifies exact-label teardown, and writes a redacted JSON report.",
177
+ "The optional port probe publishes 127.0.0.1::8080 only for the disposable fixture",
178
+ "and verifies Docker-assigned host-port discovery; normal sandboxes publish no ports.",
179
+ "Stop the connected bridge first so the Docker ownership lock can be acquired.",
180
+ "",
181
+ ].join("\n");
136
182
  case "run-once":
137
183
  return [
138
184
  "Forge Bridge — run-once",
@@ -148,7 +194,7 @@ export function commandHelpText(command) {
148
194
  "Internal MCP server used by provider CLIs to report Forge phase artifacts.",
149
195
  "",
150
196
  "Usage:",
151
- " forge-bridge mcp --worktree <path> --work-item-id <id> --phase <phase>",
197
+ " forge-bridge mcp --worktree <path> --work-item-id <id> --phase <phase> # includes ci-repair",
152
198
  "",
153
199
  ].join("\n");
154
200
  case "version":