@loucompanion/forge-bridge 0.1.3-dev.b6be5e7f9668 → 0.1.3-dev.bca7d0ccee53

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 (150) hide show
  1. package/README.md +78 -0
  2. package/dist/bridge/bridge.base.d.ts +6 -1
  3. package/dist/bridge/bridge.service.d.ts +60 -3
  4. package/dist/bridge/bridge.service.js +970 -24
  5. package/dist/bridge/bridge.types.d.ts +172 -1
  6. package/dist/cleanup/cleanup.service.d.ts +13 -2
  7. package/dist/cleanup/cleanup.service.js +435 -3
  8. package/dist/cleanup/cleanup.types.d.ts +10 -0
  9. package/dist/cleanup/index.d.ts +1 -1
  10. package/dist/cleanup/index.js +1 -1
  11. package/dist/cleanup/internals.d.ts +2 -2
  12. package/dist/cleanup/internals.js +1 -1
  13. package/dist/cli/cli.help.d.ts +2 -0
  14. package/dist/cli/cli.help.js +217 -0
  15. package/dist/cli/cli.service.d.ts +42 -2
  16. package/dist/cli/cli.service.js +357 -206
  17. package/dist/cli/local-config.d.ts +6 -0
  18. package/dist/cli/local-config.js +47 -3
  19. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  20. package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  22. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  23. package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
  24. package/dist/runtime/attempt-process-supervisor.js +350 -0
  25. package/dist/runtime/generated-output-reset.d.ts +10 -0
  26. package/dist/runtime/generated-output-reset.js +103 -0
  27. package/dist/runtime/host-listener-inspector.d.ts +44 -0
  28. package/dist/runtime/host-listener-inspector.js +324 -0
  29. package/dist/runtime/host-port-broker.d.ts +76 -0
  30. package/dist/runtime/host-port-broker.js +333 -0
  31. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  32. package/dist/runtime/host-runtime-contract.js +700 -0
  33. package/dist/runtime/host-runtime-lease.d.ts +133 -0
  34. package/dist/runtime/host-runtime-lease.js +1462 -0
  35. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  36. package/dist/runtime/host-runtime-promotion.js +159 -0
  37. package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
  38. package/dist/runtime/node-host-runtime-adapter.js +268 -0
  39. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  40. package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
  41. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  42. package/dist/runtime/runtime-isolation-lease.js +158 -0
  43. package/dist/sandbox/command-runner.d.ts +4 -0
  44. package/dist/sandbox/command-runner.js +47 -0
  45. package/dist/sandbox/contract.d.ts +5 -0
  46. package/dist/sandbox/contract.js +276 -0
  47. package/dist/sandbox/event-budget.d.ts +11 -0
  48. package/dist/sandbox/event-budget.js +27 -0
  49. package/dist/sandbox/image-policy.d.ts +35 -0
  50. package/dist/sandbox/image-policy.js +247 -0
  51. package/dist/sandbox/index.d.ts +5 -0
  52. package/dist/sandbox/index.js +5 -0
  53. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  54. package/dist/sandbox/lease-watchdog.js +67 -0
  55. package/dist/sandbox/preflight.d.ts +54 -0
  56. package/dist/sandbox/preflight.js +335 -0
  57. package/dist/sandbox/process.d.ts +20 -0
  58. package/dist/sandbox/process.js +209 -0
  59. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  60. package/dist/sandbox/provider-auth/codex.js +212 -0
  61. package/dist/sandbox/repository-lock.d.ts +5 -0
  62. package/dist/sandbox/repository-lock.js +119 -0
  63. package/dist/sandbox/runtime.d.ts +297 -0
  64. package/dist/sandbox/runtime.js +2338 -0
  65. package/dist/sandbox/sandbox.types.d.ts +86 -0
  66. package/dist/sandbox/sandbox.types.js +1 -0
  67. package/dist/sandbox/smoke.d.ts +76 -0
  68. package/dist/sandbox/smoke.js +380 -0
  69. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  70. package/dist/sandbox/worker-supervisor.js +36 -0
  71. package/dist/sandbox/worker.d.ts +1 -0
  72. package/dist/sandbox/worker.js +225 -0
  73. package/dist/sandbox/workspace.service.d.ts +13 -0
  74. package/dist/sandbox/workspace.service.js +72 -0
  75. package/dist/session/managed-attempt-tools.d.ts +17 -0
  76. package/dist/session/managed-attempt-tools.js +712 -0
  77. package/dist/session/phase-completion.artifacts.d.ts +10 -0
  78. package/dist/session/phase-completion.artifacts.js +627 -0
  79. package/dist/session/phase-completion.d.ts +43 -46
  80. package/dist/session/phase-completion.helpers.d.ts +117 -0
  81. package/dist/session/phase-completion.helpers.js +701 -0
  82. package/dist/session/phase-completion.js +40 -3488
  83. package/dist/session/phase-completion.prompts.d.ts +14 -0
  84. package/dist/session/phase-completion.prompts.js +235 -0
  85. package/dist/session/phase-completion.runtime.d.ts +48 -0
  86. package/dist/session/phase-completion.runtime.js +786 -0
  87. package/dist/session/phase-completion.schemas.d.ts +3 -0
  88. package/dist/session/phase-completion.schemas.js +812 -0
  89. package/dist/session/phase-completion.validation.d.ts +37 -0
  90. package/dist/session/phase-completion.validation.js +850 -0
  91. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  92. package/dist/session/provider-cli/ci-repair-readiness.js +95 -0
  93. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +93 -0
  94. package/dist/session/provider-cli/provider-cli.artifacts.js +778 -0
  95. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  96. package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
  97. package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
  98. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
  99. package/dist/session/provider-cli/provider-cli.deterministic.js +375 -0
  100. package/dist/session/provider-cli/provider-cli.process.d.ts +48 -0
  101. package/dist/session/provider-cli/provider-cli.process.js +474 -0
  102. package/dist/session/provider-cli/provider-cli.prompts.d.ts +47 -0
  103. package/dist/session/provider-cli/provider-cli.prompts.js +595 -0
  104. package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
  105. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +66 -0
  106. package/dist/session/provider-cli/provider-cli.runtime.d.ts +9 -0
  107. package/dist/session/provider-cli/provider-cli.runtime.js +41 -0
  108. package/dist/session/provider-cli/provider-cli.service.d.ts +26 -53
  109. package/dist/session/provider-cli/provider-cli.service.js +524 -2571
  110. package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
  111. package/dist/session/provider-cli/provider-output-state.d.ts +22 -0
  112. package/dist/session/provider-cli/provider-output-state.js +473 -0
  113. package/dist/session/session-redaction.d.ts +3 -0
  114. package/dist/session/session-redaction.js +23 -0
  115. package/dist/session/session.base.d.ts +6 -1
  116. package/dist/session/session.service.d.ts +8 -2
  117. package/dist/session/session.service.js +480 -58
  118. package/dist/session/session.types.d.ts +161 -1
  119. package/dist/session/session.types.js +318 -3
  120. package/dist/shared/path/path.js +3 -1
  121. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  122. package/dist/shared/process/host-process-recovery.js +78 -0
  123. package/dist/shared/process/host-process-supervisor.d.ts +57 -0
  124. package/dist/shared/process/host-process-supervisor.js +282 -0
  125. package/dist/shared/process/process.d.ts +1 -0
  126. package/dist/shared/process/process.js +1 -0
  127. package/dist/shared/process/windows-launch.d.ts +8 -0
  128. package/dist/shared/process/windows-launch.js +41 -0
  129. package/dist/shared/provider-capabilities.d.ts +1 -0
  130. package/dist/shared/provider-capabilities.js +3 -0
  131. package/dist/shared/redaction/redaction.js +10 -3
  132. package/dist/version.d.ts +1 -1
  133. package/dist/version.js +1 -1
  134. package/dist/worktree/artifact-injector.d.ts +3 -2
  135. package/dist/worktree/artifact-injector.js +82 -10
  136. package/dist/worktree/dependency-warmer.d.ts +21 -1
  137. package/dist/worktree/dependency-warmer.js +138 -5
  138. package/dist/worktree/index.d.ts +1 -0
  139. package/dist/worktree/index.js +1 -0
  140. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  141. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  142. package/dist/worktree/worktree-paths.d.ts +4 -0
  143. package/dist/worktree/worktree-paths.js +30 -0
  144. package/dist/worktree/worktree.base.d.ts +3 -0
  145. package/dist/worktree/worktree.service.d.ts +9 -3
  146. package/dist/worktree/worktree.service.js +100 -52
  147. package/dist/worktree/worktree.types.d.ts +3 -1
  148. package/package.json +6 -3
  149. package/runtime/windows-job-supervisor.ps1 +356 -0
  150. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -0,0 +1,209 @@
