@evermore.work/adapter-utils 2026.509.0-canary.0
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.
- package/dist/billing.d.ts +2 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +16 -0
- package/dist/billing.js.map +1 -0
- package/dist/billing.test.d.ts +2 -0
- package/dist/billing.test.d.ts.map +1 -0
- package/dist/billing.test.js +14 -0
- package/dist/billing.test.js.map +1 -0
- package/dist/command-managed-runtime.d.ts +45 -0
- package/dist/command-managed-runtime.d.ts.map +1 -0
- package/dist/command-managed-runtime.js +164 -0
- package/dist/command-managed-runtime.js.map +1 -0
- package/dist/command-managed-runtime.test.d.ts +2 -0
- package/dist/command-managed-runtime.test.d.ts.map +1 -0
- package/dist/command-managed-runtime.test.js +102 -0
- package/dist/command-managed-runtime.test.js.map +1 -0
- package/dist/command-redaction.d.ts +3 -0
- package/dist/command-redaction.d.ts.map +1 -0
- package/dist/command-redaction.js +17 -0
- package/dist/command-redaction.js.map +1 -0
- package/dist/execution-target-sandbox.test.d.ts +2 -0
- package/dist/execution-target-sandbox.test.d.ts.map +1 -0
- package/dist/execution-target-sandbox.test.js +392 -0
- package/dist/execution-target-sandbox.test.js.map +1 -0
- package/dist/execution-target.d.ts +150 -0
- package/dist/execution-target.d.ts.map +1 -0
- package/dist/execution-target.js +791 -0
- package/dist/execution-target.js.map +1 -0
- package/dist/execution-target.test.d.ts +2 -0
- package/dist/execution-target.test.d.ts.map +1 -0
- package/dist/execution-target.test.js +314 -0
- package/dist/execution-target.test.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/log-redaction.d.ts +9 -0
- package/dist/log-redaction.d.ts.map +1 -0
- package/dist/log-redaction.js +88 -0
- package/dist/log-redaction.js.map +1 -0
- package/dist/remote-execution-env.d.ts +2 -0
- package/dist/remote-execution-env.d.ts.map +1 -0
- package/dist/remote-execution-env.js +46 -0
- package/dist/remote-execution-env.js.map +1 -0
- package/dist/remote-managed-runtime.d.ts +31 -0
- package/dist/remote-managed-runtime.d.ts.map +1 -0
- package/dist/remote-managed-runtime.js +81 -0
- package/dist/remote-managed-runtime.js.map +1 -0
- package/dist/sandbox-callback-bridge.d.ts +132 -0
- package/dist/sandbox-callback-bridge.d.ts.map +1 -0
- package/dist/sandbox-callback-bridge.js +925 -0
- package/dist/sandbox-callback-bridge.js.map +1 -0
- package/dist/sandbox-callback-bridge.test.d.ts +2 -0
- package/dist/sandbox-callback-bridge.test.d.ts.map +1 -0
- package/dist/sandbox-callback-bridge.test.js +719 -0
- package/dist/sandbox-callback-bridge.test.js.map +1 -0
- package/dist/sandbox-managed-runtime.d.ts +54 -0
- package/dist/sandbox-managed-runtime.d.ts.map +1 -0
- package/dist/sandbox-managed-runtime.js +234 -0
- package/dist/sandbox-managed-runtime.js.map +1 -0
- package/dist/sandbox-managed-runtime.test.d.ts +2 -0
- package/dist/sandbox-managed-runtime.test.d.ts.map +1 -0
- package/dist/sandbox-managed-runtime.test.js +118 -0
- package/dist/sandbox-managed-runtime.test.js.map +1 -0
- package/dist/sandbox-shell.d.ts +2 -0
- package/dist/sandbox-shell.d.ts.map +1 -0
- package/dist/sandbox-shell.js +4 -0
- package/dist/sandbox-shell.js.map +1 -0
- package/dist/server-utils.d.ts +253 -0
- package/dist/server-utils.d.ts.map +1 -0
- package/dist/server-utils.js +1522 -0
- package/dist/server-utils.js.map +1 -0
- package/dist/server-utils.test.d.ts +2 -0
- package/dist/server-utils.test.d.ts.map +1 -0
- package/dist/server-utils.test.js +685 -0
- package/dist/server-utils.test.js.map +1 -0
- package/dist/session-compaction.d.ts +25 -0
- package/dist/session-compaction.d.ts.map +1 -0
- package/dist/session-compaction.js +154 -0
- package/dist/session-compaction.js.map +1 -0
- package/dist/ssh-fixture.test.d.ts +2 -0
- package/dist/ssh-fixture.test.d.ts.map +1 -0
- package/dist/ssh-fixture.test.js +214 -0
- package/dist/ssh-fixture.test.js.map +1 -0
- package/dist/ssh.d.ts +111 -0
- package/dist/ssh.d.ts.map +1 -0
- package/dist/ssh.js +1098 -0
- package/dist/ssh.js.map +1 -0
- package/dist/types.d.ts +465 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +41 -0
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { preferredShellForSandbox } from "./sandbox-shell.js";
|
|
6
|
+
const DEFAULT_BRIDGE_TOKEN_BYTES = 24;
|
|
7
|
+
const DEFAULT_BRIDGE_POLL_INTERVAL_MS = 100;
|
|
8
|
+
const DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS = 30_000;
|
|
9
|
+
const DEFAULT_BRIDGE_STOP_TIMEOUT_MS = 2_000;
|
|
10
|
+
const DEFAULT_BRIDGE_MAX_QUEUE_DEPTH = 64;
|
|
11
|
+
const DEFAULT_BRIDGE_MAX_BODY_BYTES = 256 * 1024;
|
|
12
|
+
const REMOTE_WRITE_BASE64_CHUNK_SIZE = 32 * 1024;
|
|
13
|
+
const SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT = "evermore-bridge-server.mjs";
|
|
14
|
+
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES = DEFAULT_BRIDGE_MAX_BODY_BYTES;
|
|
15
|
+
// Routes the in-sandbox heartbeat skill is documented to call. The server
|
|
16
|
+
// still enforces actor-level permissions on top of this allowlist; the list
|
|
17
|
+
// exists to bound the surface area a compromised CLI could reach via the
|
|
18
|
+
// reverse bridge. Keep this in sync with the Evermore skill in
|
|
19
|
+
// `skills/evermore/SKILL.md` and `references/api-reference.md`.
|
|
20
|
+
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST = [
|
|
21
|
+
// Identity, inbox, agent self-management
|
|
22
|
+
{ method: "GET", path: /^\/api\/agents\/me$/ },
|
|
23
|
+
{ method: "GET", path: /^\/api\/agents\/me\/inbox-lite$/ },
|
|
24
|
+
{ method: "GET", path: /^\/api\/agents\/me\/inbox\/mine$/ },
|
|
25
|
+
{ method: "GET", path: /^\/api\/agents\/[^/]+$/ },
|
|
26
|
+
{ method: "GET", path: /^\/api\/agents\/[^/]+\/skills$/ },
|
|
27
|
+
{ method: "POST", path: /^\/api\/agents\/[^/]+\/skills\/sync$/ },
|
|
28
|
+
{ method: "PATCH", path: /^\/api\/agents\/[^/]+\/instructions-path$/ },
|
|
29
|
+
// Company-level reads used to discover work and context
|
|
30
|
+
{ method: "GET", path: /^\/api\/companies\/[^/]+$/ },
|
|
31
|
+
{ method: "GET", path: /^\/api\/companies\/[^/]+\/dashboard$/ },
|
|
32
|
+
{ method: "GET", path: /^\/api\/companies\/[^/]+\/agents$/ },
|
|
33
|
+
{ method: "GET", path: /^\/api\/companies\/[^/]+\/issues$/ },
|
|
34
|
+
{ method: "GET", path: /^\/api\/companies\/[^/]+\/projects$/ },
|
|
35
|
+
{ method: "GET", path: /^\/api\/companies\/[^/]+\/goals$/ },
|
|
36
|
+
{ method: "GET", path: /^\/api\/companies\/[^/]+\/org$/ },
|
|
37
|
+
{ method: "GET", path: /^\/api\/companies\/[^/]+\/approvals$/ },
|
|
38
|
+
{ method: "GET", path: /^\/api\/companies\/[^/]+\/routines$/ },
|
|
39
|
+
{ method: "GET", path: /^\/api\/companies\/[^/]+\/skills$/ },
|
|
40
|
+
{ method: "GET", path: /^\/api\/projects\/[^/]+$/ },
|
|
41
|
+
{ method: "GET", path: /^\/api\/goals\/[^/]+$/ },
|
|
42
|
+
// Issue lifecycle: read context, checkout, update, comment, document, release
|
|
43
|
+
{ method: "GET", path: /^\/api\/issues\/[^/]+$/ },
|
|
44
|
+
{ method: "GET", path: /^\/api\/issues\/[^/]+\/heartbeat-context$/ },
|
|
45
|
+
{ method: "GET", path: /^\/api\/issues\/[^/]+\/comments(?:\/[^/]+)?$/ },
|
|
46
|
+
{ method: "POST", path: /^\/api\/issues\/[^/]+\/comments$/ },
|
|
47
|
+
{ method: "GET", path: /^\/api\/issues\/[^/]+\/documents(?:\/[^/]+)?$/ },
|
|
48
|
+
{ method: "GET", path: /^\/api\/issues\/[^/]+\/documents\/[^/]+\/revisions$/ },
|
|
49
|
+
{ method: "PUT", path: /^\/api\/issues\/[^/]+\/documents\/[^/]+$/ },
|
|
50
|
+
{ method: "POST", path: /^\/api\/issues\/[^/]+\/checkout$/ },
|
|
51
|
+
{ method: "POST", path: /^\/api\/issues\/[^/]+\/release$/ },
|
|
52
|
+
{ method: "PATCH", path: /^\/api\/issues\/[^/]+$/ },
|
|
53
|
+
{ method: "GET", path: /^\/api\/issues\/[^/]+\/approvals$/ },
|
|
54
|
+
// Issue-thread interactions (suggest tasks, ask questions, request confirmation)
|
|
55
|
+
{ method: "GET", path: /^\/api\/issues\/[^/]+\/interactions(?:\/[^/]+)?$/ },
|
|
56
|
+
{ method: "POST", path: /^\/api\/issues\/[^/]+\/interactions$/ },
|
|
57
|
+
{ method: "POST", path: /^\/api\/issues\/[^/]+\/interactions\/[^/]+\/(?:accept|reject|respond)$/ },
|
|
58
|
+
// Subtasks / delegation
|
|
59
|
+
{ method: "POST", path: /^\/api\/companies\/[^/]+\/issues$/ },
|
|
60
|
+
// Approvals (request, read, comment)
|
|
61
|
+
{ method: "GET", path: /^\/api\/approvals\/[^/]+$/ },
|
|
62
|
+
{ method: "GET", path: /^\/api\/approvals\/[^/]+\/issues$/ },
|
|
63
|
+
{ method: "GET", path: /^\/api\/approvals\/[^/]+\/comments$/ },
|
|
64
|
+
{ method: "POST", path: /^\/api\/approvals\/[^/]+\/comments$/ },
|
|
65
|
+
{ method: "POST", path: /^\/api\/companies\/[^/]+\/approvals$/ },
|
|
66
|
+
// Execution workspaces and runtime services (start/stop/restart dev servers)
|
|
67
|
+
{ method: "GET", path: /^\/api\/execution-workspaces\/[^/]+$/ },
|
|
68
|
+
{ method: "POST", path: /^\/api\/execution-workspaces\/[^/]+\/runtime-services\/(?:start|stop|restart)$/ },
|
|
69
|
+
// Routines (agents manage their own routines and triggers)
|
|
70
|
+
{ method: "GET", path: /^\/api\/routines\/[^/]+$/ },
|
|
71
|
+
{ method: "GET", path: /^\/api\/routines\/[^/]+\/runs$/ },
|
|
72
|
+
{ method: "POST", path: /^\/api\/companies\/[^/]+\/routines$/ },
|
|
73
|
+
{ method: "PATCH", path: /^\/api\/routines\/[^/]+$/ },
|
|
74
|
+
{ method: "POST", path: /^\/api\/routines\/[^/]+\/run$/ },
|
|
75
|
+
{ method: "POST", path: /^\/api\/routines\/[^/]+\/triggers$/ },
|
|
76
|
+
{ method: "PATCH", path: /^\/api\/routine-triggers\/[^/]+$/ },
|
|
77
|
+
{ method: "DELETE", path: /^\/api\/routine-triggers\/[^/]+$/ },
|
|
78
|
+
];
|
|
79
|
+
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST = [
|
|
80
|
+
"accept",
|
|
81
|
+
"content-type",
|
|
82
|
+
"if-match",
|
|
83
|
+
"if-none-match",
|
|
84
|
+
];
|
|
85
|
+
function shellQuote(value) {
|
|
86
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
87
|
+
}
|
|
88
|
+
function normalizeMethod(value) {
|
|
89
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim().toUpperCase() : "GET";
|
|
90
|
+
}
|
|
91
|
+
function normalizeTimeoutMs(value, fallback) {
|
|
92
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
|
|
93
|
+
}
|
|
94
|
+
function toBuffer(bytes) {
|
|
95
|
+
if (Buffer.isBuffer(bytes))
|
|
96
|
+
return bytes;
|
|
97
|
+
if (bytes instanceof ArrayBuffer)
|
|
98
|
+
return Buffer.from(bytes);
|
|
99
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
100
|
+
}
|
|
101
|
+
function buildRunnerFailureMessage(action, result) {
|
|
102
|
+
const stderr = result.stderr.trim();
|
|
103
|
+
const stdout = result.stdout.trim();
|
|
104
|
+
const detail = stderr || stdout;
|
|
105
|
+
if (result.timedOut) {
|
|
106
|
+
return `${action} timed out${detail ? `: ${detail}` : ""}`;
|
|
107
|
+
}
|
|
108
|
+
return `${action} failed with exit code ${result.exitCode ?? "null"}${detail ? `: ${detail}` : ""}`;
|
|
109
|
+
}
|
|
110
|
+
async function runShell(runner, cwd, script, timeoutMs, shellCommand = "sh", stdin) {
|
|
111
|
+
return await runner.execute({
|
|
112
|
+
command: shellCommand,
|
|
113
|
+
args: ["-lc", script],
|
|
114
|
+
cwd,
|
|
115
|
+
timeoutMs,
|
|
116
|
+
stdin,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function requireSuccessfulResult(action, result) {
|
|
120
|
+
if (!result.timedOut && result.exitCode === 0)
|
|
121
|
+
return result;
|
|
122
|
+
throw new Error(buildRunnerFailureMessage(action, result));
|
|
123
|
+
}
|
|
124
|
+
function base64Chunks(body) {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (let offset = 0; offset < body.length; offset += REMOTE_WRITE_BASE64_CHUNK_SIZE) {
|
|
127
|
+
out.push(body.slice(offset, offset + REMOTE_WRITE_BASE64_CHUNK_SIZE));
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
async function pathExists(filePath) {
|
|
132
|
+
return await fs.stat(filePath).then(() => true).catch(() => false);
|
|
133
|
+
}
|
|
134
|
+
function buildRemotePidLockAcquireScript(lockDirExpr, timeoutMessage) {
|
|
135
|
+
return [
|
|
136
|
+
"attempts=0",
|
|
137
|
+
`while ! mkdir ${lockDirExpr} 2>/dev/null; do`,
|
|
138
|
+
" holder_pid=\"\"",
|
|
139
|
+
` if [ -s ${lockDirExpr}/pid ]; then`,
|
|
140
|
+
` holder_pid="$(cat ${lockDirExpr}/pid 2>/dev/null || true)"`,
|
|
141
|
+
" fi",
|
|
142
|
+
" if [ -n \"$holder_pid\" ] && ! kill -0 \"$holder_pid\" 2>/dev/null; then",
|
|
143
|
+
` rm -rf ${lockDirExpr}`,
|
|
144
|
+
" continue",
|
|
145
|
+
" fi",
|
|
146
|
+
" attempts=$((attempts + 1))",
|
|
147
|
+
" if [ \"$attempts\" -ge 600 ]; then",
|
|
148
|
+
` echo ${shellQuote(timeoutMessage)} >&2`,
|
|
149
|
+
" exit 1",
|
|
150
|
+
" fi",
|
|
151
|
+
" sleep 0.05",
|
|
152
|
+
"done",
|
|
153
|
+
`printf '%s\\n' "$$" > ${lockDirExpr}/pid`,
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
function buildRemotePidLockCleanupScript(lockDirExpr, cleanupLines) {
|
|
157
|
+
return [
|
|
158
|
+
"cleanup() {",
|
|
159
|
+
...cleanupLines.map((line) => ` ${line}`),
|
|
160
|
+
` rm -rf ${lockDirExpr}`,
|
|
161
|
+
"}",
|
|
162
|
+
"trap cleanup EXIT INT TERM",
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
export function createSandboxCallbackBridgeToken(bytes = DEFAULT_BRIDGE_TOKEN_BYTES) {
|
|
166
|
+
return randomBytes(bytes).toString("base64url");
|
|
167
|
+
}
|
|
168
|
+
export function authorizeSandboxCallbackBridgeRequestWithRoutes(request, routes = DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST) {
|
|
169
|
+
const method = normalizeMethod(request.method);
|
|
170
|
+
return routes.some((route) => route.method === method && route.path.test(request.path))
|
|
171
|
+
? null
|
|
172
|
+
: `Route not allowed: ${method} ${request.path}`;
|
|
173
|
+
}
|
|
174
|
+
export function sanitizeSandboxCallbackBridgeHeaders(headers, allowlist = DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST) {
|
|
175
|
+
const allowed = new Set(allowlist.map((header) => header.toLowerCase()));
|
|
176
|
+
return Object.fromEntries(Object.entries(headers).filter(([key]) => allowed.has(key.toLowerCase())));
|
|
177
|
+
}
|
|
178
|
+
export function sandboxCallbackBridgeDirectories(rootDir) {
|
|
179
|
+
return {
|
|
180
|
+
rootDir,
|
|
181
|
+
requestsDir: path.posix.join(rootDir, "requests"),
|
|
182
|
+
responsesDir: path.posix.join(rootDir, "responses"),
|
|
183
|
+
logsDir: path.posix.join(rootDir, "logs"),
|
|
184
|
+
readyFile: path.posix.join(rootDir, "ready.json"),
|
|
185
|
+
pidFile: path.posix.join(rootDir, "server.pid"),
|
|
186
|
+
logFile: path.posix.join(rootDir, "logs", "bridge.log"),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export function buildSandboxCallbackBridgeEnv(input) {
|
|
190
|
+
return {
|
|
191
|
+
EVERMORE_API_BRIDGE_MODE: "queue_v1",
|
|
192
|
+
EVERMORE_BRIDGE_QUEUE_DIR: input.queueDir,
|
|
193
|
+
EVERMORE_BRIDGE_TOKEN: input.bridgeToken,
|
|
194
|
+
EVERMORE_BRIDGE_HOST: input.host?.trim() || "127.0.0.1",
|
|
195
|
+
EVERMORE_BRIDGE_PORT: String(input.port && input.port > 0 ? Math.trunc(input.port) : 0),
|
|
196
|
+
EVERMORE_BRIDGE_POLL_INTERVAL_MS: String(normalizeTimeoutMs(input.pollIntervalMs, DEFAULT_BRIDGE_POLL_INTERVAL_MS)),
|
|
197
|
+
EVERMORE_BRIDGE_RESPONSE_TIMEOUT_MS: String(normalizeTimeoutMs(input.responseTimeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS)),
|
|
198
|
+
EVERMORE_BRIDGE_MAX_QUEUE_DEPTH: String(normalizeTimeoutMs(input.maxQueueDepth, DEFAULT_BRIDGE_MAX_QUEUE_DEPTH)),
|
|
199
|
+
EVERMORE_BRIDGE_MAX_BODY_BYTES: String(normalizeTimeoutMs(input.maxBodyBytes, DEFAULT_BRIDGE_MAX_BODY_BYTES)),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
export async function createSandboxCallbackBridgeAsset() {
|
|
203
|
+
const localDir = await fs.mkdtemp(path.join(os.tmpdir(), "evermore-bridge-asset-"));
|
|
204
|
+
const entrypoint = path.join(localDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
|
|
205
|
+
await fs.writeFile(entrypoint, getSandboxCallbackBridgeServerSource(), "utf8");
|
|
206
|
+
return {
|
|
207
|
+
localDir,
|
|
208
|
+
entrypoint,
|
|
209
|
+
cleanup: async () => {
|
|
210
|
+
await fs.rm(localDir, { recursive: true, force: true }).catch(() => undefined);
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
export function createFileSystemSandboxCallbackBridgeQueueClient() {
|
|
215
|
+
return {
|
|
216
|
+
makeDir: async (remotePath) => {
|
|
217
|
+
await fs.mkdir(remotePath, { recursive: true });
|
|
218
|
+
},
|
|
219
|
+
listJsonFiles: async (remotePath) => {
|
|
220
|
+
const entries = await fs.readdir(remotePath, { withFileTypes: true }).catch(() => []);
|
|
221
|
+
return entries
|
|
222
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
223
|
+
.map((entry) => entry.name)
|
|
224
|
+
.sort((left, right) => left.localeCompare(right));
|
|
225
|
+
},
|
|
226
|
+
readTextFile: async (remotePath) => await fs.readFile(remotePath, "utf8"),
|
|
227
|
+
writeTextFile: async (remotePath, body) => {
|
|
228
|
+
await fs.mkdir(path.posix.dirname(remotePath), { recursive: true });
|
|
229
|
+
await fs.writeFile(remotePath, body, "utf8");
|
|
230
|
+
},
|
|
231
|
+
writeResponseFile: async (responsePath, body, options = {}) => {
|
|
232
|
+
const responseDir = path.posix.dirname(responsePath);
|
|
233
|
+
const tempPath = `${responsePath}.tmp`;
|
|
234
|
+
const lockDir = `${responsePath}.evermore-write.lock`;
|
|
235
|
+
const lockPidFile = `${lockDir}/pid`;
|
|
236
|
+
if (options.requestPath) {
|
|
237
|
+
const requestExists = await pathExists(options.requestPath);
|
|
238
|
+
if (!requestExists) {
|
|
239
|
+
return { wrote: false };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
await fs.mkdir(responseDir, { recursive: true });
|
|
243
|
+
// PID-liveness mkdir-mutex: mirrors the shell-based bridge mutex so a
|
|
244
|
+
// crashed holder (SIGKILL / OOM) doesn't deadlock subsequent writers
|
|
245
|
+
// for the full timeout window.
|
|
246
|
+
let attempts = 0;
|
|
247
|
+
while (true) {
|
|
248
|
+
try {
|
|
249
|
+
await fs.mkdir(lockDir);
|
|
250
|
+
await fs.writeFile(lockPidFile, `${process.pid}\n`, "utf8");
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
const code = error?.code;
|
|
255
|
+
if (code !== "EEXIST") {
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
let holderPid = null;
|
|
259
|
+
try {
|
|
260
|
+
const raw = await fs.readFile(lockPidFile, "utf8");
|
|
261
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
262
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
263
|
+
holderPid = parsed;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// pid file missing or unreadable — treat as stale lock
|
|
267
|
+
}
|
|
268
|
+
let holderAlive = false;
|
|
269
|
+
if (holderPid !== null) {
|
|
270
|
+
try {
|
|
271
|
+
process.kill(holderPid, 0);
|
|
272
|
+
holderAlive = true;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
holderAlive = false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (!holderAlive) {
|
|
279
|
+
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
attempts += 1;
|
|
283
|
+
if (attempts >= 600) {
|
|
284
|
+
throw new Error("Timed out acquiring sandbox callback bridge response lock.");
|
|
285
|
+
}
|
|
286
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
if (options.requestPath) {
|
|
291
|
+
const requestExists = await pathExists(options.requestPath);
|
|
292
|
+
if (!requestExists) {
|
|
293
|
+
return { wrote: false };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const responseExists = await pathExists(responsePath);
|
|
297
|
+
if (responseExists) {
|
|
298
|
+
return { wrote: false };
|
|
299
|
+
}
|
|
300
|
+
await fs.writeFile(tempPath, body, "utf8");
|
|
301
|
+
await fs.rename(tempPath, responsePath);
|
|
302
|
+
return { wrote: true };
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
306
|
+
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
rename: async (fromPath, toPath) => {
|
|
310
|
+
await fs.mkdir(path.posix.dirname(toPath), { recursive: true });
|
|
311
|
+
await fs.rename(fromPath, toPath);
|
|
312
|
+
},
|
|
313
|
+
remove: async (remotePath) => {
|
|
314
|
+
await fs.rm(remotePath, { recursive: true, force: true }).catch(() => undefined);
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
export function createCommandManagedSandboxCallbackBridgeQueueClient(input) {
|
|
319
|
+
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
|
|
320
|
+
const shellCommand = preferredShellForSandbox(input.shellCommand);
|
|
321
|
+
const runChecked = async (action, script) => requireSuccessfulResult(action, await runShell(input.runner, input.remoteCwd, script, timeoutMs, shellCommand));
|
|
322
|
+
return {
|
|
323
|
+
makeDir: async (remotePath) => {
|
|
324
|
+
await runChecked(`mkdir ${remotePath}`, `mkdir -p ${shellQuote(remotePath)}`);
|
|
325
|
+
},
|
|
326
|
+
listJsonFiles: async (remotePath) => {
|
|
327
|
+
const result = await runShell(input.runner, input.remoteCwd, [
|
|
328
|
+
`if [ -d ${shellQuote(remotePath)} ]; then`,
|
|
329
|
+
` for file in ${shellQuote(remotePath)}/*.json; do`,
|
|
330
|
+
` [ -f "$file" ] || continue`,
|
|
331
|
+
" basename \"$file\"",
|
|
332
|
+
" done",
|
|
333
|
+
"fi",
|
|
334
|
+
].join("\n"), timeoutMs, shellCommand);
|
|
335
|
+
requireSuccessfulResult(`list ${remotePath}`, result);
|
|
336
|
+
return result.stdout
|
|
337
|
+
.split(/\r?\n/)
|
|
338
|
+
.map((line) => line.trim())
|
|
339
|
+
.filter((line) => line.length > 0)
|
|
340
|
+
.sort((left, right) => left.localeCompare(right));
|
|
341
|
+
},
|
|
342
|
+
readTextFile: async (remotePath) => {
|
|
343
|
+
const result = await runChecked(`read ${remotePath}`, `base64 < ${shellQuote(remotePath)}`);
|
|
344
|
+
return Buffer.from(result.stdout.replace(/\s+/g, ""), "base64").toString("utf8");
|
|
345
|
+
},
|
|
346
|
+
writeTextFile: async (remotePath, body) => {
|
|
347
|
+
const remoteDir = path.posix.dirname(remotePath);
|
|
348
|
+
const tempPath = `${remotePath}.evermore-upload.b64`;
|
|
349
|
+
await runChecked(`prepare upload ${remotePath}`, `mkdir -p ${shellQuote(remoteDir)} && rm -f ${shellQuote(tempPath)} && : > ${shellQuote(tempPath)}`);
|
|
350
|
+
const base64Body = toBuffer(Buffer.from(body, "utf8")).toString("base64");
|
|
351
|
+
for (const chunk of base64Chunks(base64Body)) {
|
|
352
|
+
await runChecked(`append upload chunk ${remotePath}`, `printf '%s' ${shellQuote(chunk)} >> ${shellQuote(tempPath)}`);
|
|
353
|
+
}
|
|
354
|
+
await runChecked(`finalize upload ${remotePath}`, `base64 -d < ${shellQuote(tempPath)} > ${shellQuote(remotePath)} && rm -f ${shellQuote(tempPath)}`);
|
|
355
|
+
},
|
|
356
|
+
writeResponseFile: async (responsePath, body, options = {}) => {
|
|
357
|
+
const responseDir = path.posix.dirname(responsePath);
|
|
358
|
+
const tempPath = `${responsePath}.tmp`;
|
|
359
|
+
const lockDir = `${responsePath}.evermore-write.lock`;
|
|
360
|
+
const requestPath = options.requestPath?.trim() || "";
|
|
361
|
+
const result = await runShell(input.runner, input.remoteCwd, [
|
|
362
|
+
"set -eu",
|
|
363
|
+
`response_dir=${shellQuote(responseDir)}`,
|
|
364
|
+
`response_path=${shellQuote(responsePath)}`,
|
|
365
|
+
`temp_path=${shellQuote(tempPath)}`,
|
|
366
|
+
`lock_dir=${shellQuote(lockDir)}`,
|
|
367
|
+
`request_path=${shellQuote(requestPath)}`,
|
|
368
|
+
"mkdir -p \"$response_dir\"",
|
|
369
|
+
...buildRemotePidLockAcquireScript("\"$lock_dir\"", "Timed out acquiring sandbox callback bridge response lock."),
|
|
370
|
+
...buildRemotePidLockCleanupScript("\"$lock_dir\"", [
|
|
371
|
+
"rm -f \"$temp_path\"",
|
|
372
|
+
]),
|
|
373
|
+
"if [ -n \"$request_path\" ] && [ ! -f \"$request_path\" ]; then",
|
|
374
|
+
" printf '{\"wrote\":false}\\n'",
|
|
375
|
+
" exit 0",
|
|
376
|
+
"fi",
|
|
377
|
+
"if [ -f \"$response_path\" ]; then",
|
|
378
|
+
" printf '{\"wrote\":false}\\n'",
|
|
379
|
+
" exit 0",
|
|
380
|
+
"fi",
|
|
381
|
+
"cat > \"$temp_path\"",
|
|
382
|
+
"mv \"$temp_path\" \"$response_path\"",
|
|
383
|
+
"printf '{\"wrote\":true}\\n'",
|
|
384
|
+
].join("\n"), timeoutMs, shellCommand, body);
|
|
385
|
+
requireSuccessfulResult(`write bridge response ${responsePath}`, result);
|
|
386
|
+
try {
|
|
387
|
+
return {
|
|
388
|
+
wrote: JSON.parse(result.stdout.trim())?.wrote === true,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
throw new Error(`Sandbox callback bridge response write wrote invalid result JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
rename: async (fromPath, toPath) => {
|
|
396
|
+
await runChecked(`rename ${fromPath}`, `mkdir -p ${shellQuote(path.posix.dirname(toPath))} && mv ${shellQuote(fromPath)} ${shellQuote(toPath)}`);
|
|
397
|
+
},
|
|
398
|
+
remove: async (remotePath) => {
|
|
399
|
+
await runChecked(`remove ${remotePath}`, `rm -rf ${shellQuote(remotePath)}`);
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
async function writeBridgeResponse(client, requestPath, responsePath, response) {
|
|
404
|
+
const body = `${JSON.stringify(response)}\n`;
|
|
405
|
+
if (client.writeResponseFile) {
|
|
406
|
+
await client.writeResponseFile(responsePath, body, { requestPath });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const tempPath = `${responsePath}.tmp`;
|
|
410
|
+
await client.writeTextFile(tempPath, body);
|
|
411
|
+
await client.rename(tempPath, responsePath);
|
|
412
|
+
}
|
|
413
|
+
export async function startSandboxCallbackBridgeWorker(input) {
|
|
414
|
+
const pollIntervalMs = normalizeTimeoutMs(input.pollIntervalMs, DEFAULT_BRIDGE_POLL_INTERVAL_MS);
|
|
415
|
+
const maxBodyBytes = normalizeTimeoutMs(input.maxBodyBytes, DEFAULT_BRIDGE_MAX_BODY_BYTES);
|
|
416
|
+
const directories = sandboxCallbackBridgeDirectories(input.queueDir);
|
|
417
|
+
await input.client.makeDir(directories.rootDir);
|
|
418
|
+
await input.client.makeDir(directories.requestsDir);
|
|
419
|
+
await input.client.makeDir(directories.responsesDir);
|
|
420
|
+
await input.client.makeDir(directories.logsDir);
|
|
421
|
+
let stopping = false;
|
|
422
|
+
let inFlight = 0;
|
|
423
|
+
let settled = false;
|
|
424
|
+
let stopDeadline = Number.POSITIVE_INFINITY;
|
|
425
|
+
let settleResolve = null;
|
|
426
|
+
const settledPromise = new Promise((resolve) => {
|
|
427
|
+
settleResolve = resolve;
|
|
428
|
+
});
|
|
429
|
+
const authorizeRequest = input.authorizeRequest ??
|
|
430
|
+
((request) => authorizeSandboxCallbackBridgeRequestWithRoutes(request));
|
|
431
|
+
const processRequestFile = async (fileName) => {
|
|
432
|
+
const requestPath = path.posix.join(directories.requestsDir, fileName);
|
|
433
|
+
const responsePath = path.posix.join(directories.responsesDir, fileName);
|
|
434
|
+
const raw = await input.client.readTextFile(requestPath);
|
|
435
|
+
let request;
|
|
436
|
+
try {
|
|
437
|
+
request = JSON.parse(raw);
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
const requestId = fileName.replace(/\.json$/i, "") || randomUUID();
|
|
441
|
+
await writeBridgeResponse(input.client, requestPath, responsePath, {
|
|
442
|
+
id: requestId,
|
|
443
|
+
status: 400,
|
|
444
|
+
headers: { "content-type": "application/json" },
|
|
445
|
+
body: JSON.stringify({ error: "Invalid bridge request payload." }),
|
|
446
|
+
completedAt: new Date().toISOString(),
|
|
447
|
+
});
|
|
448
|
+
await input.client.remove(requestPath);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const denialReason = await authorizeRequest(request);
|
|
452
|
+
if (denialReason) {
|
|
453
|
+
await writeBridgeResponse(input.client, requestPath, responsePath, {
|
|
454
|
+
id: request.id,
|
|
455
|
+
status: 403,
|
|
456
|
+
headers: { "content-type": "application/json" },
|
|
457
|
+
body: JSON.stringify({ error: denialReason }),
|
|
458
|
+
completedAt: new Date().toISOString(),
|
|
459
|
+
});
|
|
460
|
+
await input.client.remove(requestPath);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
const result = await input.handleRequest(request);
|
|
465
|
+
const responseBody = result.body ?? "";
|
|
466
|
+
if (Buffer.byteLength(responseBody, "utf8") > maxBodyBytes) {
|
|
467
|
+
throw new Error(`Bridge response body exceeded the configured size limit of ${maxBodyBytes} bytes.`);
|
|
468
|
+
}
|
|
469
|
+
await writeBridgeResponse(input.client, requestPath, responsePath, {
|
|
470
|
+
id: request.id,
|
|
471
|
+
status: result.status,
|
|
472
|
+
headers: result.headers ?? {},
|
|
473
|
+
body: responseBody,
|
|
474
|
+
completedAt: new Date().toISOString(),
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
console.warn(`[evermore] sandbox callback bridge handler failed for ${request.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
479
|
+
await writeBridgeResponse(input.client, requestPath, responsePath, {
|
|
480
|
+
id: request.id,
|
|
481
|
+
status: 502,
|
|
482
|
+
headers: { "content-type": "application/json" },
|
|
483
|
+
body: JSON.stringify({
|
|
484
|
+
error: error instanceof Error ? error.message : String(error),
|
|
485
|
+
}),
|
|
486
|
+
completedAt: new Date().toISOString(),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
await input.client.remove(requestPath);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
const failPendingRequests = async (message) => {
|
|
494
|
+
const fileNames = await input.client.listJsonFiles(directories.requestsDir).catch(() => []);
|
|
495
|
+
for (const fileName of fileNames) {
|
|
496
|
+
const requestPath = path.posix.join(directories.requestsDir, fileName);
|
|
497
|
+
const responsePath = path.posix.join(directories.responsesDir, fileName);
|
|
498
|
+
const requestId = fileName.replace(/\.json$/i, "") || randomUUID();
|
|
499
|
+
try {
|
|
500
|
+
const raw = await input.client.readTextFile(requestPath);
|
|
501
|
+
const parsed = JSON.parse(raw);
|
|
502
|
+
await writeBridgeResponse(input.client, requestPath, responsePath, {
|
|
503
|
+
id: typeof parsed.id === "string" && parsed.id.length > 0 ? parsed.id : requestId,
|
|
504
|
+
status: 503,
|
|
505
|
+
headers: { "content-type": "application/json" },
|
|
506
|
+
body: JSON.stringify({ error: message }),
|
|
507
|
+
completedAt: new Date().toISOString(),
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
console.warn(`[evermore] sandbox callback bridge failed to abort pending request ${requestId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
await input.client.remove(requestPath).catch(() => undefined);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
const loop = (async () => {
|
|
519
|
+
try {
|
|
520
|
+
while (true) {
|
|
521
|
+
const fileNames = await input.client.listJsonFiles(directories.requestsDir);
|
|
522
|
+
if (fileNames.length === 0) {
|
|
523
|
+
if (stopping) {
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
for (const fileName of fileNames) {
|
|
530
|
+
if (stopping && Date.now() >= stopDeadline)
|
|
531
|
+
break;
|
|
532
|
+
inFlight += 1;
|
|
533
|
+
try {
|
|
534
|
+
await processRequestFile(fileName);
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
inFlight -= 1;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (stopping && Date.now() >= stopDeadline) {
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
finally {
|
|
546
|
+
settled = true;
|
|
547
|
+
if (settleResolve) {
|
|
548
|
+
settleResolve();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
})();
|
|
552
|
+
void loop;
|
|
553
|
+
return {
|
|
554
|
+
stop: async (options = {}) => {
|
|
555
|
+
stopping = true;
|
|
556
|
+
const drainMs = normalizeTimeoutMs(options.drainTimeoutMs, DEFAULT_BRIDGE_STOP_TIMEOUT_MS);
|
|
557
|
+
stopDeadline = Date.now() + drainMs;
|
|
558
|
+
if (!settled) {
|
|
559
|
+
await Promise.race([
|
|
560
|
+
settledPromise,
|
|
561
|
+
new Promise((resolve) => setTimeout(resolve, drainMs)),
|
|
562
|
+
]);
|
|
563
|
+
}
|
|
564
|
+
await failPendingRequests("Bridge worker stopped before request could be handled.");
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
export async function syncSandboxCallbackBridgeEntrypoint(input) {
|
|
569
|
+
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
|
|
570
|
+
const shellCommand = preferredShellForSandbox(input.shellCommand);
|
|
571
|
+
const remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
|
|
572
|
+
const remoteEntrypointPartial = `${remoteEntrypoint}.partial`;
|
|
573
|
+
const remoteUploadPath = `${remoteEntrypoint}.evermore-upload.b64`;
|
|
574
|
+
const remoteLockDir = path.posix.join(input.assetRemoteDir, ".evermore-bridge-upload.lock");
|
|
575
|
+
const entrypointSource = await fs.readFile(input.bridgeAsset.entrypoint, "utf8");
|
|
576
|
+
const entrypointBase64 = toBuffer(Buffer.from(entrypointSource, "utf8")).toString("base64");
|
|
577
|
+
const sha256 = createHash("sha256").update(entrypointSource, "utf8").digest("hex");
|
|
578
|
+
const syncResult = await runShell(input.runner, input.remoteCwd, [
|
|
579
|
+
"set -eu",
|
|
580
|
+
`remote_dir=${shellQuote(input.assetRemoteDir)}`,
|
|
581
|
+
`remote_path=${shellQuote(remoteEntrypoint)}`,
|
|
582
|
+
`remote_partial=${shellQuote(remoteEntrypointPartial)}`,
|
|
583
|
+
`remote_upload=${shellQuote(remoteUploadPath)}`,
|
|
584
|
+
`lock_dir=${shellQuote(remoteLockDir)}`,
|
|
585
|
+
`expected_sha=${shellQuote(sha256)}`,
|
|
586
|
+
"hash_file() {",
|
|
587
|
+
" if command -v sha256sum >/dev/null 2>&1; then",
|
|
588
|
+
" sha256sum \"$1\" | awk '{print $1}'",
|
|
589
|
+
" return 0",
|
|
590
|
+
" fi",
|
|
591
|
+
" if command -v shasum >/dev/null 2>&1; then",
|
|
592
|
+
" shasum -a 256 \"$1\" | awk '{print $1}'",
|
|
593
|
+
" return 0",
|
|
594
|
+
" fi",
|
|
595
|
+
" return 127",
|
|
596
|
+
"}",
|
|
597
|
+
"mkdir -p \"$remote_dir\"",
|
|
598
|
+
...buildRemotePidLockAcquireScript("\"$lock_dir\"", "Timed out acquiring sandbox callback bridge upload lock."),
|
|
599
|
+
...buildRemotePidLockCleanupScript("\"$lock_dir\"", [
|
|
600
|
+
"rm -f \"$remote_upload\" \"$remote_partial\"",
|
|
601
|
+
]),
|
|
602
|
+
"current_sha=\"\"",
|
|
603
|
+
"if [ -f \"$remote_path\" ]; then",
|
|
604
|
+
" current_sha=\"$(hash_file \"$remote_path\" 2>/dev/null)\" || current_sha=\"\"",
|
|
605
|
+
"fi",
|
|
606
|
+
"if [ -n \"$current_sha\" ] && [ \"$current_sha\" = \"$expected_sha\" ]; then",
|
|
607
|
+
" printf '{\"uploaded\":false}\\n'",
|
|
608
|
+
" exit 0",
|
|
609
|
+
"fi",
|
|
610
|
+
"rm -f \"$remote_upload\" \"$remote_partial\"",
|
|
611
|
+
"cat > \"$remote_upload\"",
|
|
612
|
+
"base64 -d < \"$remote_upload\" > \"$remote_partial\"",
|
|
613
|
+
// Verify upload integrity. If neither sha256sum nor shasum is on PATH
|
|
614
|
+
// (minimal Alpine/scratch images), surface the missing-tool error
|
|
615
|
+
// instead of a misleading "sha mismatch" — the verify step is then
|
|
616
|
+
// best-effort and we trust base64-decode + atomic rename below.
|
|
617
|
+
"if partial_sha=\"$(hash_file \"$remote_partial\" 2>/dev/null)\"; then",
|
|
618
|
+
" if [ \"$partial_sha\" != \"$expected_sha\" ]; then",
|
|
619
|
+
" echo \"Sandbox callback bridge entrypoint upload sha mismatch.\" >&2",
|
|
620
|
+
" exit 1",
|
|
621
|
+
" fi",
|
|
622
|
+
"else",
|
|
623
|
+
" echo \"Sandbox callback bridge entrypoint sha verify skipped: no sha256sum/shasum on remote.\" >&2",
|
|
624
|
+
"fi",
|
|
625
|
+
"mv \"$remote_partial\" \"$remote_path\"",
|
|
626
|
+
"printf '{\"uploaded\":true}\\n'",
|
|
627
|
+
].join("\n"), timeoutMs, shellCommand, entrypointBase64);
|
|
628
|
+
requireSuccessfulResult("sync sandbox callback bridge entrypoint", syncResult);
|
|
629
|
+
let uploaded = false;
|
|
630
|
+
try {
|
|
631
|
+
uploaded = JSON.parse(syncResult.stdout.trim())?.uploaded === true;
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
throw new Error(`Sandbox callback bridge sync wrote invalid result JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
remoteEntrypoint,
|
|
638
|
+
sha256,
|
|
639
|
+
uploaded,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
export async function startSandboxCallbackBridgeServer(input) {
|
|
643
|
+
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
|
|
644
|
+
const shellCommand = preferredShellForSandbox(input.shellCommand);
|
|
645
|
+
const directories = sandboxCallbackBridgeDirectories(input.queueDir);
|
|
646
|
+
let remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
|
|
647
|
+
if (input.bridgeAsset) {
|
|
648
|
+
const assetSync = await syncSandboxCallbackBridgeEntrypoint({
|
|
649
|
+
runner: input.runner,
|
|
650
|
+
remoteCwd: input.remoteCwd,
|
|
651
|
+
assetRemoteDir: input.assetRemoteDir,
|
|
652
|
+
bridgeAsset: input.bridgeAsset,
|
|
653
|
+
timeoutMs,
|
|
654
|
+
shellCommand,
|
|
655
|
+
});
|
|
656
|
+
remoteEntrypoint = assetSync.remoteEntrypoint;
|
|
657
|
+
}
|
|
658
|
+
const env = buildSandboxCallbackBridgeEnv({
|
|
659
|
+
queueDir: input.queueDir,
|
|
660
|
+
bridgeToken: input.bridgeToken,
|
|
661
|
+
host: input.host,
|
|
662
|
+
port: input.port,
|
|
663
|
+
pollIntervalMs: input.pollIntervalMs,
|
|
664
|
+
responseTimeoutMs: input.responseTimeoutMs,
|
|
665
|
+
maxQueueDepth: input.maxQueueDepth,
|
|
666
|
+
maxBodyBytes: input.maxBodyBytes,
|
|
667
|
+
});
|
|
668
|
+
const nodeCommand = input.nodeCommand?.trim() || "node";
|
|
669
|
+
const startResult = await input.runner.execute({
|
|
670
|
+
command: shellCommand,
|
|
671
|
+
args: [
|
|
672
|
+
"-lc",
|
|
673
|
+
[
|
|
674
|
+
`mkdir -p ${shellQuote(directories.requestsDir)} ${shellQuote(directories.responsesDir)} ${shellQuote(directories.logsDir)}`,
|
|
675
|
+
`rm -f ${shellQuote(directories.readyFile)} ${shellQuote(directories.pidFile)}`,
|
|
676
|
+
`nohup env ${Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} ` +
|
|
677
|
+
`${shellQuote(nodeCommand)} ${shellQuote(remoteEntrypoint)} ` +
|
|
678
|
+
`>> ${shellQuote(directories.logFile)} 2>&1 < /dev/null &`,
|
|
679
|
+
"pid=$!",
|
|
680
|
+
`printf '%s\\n' \"$pid\" > ${shellQuote(directories.pidFile)}`,
|
|
681
|
+
"printf '{\"pid\":%s}\\n' \"$pid\"",
|
|
682
|
+
].join("\n"),
|
|
683
|
+
],
|
|
684
|
+
cwd: input.remoteCwd,
|
|
685
|
+
timeoutMs,
|
|
686
|
+
});
|
|
687
|
+
requireSuccessfulResult("start sandbox callback bridge", startResult);
|
|
688
|
+
const readyResult = await runShell(input.runner, input.remoteCwd, [
|
|
689
|
+
"i=0",
|
|
690
|
+
`while [ \"$i\" -lt 200 ]; do`,
|
|
691
|
+
` if [ -s ${shellQuote(directories.readyFile)} ]; then`,
|
|
692
|
+
` cat ${shellQuote(directories.readyFile)}`,
|
|
693
|
+
" exit 0",
|
|
694
|
+
" fi",
|
|
695
|
+
` if [ -s ${shellQuote(directories.logFile)} ] && ! kill -0 \"$(cat ${shellQuote(directories.pidFile)} 2>/dev/null)\" 2>/dev/null; then`,
|
|
696
|
+
` cat ${shellQuote(directories.logFile)} >&2`,
|
|
697
|
+
" exit 1",
|
|
698
|
+
" fi",
|
|
699
|
+
" i=$((i + 1))",
|
|
700
|
+
" sleep 0.05",
|
|
701
|
+
"done",
|
|
702
|
+
`echo "Timed out waiting for bridge readiness." >&2`,
|
|
703
|
+
`if [ -s ${shellQuote(directories.logFile)} ]; then cat ${shellQuote(directories.logFile)} >&2; fi`,
|
|
704
|
+
"exit 1",
|
|
705
|
+
].join("\n"), timeoutMs, shellCommand);
|
|
706
|
+
requireSuccessfulResult("wait for sandbox callback bridge readiness", readyResult);
|
|
707
|
+
let readyData;
|
|
708
|
+
try {
|
|
709
|
+
readyData = JSON.parse(readyResult.stdout.trim());
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
throw new Error(`Sandbox callback bridge wrote invalid readiness JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
713
|
+
}
|
|
714
|
+
const host = typeof readyData.host === "string" && readyData.host.trim().length > 0
|
|
715
|
+
? readyData.host.trim()
|
|
716
|
+
: "127.0.0.1";
|
|
717
|
+
const port = typeof readyData.port === "number" && Number.isFinite(readyData.port) ? readyData.port : 0;
|
|
718
|
+
if (!port) {
|
|
719
|
+
throw new Error("Sandbox callback bridge did not report a listening port.");
|
|
720
|
+
}
|
|
721
|
+
const baseUrl = typeof readyData.baseUrl === "string" && readyData.baseUrl.trim().length > 0
|
|
722
|
+
? readyData.baseUrl.trim()
|
|
723
|
+
: `http://${host}:${port}`;
|
|
724
|
+
return {
|
|
725
|
+
baseUrl,
|
|
726
|
+
host,
|
|
727
|
+
port,
|
|
728
|
+
pid: typeof readyData.pid === "number" && Number.isFinite(readyData.pid) ? readyData.pid : 0,
|
|
729
|
+
directories,
|
|
730
|
+
stop: async () => {
|
|
731
|
+
const stopResult = await input.runner.execute({
|
|
732
|
+
command: shellCommand,
|
|
733
|
+
args: [
|
|
734
|
+
"-lc",
|
|
735
|
+
[
|
|
736
|
+
`if [ -s ${shellQuote(directories.pidFile)} ]; then`,
|
|
737
|
+
` pid="$(cat ${shellQuote(directories.pidFile)})"`,
|
|
738
|
+
" kill \"$pid\" 2>/dev/null || true",
|
|
739
|
+
" i=0",
|
|
740
|
+
" while kill -0 \"$pid\" 2>/dev/null && [ \"$i\" -lt 40 ]; do",
|
|
741
|
+
" i=$((i + 1))",
|
|
742
|
+
" sleep 0.05",
|
|
743
|
+
" done",
|
|
744
|
+
"fi",
|
|
745
|
+
`rm -f ${shellQuote(directories.pidFile)} ${shellQuote(directories.readyFile)}`,
|
|
746
|
+
].join("\n"),
|
|
747
|
+
],
|
|
748
|
+
cwd: input.remoteCwd,
|
|
749
|
+
timeoutMs,
|
|
750
|
+
});
|
|
751
|
+
if (stopResult.timedOut) {
|
|
752
|
+
throw new Error(buildRunnerFailureMessage("stop sandbox callback bridge", stopResult));
|
|
753
|
+
}
|
|
754
|
+
},
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
function getSandboxCallbackBridgeServerSource() {
|
|
758
|
+
return `import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
759
|
+
import { createServer } from "node:http";
|
|
760
|
+
import { promises as fs } from "node:fs";
|
|
761
|
+
import path from "node:path";
|
|
762
|
+
|
|
763
|
+
const queueDir = process.env.EVERMORE_BRIDGE_QUEUE_DIR;
|
|
764
|
+
const bridgeToken = process.env.EVERMORE_BRIDGE_TOKEN;
|
|
765
|
+
const host = process.env.EVERMORE_BRIDGE_HOST || "127.0.0.1";
|
|
766
|
+
const port = Number(process.env.EVERMORE_BRIDGE_PORT || "0");
|
|
767
|
+
const pollIntervalMs = Number(process.env.EVERMORE_BRIDGE_POLL_INTERVAL_MS || "100");
|
|
768
|
+
const responseTimeoutMs = Number(process.env.EVERMORE_BRIDGE_RESPONSE_TIMEOUT_MS || "30000");
|
|
769
|
+
const maxQueueDepth = Number(process.env.EVERMORE_BRIDGE_MAX_QUEUE_DEPTH || "${DEFAULT_BRIDGE_MAX_QUEUE_DEPTH}");
|
|
770
|
+
const maxBodyBytes = Number(process.env.EVERMORE_BRIDGE_MAX_BODY_BYTES || "${DEFAULT_BRIDGE_MAX_BODY_BYTES}");
|
|
771
|
+
const allowedHeaders = new Set(${JSON.stringify([...DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST])});
|
|
772
|
+
|
|
773
|
+
if (!queueDir || !bridgeToken) {
|
|
774
|
+
throw new Error("EVERMORE_BRIDGE_QUEUE_DIR and EVERMORE_BRIDGE_TOKEN are required.");
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const requestsDir = path.posix.join(queueDir, "requests");
|
|
778
|
+
const responsesDir = path.posix.join(queueDir, "responses");
|
|
779
|
+
const logsDir = path.posix.join(queueDir, "logs");
|
|
780
|
+
const readyFile = path.posix.join(queueDir, "ready.json");
|
|
781
|
+
|
|
782
|
+
function sleep(ms) {
|
|
783
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function normalizeHeaders(headers) {
|
|
787
|
+
const out = {};
|
|
788
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
789
|
+
if (value == null) continue;
|
|
790
|
+
const normalizedKey = key.toLowerCase();
|
|
791
|
+
if (!allowedHeaders.has(normalizedKey)) {
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
out[normalizedKey] = Array.isArray(value) ? value.join(", ") : String(value);
|
|
795
|
+
}
|
|
796
|
+
return out;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async function readBody(req) {
|
|
800
|
+
const chunks = [];
|
|
801
|
+
let totalBytes = 0;
|
|
802
|
+
for await (const chunk of req) {
|
|
803
|
+
const nextChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
804
|
+
chunks.push(nextChunk);
|
|
805
|
+
totalBytes += nextChunk.byteLength;
|
|
806
|
+
if (totalBytes > maxBodyBytes) {
|
|
807
|
+
throw new Error("Bridge request body exceeded the configured size limit.");
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function queueDepth() {
|
|
814
|
+
const entries = await fs.readdir(requestsDir, { withFileTypes: true }).catch(() => []);
|
|
815
|
+
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).length;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function tokensMatch(received) {
|
|
819
|
+
const expected = Buffer.from(bridgeToken, "utf8");
|
|
820
|
+
const actual = Buffer.from(typeof received === "string" ? received : "", "utf8");
|
|
821
|
+
if (expected.length !== actual.length) return false;
|
|
822
|
+
return timingSafeEqual(expected, actual);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function waitForResponse(requestId) {
|
|
826
|
+
const responsePath = path.posix.join(responsesDir, \`\${requestId}.json\`);
|
|
827
|
+
const deadline = Date.now() + responseTimeoutMs;
|
|
828
|
+
while (Date.now() < deadline) {
|
|
829
|
+
const body = await fs.readFile(responsePath, "utf8").catch(() => null);
|
|
830
|
+
if (body != null) {
|
|
831
|
+
await fs.rm(responsePath, { force: true }).catch(() => undefined);
|
|
832
|
+
return JSON.parse(body);
|
|
833
|
+
}
|
|
834
|
+
await sleep(pollIntervalMs);
|
|
835
|
+
}
|
|
836
|
+
throw new Error("Timed out waiting for host bridge response.");
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const server = createServer(async (req, res) => {
|
|
840
|
+
try {
|
|
841
|
+
const auth = req.headers.authorization || "";
|
|
842
|
+
const receivedToken = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
|
|
843
|
+
if (!tokensMatch(receivedToken)) {
|
|
844
|
+
res.statusCode = 401;
|
|
845
|
+
res.setHeader("content-type", "application/json");
|
|
846
|
+
res.end(JSON.stringify({ error: "Invalid bridge token." }));
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (await queueDepth() >= maxQueueDepth) {
|
|
851
|
+
res.statusCode = 503;
|
|
852
|
+
res.setHeader("content-type", "application/json");
|
|
853
|
+
res.end(JSON.stringify({ error: "Bridge request queue is full." }));
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
858
|
+
const contentType = typeof req.headers["content-type"] === "string" ? req.headers["content-type"] : "";
|
|
859
|
+
if (req.method && req.method !== "GET" && req.method !== "HEAD" && !/json/i.test(contentType)) {
|
|
860
|
+
res.statusCode = 415;
|
|
861
|
+
res.setHeader("content-type", "application/json");
|
|
862
|
+
res.end(JSON.stringify({ error: "Bridge only accepts JSON request bodies." }));
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const requestId = randomUUID();
|
|
866
|
+
const requestBody = await readBody(req);
|
|
867
|
+
const payload = {
|
|
868
|
+
id: requestId,
|
|
869
|
+
method: req.method || "GET",
|
|
870
|
+
path: url.pathname,
|
|
871
|
+
query: url.search,
|
|
872
|
+
headers: normalizeHeaders(req.headers),
|
|
873
|
+
body: requestBody,
|
|
874
|
+
createdAt: new Date().toISOString(),
|
|
875
|
+
};
|
|
876
|
+
const requestPath = path.posix.join(requestsDir, \`\${requestId}.json\`);
|
|
877
|
+
const tempPath = \`\${requestPath}.tmp\`;
|
|
878
|
+
await fs.writeFile(tempPath, \`\${JSON.stringify(payload)}\\n\`, "utf8");
|
|
879
|
+
await fs.rename(tempPath, requestPath);
|
|
880
|
+
|
|
881
|
+
const response = await waitForResponse(requestId);
|
|
882
|
+
res.statusCode = typeof response.status === "number" ? response.status : 200;
|
|
883
|
+
for (const [key, value] of Object.entries(response.headers || {})) {
|
|
884
|
+
if (typeof value !== "string" || key.toLowerCase() === "content-length") continue;
|
|
885
|
+
res.setHeader(key, value);
|
|
886
|
+
}
|
|
887
|
+
res.end(typeof response.body === "string" ? response.body : "");
|
|
888
|
+
} catch (error) {
|
|
889
|
+
res.statusCode = 502;
|
|
890
|
+
res.setHeader("content-type", "application/json");
|
|
891
|
+
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
async function shutdown() {
|
|
896
|
+
server.close(() => {
|
|
897
|
+
process.exit(0);
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
process.on("SIGINT", () => void shutdown());
|
|
902
|
+
process.on("SIGTERM", () => void shutdown());
|
|
903
|
+
|
|
904
|
+
await fs.mkdir(requestsDir, { recursive: true });
|
|
905
|
+
await fs.mkdir(responsesDir, { recursive: true });
|
|
906
|
+
await fs.mkdir(logsDir, { recursive: true });
|
|
907
|
+
|
|
908
|
+
server.listen(port, host, async () => {
|
|
909
|
+
const address = server.address();
|
|
910
|
+
if (!address || typeof address === "string") {
|
|
911
|
+
throw new Error("Bridge server did not expose a TCP address.");
|
|
912
|
+
}
|
|
913
|
+
const ready = {
|
|
914
|
+
pid: process.pid,
|
|
915
|
+
host,
|
|
916
|
+
port: address.port,
|
|
917
|
+
baseUrl: \`http://\${host}:\${address.port}\`,
|
|
918
|
+
startedAt: new Date().toISOString(),
|
|
919
|
+
};
|
|
920
|
+
const tempReadyFile = \`\${readyFile}.tmp\`;
|
|
921
|
+
await fs.writeFile(tempReadyFile, JSON.stringify(ready), "utf8");
|
|
922
|
+
await fs.rename(tempReadyFile, readyFile);
|
|
923
|
+
});`;
|
|
924
|
+
}
|
|
925
|
+
//# sourceMappingURL=sandbox-callback-bridge.js.map
|