1
+ import { spawn } from "node:child_process";
2
+ import { StringDecoder } from "node:string_decoder";
3
+ const terminationSigkillDelayMs = 5_000;
4
+ const forcedCloseSettleDelayMs = 5_000;
5
+ const expectedStdinCloseErrorCodes = new Set(["EPIPE", "ECONNRESET", "ERR_STREAM_DESTROYED"]);
6
+ function isExpectedStdinCloseError(error) {
7
+ return "code" in error
8
+ && typeof error.code === "string"
9
+ && expectedStdinCloseErrorCodes.has(error.code);
10
+ }
11
+ export async function runSandboxProcess(request) {
12
+ const limit = Math.max(1024, request.maxOutputBytes ?? 1024 * 1024);
13
+ return await new Promise((resolve, reject) => {
14
+ const child = spawn(request.command, [...request.args], {
15
+ cwd: request.cwd,
16
+ env: request.env,
17
+ shell: false,
18
+ windowsHide: true,
19
+ stdio: [request.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
20
+ });
21
+ let stdout = Buffer.alloc(0);
22
+ let stderr = Buffer.alloc(0);
23
+ let truncated = false;
24
+ let settled = false;
25
+ let terminating = false;
26
+ let discardStdoutLineCallbacks = false;
27
+ let stdoutText = "";
28
+ const stdoutDecoder = new StringDecoder("utf8");
29
+ let stdoutLineChain = Promise.resolve();
30
+ let stdoutLineError = null;
31
+ let timeout = null;
32
+ let sigkillTimer = null;
33
+ let forcedSettleTimer = null;
34
+ const append = (current, chunk) => {
35
+ if (current.length >= limit) {
36
+ truncated = true;
37
+ return current;
38
+ }
39
+ const remaining = limit - current.length;
40
+ if (chunk.length > remaining) {
41
+ truncated = true;
42
+ if (request.terminateOnOutputLimit)
43
+ terminate(`sandbox_log_limit: process output exceeded ${limit} bytes`);
44
+ }
45
+ return Buffer.concat([current, chunk.subarray(0, remaining)]);
46
+ };
47
+ const appendStderrLine = (line) => {
48
+ stderr = append(stderr, Buffer.from(`\n${line}\n`));
49
+ };
50
+ const cleanup = () => {
51
+ if (timeout)
52
+ clearTimeout(timeout);
53
+ if (sigkillTimer)
54
+ clearTimeout(sigkillTimer);
55
+ if (forcedSettleTimer)
56
+ clearTimeout(forcedSettleTimer);
57
+ child.stdout.removeListener("data", onStdoutData);
58
+ child.stderr.removeListener("data", onStderrData);
59
+ child.removeListener("error", onProcessError);
60
+ child.removeListener("close", onProcessClose);
61
+ request.signal?.removeEventListener("abort", abort);
62
+ };
63
+ const settle = async (code, force) => {
64
+ if (settled)
65
+ return;
66
+ settled = true;
67
+ discardStdoutLineCallbacks = force;
68
+ cleanup();
69
+ if (stdoutLineError) {
70
+ reject(stdoutLineError);
71
+ return;
72
+ }
73
+ if (!force) {
74
+ if (request.onStdoutLine)
75
+ stdoutText += stdoutDecoder.end();
76
+ if (request.onStdoutLine && stdoutText) {
77
+ stdoutLineChain = stdoutLineChain.then(() => request.onStdoutLine(stdoutText));
78
+ }
79
+ await stdoutLineChain.catch(error => { stdoutLineError ??= error; });
80
+ if (stdoutLineError) {
81
+ reject(stdoutLineError);
82
+ return;
83
+ }
84
+ }
85
+ resolve({
86
+ exitCode: code ?? 1,
87
+ stdout: stdout.toString("utf8"),
88
+ stderr: stderr.toString("utf8"),
89
+ truncated
90
+ });
91
+ };
92
+ const forceSettle = (reason) => {
93
+ if (settled)
94
+ return;
95
+ appendStderrLine(reason);
96
+ void settle(1, true);
97
+ };
98
+ const killChild = (signal) => {
99
+ if (child.exitCode !== null || child.signalCode !== null)
100
+ return;
101
+ try {
102
+ child.kill(signal);
103
+ }
104
+ catch {
105
+ // The process may have exited between timeout/abort and close. Still let the
106
+ // supervisor timers settle if close never arrives.
107
+ }
108
+ };
109
+ const terminate = (reason) => {
110
+ if (settled || terminating)
111
+ return;
112
+ terminating = true;
113
+ appendStderrLine(reason);
114
+ killChild("SIGTERM");
115
+ sigkillTimer = setTimeout(() => {
116
+ killChild("SIGKILL");
117
+ forcedSettleTimer = setTimeout(() => forceSettle("process did not close after SIGKILL; forcing supervisor settlement"), forcedCloseSettleDelayMs);
118
+ forcedSettleTimer.unref?.();
119
+ }, terminationSigkillDelayMs);
120
+ sigkillTimer.unref?.();
121
+ };
122
+ const abort = () => terminate(String(request.signal?.reason ?? "process aborted"));
123
+ const onStdoutData = (chunk) => {
124
+ if (settled)
125
+ return;
126
+ stdout = append(stdout, chunk);
127
+ if (!request.onStdoutLine || settled)
128
+ return;
129
+ stdoutText += stdoutDecoder.write(chunk);
130
+ if (Buffer.byteLength(stdoutText, "utf8") > 1024 * 1024 && !stdoutText.includes("\n")) {
131
+ stdoutLineError = new Error("sandbox process emitted an oversized stdout line");
132
+ terminate("oversized stdout line");
133
+ return;
134
+ }
135
+ const lines = stdoutText.split(/\r?\n/u);
136
+ stdoutText = lines.pop() ?? "";
137
+ if (lines.length === 0)
138
+ return;
139
+ child.stdout.pause();
140
+ for (const line of lines) {
141
+ stdoutLineChain = stdoutLineChain.then(async () => {
142
+ if (discardStdoutLineCallbacks)
143
+ return;
144
+ if (Buffer.byteLength(line, "utf8") > 1024 * 1024)
145
+ throw new Error("sandbox process emitted an oversized stdout line");
146
+ await request.onStdoutLine(line);
147
+ });
148
+ }
149
+ stdoutLineChain = stdoutLineChain
150
+ .catch(error => {
151
+ stdoutLineError = error;
152
+ terminate("stdout event handler failed");
153
+ })
154
+ .finally(() => {
155
+ if (!settled)
156
+ child.stdout.resume();
157
+ });
158
+ };
159
+ const onStderrData = (chunk) => {
160
+ if (!settled)
161
+ stderr = append(stderr, chunk);
162
+ };
163
+ const onProcessError = (error) => {
164
+ if (!settled) {
165
+ settled = true;
166
+ cleanup();
167
+ reject(error);
168
+ }
169
+ };
170
+ const onStdinError = (error) => {
171
+ if (isExpectedStdinCloseError(error))
172
+ return;
173
+ onProcessError(error);
174
+ };
175
+ const onProcessClose = (code) => {
176
+ void settle(code, false);
177
+ };
178
+ child.stdout.on("data", onStdoutData);
179
+ child.stderr.on("data", onStderrData);
180
+ child.on("error", onProcessError);
181
+ child.on("close", onProcessClose);
182
+ // Keep this listener for the lifetime of stdin. A pipe error can be emitted
183
+ // after the child close event has settled the supervisor promise.
184
+ child.stdin?.on("error", onStdinError);
185
+ timeout = request.timeoutMs
186
+ ? setTimeout(() => terminate(`process timeout after ${request.timeoutMs}ms`), request.timeoutMs)
187
+ : null;
188
+ timeout?.unref?.();
189
+ request.signal?.addEventListener("abort", abort, { once: true });
190
+ if (request.signal?.aborted) {
191
+ abort();
192
+ }
193
+ if (request.input !== undefined) {
194
+ try {
195
+ child.stdin.end(request.input);
196
+ }
197
+ catch (error) {
198
+ onStdinError(error instanceof Error ? error : new Error(String(error)));
199
+ }
200
+ }
201
+ });
202
+ }
203
+ export async function requireSandboxProcess(request) {
204
+ const result = await runSandboxProcess(request);
205
+ if (result.exitCode !== 0) {
206
+ throw new Error(`${request.command} ${request.args[0] ?? ""} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`}`);
207
+ }
208
+ return result;
209
+ }
@@ -0,0 +1,26 @@
1
+ export interface SandboxAuthDoctorResult {
2
+ readonly available: boolean;
3
+ readonly code: "ok" | "sandbox_auth_unavailable";
4
+ readonly message: string;
5
+ readonly authPath?: string;
6
+ }
7
+ export interface SandboxProviderCredentialFile {
8
+ readonly name: string;
9
+ readonly contentBase64: string;
10
+ }
11
+ export interface SandboxPreparedProviderAuth {
12
+ readonly files: readonly SandboxProviderCredentialFile[];
13
+ readonly containerMountPath: string;
14
+ readonly writable: boolean;
15
+ }
16
+ export interface SandboxProviderAuthAdapter {
17
+ readonly provider: "codex";
18
+ readonly authKind: "bridge_cli";
19
+ readonly containerMountPath: string;
20
+ readonly writable: boolean;
21
+ doctor(environment?: NodeJS.ProcessEnv): SandboxAuthDoctorResult;
22
+ prepareAttemptAuth(environment?: NodeJS.ProcessEnv): Promise<SandboxPreparedProviderAuth>;
23
+ }
24
+ export declare const codexSandboxProviderAuthAdapter: SandboxProviderAuthAdapter;
25
+ export declare function sandboxProviderAuthAdapterFor(provider: string, authKind: string): SandboxProviderAuthAdapter | null;
26
+ export declare function sandboxProviderAuthUnavailableMessage(provider: string, authKind: string): string | null;
@@ -0,0 +1,212 @@
1
+ import fs from "node:fs";
2
+ import fsp from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ const maxCredentialFileBytes = 1024 * 1024;
6
+ // Capability and heartbeat paths call doctor() synchronously; keep validation
7
+ // fresh enough for auth changes without rereading auth.json for every offer.
8
+ const authDoctorCacheTtlMs = 5_000;
9
+ let authDoctorCache = null;
10
+ export const codexSandboxProviderAuthAdapter = {
11
+ provider: "codex",
12
+ authKind: "bridge_cli",
13
+ containerMountPath: "/home/forge/.codex",
14
+ // Codex refreshes OAuth tokens in place, so the container mount is writable.
15
+ // The writable state is an attempt-scoped Docker volume copy only; no code path
16
+ // syncs modified container credentials back to the host ~/.codex directory.
17
+ writable: true,
18
+ doctor(environment = process.env) {
19
+ return codexAuthDoctor(environment);
20
+ },
21
+ async prepareAttemptAuth(environment = process.env) {
22
+ const doctor = codexAuthDoctor(environment);
23
+ if (!doctor.available || !doctor.authPath) {
24
+ throw new Error(`sandbox_auth_unavailable: ${doctor.message}`);
25
+ }
26
+ let content;
27
+ try {
28
+ content = await readValidatedCodexAuthFile(doctor.authPath);
29
+ }
30
+ catch (error) {
31
+ throw new Error(`sandbox_auth_unavailable: ${error instanceof Error ? error.message : "Codex Local CLI auth.json could not be validated."}`, { cause: error });
32
+ }
33
+ return {
34
+ files: [
35
+ {
36
+ name: "auth.json",
37
+ contentBase64: content.toString("base64")
38
+ }
39
+ ],
40
+ containerMountPath: this.containerMountPath,
41
+ writable: this.writable
42
+ };
43
+ }
44
+ };
45
+ export function sandboxProviderAuthAdapterFor(provider, authKind) {
46
+ if (authKind.trim().toLowerCase() !== "bridge_cli") {
47
+ return null;
48
+ }
49
+ return provider.trim().toLowerCase() === "codex"
50
+ ? codexSandboxProviderAuthAdapter
51
+ : null;
52
+ }
53
+ export function sandboxProviderAuthUnavailableMessage(provider, authKind) {
54
+ if (authKind.trim().toLowerCase() !== "bridge_cli") {
55
+ return null;
56
+ }
57
+ return provider.trim().toLowerCase() === "codex"
58
+ ? null
59
+ : `Provider ${provider} Local CLI auth is not portable into Docker yet.`;
60
+ }
61
+ function codexAuthDoctor(environment) {
62
+ const authPath = path.join(resolveCodexHome(environment), "auth.json");
63
+ const now = Date.now();
64
+ if (authDoctorCache?.authPath === authPath && authDoctorCache.expiresAt > now) {
65
+ return authDoctorCache.result;
66
+ }
67
+ let result;
68
+ try {
69
+ readValidatedCodexAuthFileSync(authPath);
70
+ result = {
71
+ available: true,
72
+ code: "ok",
73
+ message: "Codex Local CLI auth is portable into an attempt-scoped Docker volume.",
74
+ authPath
75
+ };
76
+ }
77
+ catch (error) {
78
+ result = unavailable(error instanceof Error ? error.message : "Codex Local CLI auth.json could not be validated.");
79
+ }
80
+ authDoctorCache = {
81
+ authPath,
82
+ expiresAt: now + authDoctorCacheTtlMs,
83
+ result
84
+ };
85
+ return result;
86
+ }
87
+ async function readValidatedCodexAuthFile(authPath) {
88
+ let pathStat;
89
+ try {
90
+ pathStat = await fsp.lstat(authPath);
91
+ }
92
+ catch {
93
+ throw new Error("Codex Local CLI auth is not available on the bridge host.");
94
+ }
95
+ validateCodexAuthStat(pathStat);
96
+ let handle;
97
+ try {
98
+ handle = await fsp.open(authPath, codexAuthOpenFlags());
99
+ }
100
+ catch (error) {
101
+ throw codexAuthOpenError(error);
102
+ }
103
+ try {
104
+ const openedStat = await handle.stat();
105
+ validateSameCodexAuthFile(pathStat, openedStat);
106
+ validateCodexAuthStat(openedStat);
107
+ const content = await handle.readFile();
108
+ const afterStat = await handle.stat();
109
+ validateSameCodexAuthFile(openedStat, afterStat);
110
+ validateCodexAuthStat(afterStat);
111
+ validateCodexAuthReadSize(content, openedStat, afterStat);
112
+ validateCodexAuthJson(content);
113
+ return content;
114
+ }
115
+ finally {
116
+ await handle.close();
117
+ }
118
+ }
119
+ function readValidatedCodexAuthFileSync(authPath) {
120
+ let pathStat;
121
+ try {
122
+ pathStat = fs.lstatSync(authPath);
123
+ }
124
+ catch {
125
+ throw new Error("Codex Local CLI auth is not available on the bridge host.");
126
+ }
127
+ validateCodexAuthStat(pathStat);
128
+ let fd;
129
+ try {
130
+ fd = fs.openSync(authPath, codexAuthOpenFlags());
131
+ }
132
+ catch (error) {
133
+ throw codexAuthOpenError(error);
134
+ }
135
+ try {
136
+ const openedStat = fs.fstatSync(fd);
137
+ validateSameCodexAuthFile(pathStat, openedStat);
138
+ validateCodexAuthStat(openedStat);
139
+ const content = fs.readFileSync(fd);
140
+ const afterStat = fs.fstatSync(fd);
141
+ validateSameCodexAuthFile(openedStat, afterStat);
142
+ validateCodexAuthStat(afterStat);
143
+ validateCodexAuthReadSize(content, openedStat, afterStat);
144
+ validateCodexAuthJson(content);
145
+ return content;
146
+ }
147
+ finally {
148
+ fs.closeSync(fd);
149
+ }
150
+ }
151
+ function codexAuthOpenFlags() {
152
+ return fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW;
153
+ }
154
+ function validateCodexAuthStat(stat) {
155
+ if (stat.isSymbolicLink()) {
156
+ throw new Error("Codex Local CLI auth.json must not be a symlink.");
157
+ }
158
+ if (!stat.isFile()) {
159
+ throw new Error("Codex Local CLI auth.json is not a regular file.");
160
+ }
161
+ if (stat.size <= 0) {
162
+ throw new Error("Codex Local CLI auth.json is empty.");
163
+ }
164
+ if (stat.size > maxCredentialFileBytes) {
165
+ throw new Error("Codex Local CLI auth.json exceeds the 1 MiB sandbox auth limit.");
166
+ }
167
+ }
168
+ function validateSameCodexAuthFile(before, after) {
169
+ if (before.dev !== 0 && before.ino !== 0 && after.dev !== 0 && after.ino !== 0
170
+ && (before.dev !== after.dev || before.ino !== after.ino)) {
171
+ throw new Error("Codex Local CLI auth.json changed while being copied.");
172
+ }
173
+ }
174
+ function validateCodexAuthReadSize(content, openedStat, afterStat) {
175
+ if (openedStat.size !== afterStat.size || content.length !== afterStat.size) {
176
+ throw new Error("Codex Local CLI auth.json changed while being copied.");
177
+ }
178
+ }
179
+ function validateCodexAuthJson(content) {
180
+ try {
181
+ JSON.parse(content.toString("utf8"));
182
+ }
183
+ catch {
184
+ throw new Error("Codex Local CLI auth.json is not valid JSON.");
185
+ }
186
+ }
187
+ function codexAuthOpenError(error) {
188
+ const code = typeof error === "object" && error !== null && "code" in error
189
+ ? error.code
190
+ : undefined;
191
+ if (code === "ELOOP") {
192
+ return new Error("Codex Local CLI auth.json must not be a symlink.");
193
+ }
194
+ if (code === "ENOENT") {
195
+ return new Error("Codex Local CLI auth is not available on the bridge host.");
196
+ }
197
+ if (code === "EACCES" || code === "EPERM") {
198
+ return new Error("Codex Local CLI auth.json could not be read.");
199
+ }
200
+ return error instanceof Error ? error : new Error("Codex Local CLI auth.json could not be read.");
201
+ }
202
+ function unavailable(message) {
203
+ return {
204
+ available: false,
205
+ code: "sandbox_auth_unavailable",
206
+ message
207
+ };
208
+ }
209
+ function resolveCodexHome(environment) {
210
+ const configured = environment.FORGE_CODEX_HOME?.trim();
211
+ return configured ? path.resolve(configured) : path.join(os.homedir(), ".codex");
212
+ }
@@ -0,0 +1,5 @@
1
+ export declare class RepositoryCoordinatorLock {
2
+ private readonly stateRoot;
3
+ constructor(stateRoot: string);
4
+ withLock<T>(repoPath: string, action: () => Promise<T>, signal?: AbortSignal): Promise<T>;
5
+ }
@@ -0,0 +1,119 @@
1
+ import { createHash, 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
+ const LOCK_HEARTBEAT_INTERVAL_MS = 30_000;
6
+ const LOCK_STALE_AFTER_MS = 2 * 60_000;
7
+ export class RepositoryCoordinatorLock {
8
+ stateRoot;
9
+ constructor(stateRoot) {
10
+ this.stateRoot = stateRoot;
11
+ }
12
+ async withLock(repoPath, action, signal) {
13
+ const resolved = path.resolve(repoPath);
14
+ const key = createHash("sha256").update(resolved.toLowerCase()).digest("hex");
15
+ const root = path.join(this.stateRoot, "repo-locks");
16
+ const target = path.join(root, `${key}.lock`);
17
+ const token = randomUUID();
18
+ await fs.mkdir(root, { recursive: true, mode: 0o700 });
19
+ const started = Date.now();
20
+ while (true) {
21
+ if (signal?.aborted)
22
+ throw new Error("sandbox_import_cancelled: repository coordination was cancelled.");
23
+ try {
24
+ const handle = await fs.open(target, "wx", 0o600);
25
+ try {
26
+ await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, token, pid: process.pid, repoPath: resolved, acquiredAt: new Date().toISOString() })}\n`);
27
+ await handle.sync();
28
+ }
29
+ finally {
30
+ await handle.close();
31
+ }
32
+ break;
33
+ }
34
+ catch (error) {
35
+ if (error.code !== "EEXIST")
36
+ throw error;
37
+ if (await staleLock(target)) {
38
+ await fs.rm(target, { force: true });
39
+ continue;
40
+ }
41
+ if (Date.now() - started >= 2 * 60_000) {
42
+ throw new Error("sandbox_import_unavailable: repository coordinator lock timed out.", { cause: error });
43
+ }
44
+ await abortableDelay(50, signal);
45
+ }
46
+ }
47
+ const heartbeat = startHeartbeat(target, token);
48
+ try {
49
+ return await action();
50
+ }
51
+ finally {
52
+ clearInterval(heartbeat);
53
+ const owner = await readOwner(target);
54
+ if (owner?.token === token)
55
+ await fs.rm(target, { force: true });
56
+ }
57
+ }
58
+ }
59
+ async function staleLock(target) {
60
+ const stat = await fs.lstat(target).catch(() => null);
61
+ if (!stat || stat.isSymbolicLink() || !stat.isFile() || stat.size > 16 * 1024)
62
+ return true;
63
+ const owner = await readOwner(target);
64
+ if (!owner || typeof owner.pid !== "number" || !Number.isInteger(owner.pid) || owner.pid <= 0)
65
+ return true;
66
+ if (Date.now() - stat.mtimeMs >= LOCK_STALE_AFTER_MS)
67
+ return true;
68
+ try {
69
+ process.kill(owner.pid, 0);
70
+ return false;
71
+ }
72
+ catch {
73
+ return true;
74
+ }
75
+ }
76
+ function startHeartbeat(target, token) {
77
+ let refreshing = false;
78
+ const timer = setInterval(() => {
79
+ if (refreshing)
80
+ return;
81
+ refreshing = true;
82
+ void refreshHeartbeat(target, token).finally(() => { refreshing = false; });
83
+ }, LOCK_HEARTBEAT_INTERVAL_MS);
84
+ timer.unref?.();
85
+ return timer;
86
+ }
87
+ async function refreshHeartbeat(target, token) {
88
+ let handle = null;
89
+ try {
90
+ handle = await fs.open(target, fsConstants.O_RDWR | fsConstants.O_NOFOLLOW);
91
+ const stat = await handle.stat();
92
+ if (!stat.isFile() || stat.size > 16 * 1024)
93
+ return;
94
+ const owner = await handle.readFile("utf8").then(value => JSON.parse(value)).catch(() => null);
95
+ if (owner?.token !== token)
96
+ return;
97
+ const now = new Date();
98
+ await handle.utimes(now, now);
99
+ }
100
+ catch {
101
+ // A missing or replaced lock is no longer owned by this action.
102
+ }
103
+ finally {
104
+ await handle?.close().catch(() => undefined);
105
+ }
106
+ }
107
+ async function readOwner(target) {
108
+ return await fs.readFile(target, "utf8").then(value => JSON.parse(value)).catch(() => null);
109
+ }
110
+ async function abortableDelay(delayMs, signal) {
111
+ if (!signal)
112
+ return await new Promise(resolve => setTimeout(resolve, delayMs));
113
+ await new Promise((resolve, reject) => {
114
+ const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, delayMs);
115
+ const abort = () => { clearTimeout(timer); reject(new Error("sandbox_import_cancelled: repository coordination was cancelled.")); };
116
+ signal.addEventListener("abort", abort, { once: true });
117
+ timer.unref?.();
118
+ });
119
+ }