reeval 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/THIRD_PARTY_NOTICES.md +2160 -0
- package/assets/package.json +9 -0
- package/assets/pnpm-lock.yaml +1336 -0
- package/dist/agent-DVHkx8aT.js +532 -0
- package/dist/agent-DVHkx8aT.js.map +1 -0
- package/dist/analysis-CxSCt1YY.js +16 -0
- package/dist/analysis-CxSCt1YY.js.map +1 -0
- package/dist/assemble-B-nmz8oH.js +695 -0
- package/dist/assemble-B-nmz8oH.js.map +1 -0
- package/dist/assemble-CmRVDZ2Q.js +2 -0
- package/dist/attempt-BdRQ9jKX.js +176 -0
- package/dist/attempt-BdRQ9jKX.js.map +1 -0
- package/dist/bridge/index.mjs +1911 -0
- package/dist/bridge/package.json +13 -0
- package/dist/bridge/pnpm-lock.yaml +1041 -0
- package/dist/bridge/pnpm-workspace.yaml +2 -0
- package/dist/budget-DBUrH4jX.js +8 -0
- package/dist/budget-DBUrH4jX.js.map +1 -0
- package/dist/cli--CQ3Iy9q.js +1865 -0
- package/dist/cli--CQ3Iy9q.js.map +1 -0
- package/dist/cli-spec-DKAu09Gi.js +478 -0
- package/dist/cli-spec-DKAu09Gi.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +8 -0
- package/dist/cli.js.map +1 -0
- package/dist/colors-QV9dFVhN.js +34 -0
- package/dist/colors-QV9dFVhN.js.map +1 -0
- package/dist/command-BSJzHMtP.js +280 -0
- package/dist/command-BSJzHMtP.js.map +1 -0
- package/dist/command-CEm8PZ1N.js +1043 -0
- package/dist/command-CEm8PZ1N.js.map +1 -0
- package/dist/command-D-jnQWVG.js +673 -0
- package/dist/command-D-jnQWVG.js.map +1 -0
- package/dist/command-DQAvbG7d.js +432 -0
- package/dist/command-DQAvbG7d.js.map +1 -0
- package/dist/commands-A4udfyKZ.js +2 -0
- package/dist/commands-CF8mOfiu.js +207 -0
- package/dist/commands-CF8mOfiu.js.map +1 -0
- package/dist/config-Cuj0kVYj.js +415 -0
- package/dist/config-Cuj0kVYj.js.map +1 -0
- package/dist/context-loi9OCZv.js +106 -0
- package/dist/context-loi9OCZv.js.map +1 -0
- package/dist/core-tLN-z1Be.js +96 -0
- package/dist/core-tLN-z1Be.js.map +1 -0
- package/dist/db-Cjj2ZsfQ.js +2 -0
- package/dist/db-DxDzMhtB.js +827 -0
- package/dist/db-DxDzMhtB.js.map +1 -0
- package/dist/debug-D24NOQH1.js +89 -0
- package/dist/debug-D24NOQH1.js.map +1 -0
- package/dist/debug-SEmW4w8f.js +2 -0
- package/dist/dist-BZXYHN2X.js +1673 -0
- package/dist/dist-BZXYHN2X.js.map +1 -0
- package/dist/docker-DQDZWXF9.js +20 -0
- package/dist/docker-DQDZWXF9.js.map +1 -0
- package/dist/docker-DnWAVdEm.js +2 -0
- package/dist/docker-oSbCExHL.js +153 -0
- package/dist/docker-oSbCExHL.js.map +1 -0
- package/dist/doctor-CxlH1pDM.js +525 -0
- package/dist/doctor-CxlH1pDM.js.map +1 -0
- package/dist/egress-73SQfKdx.js +619 -0
- package/dist/egress-73SQfKdx.js.map +1 -0
- package/dist/env-BQqj93gu.js +21 -0
- package/dist/env-BQqj93gu.js.map +1 -0
- package/dist/environment-Mz7wvrtD.js +552 -0
- package/dist/environment-Mz7wvrtD.js.map +1 -0
- package/dist/errors-C9w_ud7g.js +160 -0
- package/dist/errors-C9w_ud7g.js.map +1 -0
- package/dist/errors-CSxfJ4qu.js +11 -0
- package/dist/errors-CSxfJ4qu.js.map +1 -0
- package/dist/files--6CEUXm7.js +2 -0
- package/dist/files-BLlrhU4-.js +62 -0
- package/dist/files-BLlrhU4-.js.map +1 -0
- package/dist/files-Bd25OAQH.js +2 -0
- package/dist/files-BvHCFWgD.js +2 -0
- package/dist/files-D2-qn_Dl.js +275 -0
- package/dist/files-D2-qn_Dl.js.map +1 -0
- package/dist/files-bF2fPuT9.js +99 -0
- package/dist/files-bF2fPuT9.js.map +1 -0
- package/dist/git-D6hMeieS.js +44 -0
- package/dist/git-D6hMeieS.js.map +1 -0
- package/dist/harness-ls5aX1UO.js +131 -0
- package/dist/harness-ls5aX1UO.js.map +1 -0
- package/dist/harness.d.ts +74 -0
- package/dist/harness.js +2 -0
- package/dist/header-DzFDoMCI.js +78 -0
- package/dist/header-DzFDoMCI.js.map +1 -0
- package/dist/ids-Bp6ut7ZD.js +49 -0
- package/dist/ids-Bp6ut7ZD.js.map +1 -0
- package/dist/images-Cc1-coFc.js +286 -0
- package/dist/images-Cc1-coFc.js.map +1 -0
- package/dist/index.d.ts +97 -0
- package/dist/index.js +1959 -0
- package/dist/index.js.map +1 -0
- package/dist/init-CFljS8BJ.js +344 -0
- package/dist/init-CFljS8BJ.js.map +1 -0
- package/dist/init-z2ZPn9Tj.js +2 -0
- package/dist/invocations-rXktgSAn.js +157 -0
- package/dist/invocations-rXktgSAn.js.map +1 -0
- package/dist/jiti-BOLXpKyG.js +46 -0
- package/dist/jiti-BOLXpKyG.js.map +1 -0
- package/dist/judge-D8Uf4dA9.js +146 -0
- package/dist/judge-D8Uf4dA9.js.map +1 -0
- package/dist/judge.d.ts +21 -0
- package/dist/judge.js +19 -0
- package/dist/judge.js.map +1 -0
- package/dist/live-Bs3LReMV.js +98 -0
- package/dist/live-Bs3LReMV.js.map +1 -0
- package/dist/matchers-DyLvm7EG.js +374 -0
- package/dist/matchers-DyLvm7EG.js.map +1 -0
- package/dist/model-BdO79gWz.js +44 -0
- package/dist/model-BdO79gWz.js.map +1 -0
- package/dist/model-CDL98xue.js +725 -0
- package/dist/model-CDL98xue.js.map +1 -0
- package/dist/model-v-0_Ik7S.js +2 -0
- package/dist/node-kG64XxdE.js +506 -0
- package/dist/node-kG64XxdE.js.map +1 -0
- package/dist/opencode-providers-DZuD_oYt.js +3149 -0
- package/dist/opencode-providers-DZuD_oYt.js.map +1 -0
- package/dist/options-CAQhu--C.js +452 -0
- package/dist/options-CAQhu--C.js.map +1 -0
- package/dist/patch-BkKje4cp.js +69 -0
- package/dist/patch-BkKje4cp.js.map +1 -0
- package/dist/paths-CBvm6pap.js +72 -0
- package/dist/paths-CBvm6pap.js.map +1 -0
- package/dist/plan-gwk6dJ8c.js +237 -0
- package/dist/plan-gwk6dJ8c.js.map +1 -0
- package/dist/preflight-BtubXKQ8.js +2 -0
- package/dist/preflight-BypZMNBG.js +185 -0
- package/dist/preflight-BypZMNBG.js.map +1 -0
- package/dist/prepare-Cwv0bHs_.js +3 -0
- package/dist/prepare-D6Uzcubb.js +213 -0
- package/dist/prepare-D6Uzcubb.js.map +1 -0
- package/dist/provenance-Dfb8YY5Y.js +129 -0
- package/dist/provenance-Dfb8YY5Y.js.map +1 -0
- package/dist/provider-BDbNOQhQ.js +2 -0
- package/dist/provider-CmAYTQQs.js +14 -0
- package/dist/provider-CmAYTQQs.js.map +1 -0
- package/dist/providers-2p3ylMHI.js +107 -0
- package/dist/providers-2p3ylMHI.js.map +1 -0
- package/dist/providers-BUOxitcT.js +2 -0
- package/dist/query-CFATHZmV.js +91 -0
- package/dist/query-CFATHZmV.js.map +1 -0
- package/dist/query-DR4m6CXf.js +2 -0
- package/dist/record-DQ8wzyco.js +99 -0
- package/dist/record-DQ8wzyco.js.map +1 -0
- package/dist/redact-C4fL_bZP.js +167 -0
- package/dist/redact-C4fL_bZP.js.map +1 -0
- package/dist/results-CeXb8Wde.js +37 -0
- package/dist/results-CeXb8Wde.js.map +1 -0
- package/dist/root-CFDjpWuc.js +110 -0
- package/dist/root-CFDjpWuc.js.map +1 -0
- package/dist/sandbox-By2gkdkC.js +133 -0
- package/dist/sandbox-By2gkdkC.js.map +1 -0
- package/dist/sandbox.d.ts +41 -0
- package/dist/sandbox.js +2 -0
- package/dist/server-Bi61uiY0.js +350 -0
- package/dist/server-Bi61uiY0.js.map +1 -0
- package/dist/server-CsSBL8NC.js +2 -0
- package/dist/source-BXzrmof5.js +2 -0
- package/dist/source-Bsfa3LZH.js +40 -0
- package/dist/source-Bsfa3LZH.js.map +1 -0
- package/dist/src-55j1zNHH.js +3 -0
- package/dist/src-AEwxd1UP.js +602 -0
- package/dist/src-AEwxd1UP.js.map +1 -0
- package/dist/src-BDwSuyIo.js +3 -0
- package/dist/src-BQyMiWri.js +490 -0
- package/dist/src-BQyMiWri.js.map +1 -0
- package/dist/src-C-LHyTSV.js +3 -0
- package/dist/src-Cmp-7YrD.js +1136 -0
- package/dist/src-Cmp-7YrD.js.map +1 -0
- package/dist/src-D0RdqpNb.js +252 -0
- package/dist/src-D0RdqpNb.js.map +1 -0
- package/dist/src-D8KYzxBX.js +793 -0
- package/dist/src-D8KYzxBX.js.map +1 -0
- package/dist/src-DEgff9Z7.js +4 -0
- package/dist/src-DXx_29pq.js +567 -0
- package/dist/src-DXx_29pq.js.map +1 -0
- package/dist/src-DwwiVLqv.js +2937 -0
- package/dist/src-DwwiVLqv.js.map +1 -0
- package/dist/src-N7E_mfcZ.js +2 -0
- package/dist/src-W9g-y5X5.js +3 -0
- package/dist/src-daS0wKH2.js +3 -0
- package/dist/state-Cyw3QTsN.js +2 -0
- package/dist/state-DZ7eqb1F.js +167 -0
- package/dist/state-DZ7eqb1F.js.map +1 -0
- package/dist/stats-BNAHlNMD.js +114 -0
- package/dist/stats-BNAHlNMD.js.map +1 -0
- package/dist/studio-app/assets/index-D6XyfFSH.js +58 -0
- package/dist/studio-app/assets/index-xwzWdcE_.css +2 -0
- package/dist/studio-app/favicon.svg +1 -0
- package/dist/studio-app/index.html +15 -0
- package/dist/summary-D7Ttky0z.js +166 -0
- package/dist/summary-D7Ttky0z.js.map +1 -0
- package/dist/text-gEcKjAyp.js +29 -0
- package/dist/text-gEcKjAyp.js.map +1 -0
- package/dist/types-C5vI3g1C.js +9 -0
- package/dist/types-C5vI3g1C.js.map +1 -0
- package/dist/types-DLec-6Cx.d.ts +1535 -0
- package/dist/usage-DsYUqIUM.js +24 -0
- package/dist/usage-DsYUqIUM.js.map +1 -0
- package/dist/view/index.d.ts +645 -0
- package/dist/view/index.js +8 -0
- package/dist/views-BrHOv6wg.js +220 -0
- package/dist/views-BrHOv6wg.js.map +1 -0
- package/dist/views-CwCWLWqo.js +3 -0
- package/dist/worker-entry-DnYkANPG.js +506 -0
- package/dist/worker-entry-DnYkANPG.js.map +1 -0
- package/dist/workspace-C2S0Z6gl.js +327 -0
- package/dist/workspace-C2S0Z6gl.js.map +1 -0
- package/dist/workspace-CyoXG6CY.js +2 -0
- package/docker/Dockerfile +19 -0
- package/package.json +79 -0
|
@@ -0,0 +1,2937 @@
|
|
|
1
|
+
import { l as ownFileURL } from "./root-CFDjpWuc.js";
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, posix, relative, win32 } from "node:path";
|
|
4
|
+
import net, { BlockList, isIP } from "node:net";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createHash, generateKeyPairSync, randomBytes, randomInt, sign } from "node:crypto";
|
|
7
|
+
import { prepareSandboxForHarness } from "@ai-sdk/harness/agent";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { isUtf8 } from "node:buffer";
|
|
10
|
+
import { Duplex, Readable, Transform } from "node:stream";
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import http2 from "node:http2";
|
|
13
|
+
import https from "node:https";
|
|
14
|
+
import tls from "node:tls";
|
|
15
|
+
import { lookup } from "node:dns/promises";
|
|
16
|
+
//#region ../sandbox-docker/src/build-failure.ts
|
|
17
|
+
var ImageBuildError = class extends Error {
|
|
18
|
+
name = "ImageBuildError";
|
|
19
|
+
failure;
|
|
20
|
+
constructor(message, failure) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.failure = failure;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const stripAnsi = (text) => text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
26
|
+
/** The failing step and its output from `--progress plain` build output. */
|
|
27
|
+
function parseBuildOutput(text) {
|
|
28
|
+
const lines = stripAnsi(text).split(/\r?\n/);
|
|
29
|
+
let step = null;
|
|
30
|
+
let error = null;
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
const failed = /^#(\d+) ERROR: /.exec(line);
|
|
33
|
+
if (failed) step = failed[1] ?? null;
|
|
34
|
+
const top = /^ERROR: (.*)$/.exec(line);
|
|
35
|
+
if (top) error = top[1] ?? null;
|
|
36
|
+
}
|
|
37
|
+
error = (error ?? "").replace(/^(failed to build: )?(failed to solve: )?/, "").trim() || null;
|
|
38
|
+
const output = step ? lines.flatMap((line) => {
|
|
39
|
+
const m = new RegExp(`^#${step} \\d+\\.\\d+ (.*)$`).exec(line);
|
|
40
|
+
return m?.[1]?.trim() ? [m[1]] : [];
|
|
41
|
+
}) : [];
|
|
42
|
+
const code = /exit code: (\d+)/.exec(error ?? "")?.[1];
|
|
43
|
+
return {
|
|
44
|
+
error,
|
|
45
|
+
output: output.slice(-8),
|
|
46
|
+
exitCode: code ? Number(code) : null
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** ENOSPC or a SIGKILL (137, usually the out-of-memory killer), from anything the build printed. */
|
|
50
|
+
function failureCause(text, exitCode) {
|
|
51
|
+
if (/no space left on device|ENOSPC/i.test(text)) return "enospc";
|
|
52
|
+
if (exitCode === 137 || /exit(?:ed| code:?) 137\b|signal: killed|OOMKilled/i.test(text)) return "oom";
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const causeLine = (cause) => cause === "enospc" ? "out of disk space (ENOSPC): free space for Docker (docker system df, reeval clean --images) and rerun" : "killed (exit 137): usually out of memory; give Docker more memory or lower the concurrency";
|
|
56
|
+
const DETERMINISTIC = /did not complete successfully|dockerfile parse error|unknown instruction|should not be blank|pull access denied|repository does not exist|manifest unknown|not found: manifest|: not found\b|unauthorized|denied:|reeval needs a Debian or Ubuntu based image/i;
|
|
57
|
+
const TRANSIENT = /EAI_AGAIN|ECONNRESET|ETIMEDOUT|ECONNREFUSED|i\/o timeout|TLS handshake timeout|no such host|temporary failure in name resolution|connection reset by peer|dial tcp|net\/http: request canceled|unexpected EOF|\b(?:500|502|503|504) |429 Too Many Requests|toomanyrequests|Service Unavailable|Bad Gateway|Gateway Timeout|ENOTFOUND/i;
|
|
58
|
+
/** A registry or network error: a pull, a metadata lookup, or a download in the harness bootstrap. */
|
|
59
|
+
const isTransient = (text) => !DETERMINISTIC.test(text) && TRANSIENT.test(text);
|
|
60
|
+
/** Up to `retries` more tries on a transient failure, after `delays` ms each; any other throws at once. */
|
|
61
|
+
async function retryTransient(run, options = {}) {
|
|
62
|
+
const delays = options.delays ?? RETRY_DELAYS;
|
|
63
|
+
for (let i = 0;; i++) try {
|
|
64
|
+
return await run();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (!(error instanceof ImageBuildError ? error.failure.transient : isTransient(error.message)) || i >= delays.length || options.signal?.aborted) throw error;
|
|
67
|
+
options.onRetry?.(error);
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, delays[i]));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Two more tries, after 2 s and then 8 s. */
|
|
72
|
+
const RETRY_DELAYS = [2e3, 8e3];
|
|
73
|
+
/**
|
|
74
|
+
* A failure's text: the headline (what failed and why), the named cause, the step's own last
|
|
75
|
+
* lines, then the log. Never the generated Dockerfile.
|
|
76
|
+
*/
|
|
77
|
+
function failureMessage(parts) {
|
|
78
|
+
const lines = [parts.headline];
|
|
79
|
+
if (parts.cause) lines.push(causeLine(parts.cause));
|
|
80
|
+
if (parts.output.length) lines.push(...parts.output.map((l) => ` | ${l}`));
|
|
81
|
+
if (parts.log) lines.push(`(full log: ${parts.log})`);
|
|
82
|
+
return lines.join("\n");
|
|
83
|
+
}
|
|
84
|
+
/** The last lines of a bootstrap command's output, past the error's own first line. */
|
|
85
|
+
function outputTail(text, max = 8) {
|
|
86
|
+
return stripAnsi(text).split(/\r?\n/).map((l) => l.trimEnd()).filter((l) => l.trim()).slice(-max);
|
|
87
|
+
}
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region ../sandbox-docker/src/process.ts
|
|
90
|
+
const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
|
|
91
|
+
const cmdEscape = (arg, twice) => {
|
|
92
|
+
const quoted = `"${arg.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\*)$/, "$1$1")}"`.replace(CMD_META, "^$1");
|
|
93
|
+
return twice ? quoted.replace(CMD_META, "^$1") : quoted;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* How to spawn `cmd` on Windows, where npm, npx and pnpm are `.cmd` scripts that only cmd.exe
|
|
97
|
+
* runs: the program PATH and PATHEXT find, and for a script, cmd.exe with every argument escaped.
|
|
98
|
+
*/
|
|
99
|
+
function windowsSpawn(cmd, args, env = process.env, exists = existsSync) {
|
|
100
|
+
const exts = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
|
|
101
|
+
const file = (/[\\/]/.test(cmd) ? [""] : (env.PATH ?? env.Path ?? "").split(";").filter(Boolean)).flatMap((dir) => {
|
|
102
|
+
const base = dir ? win32.join(dir, cmd) : cmd;
|
|
103
|
+
return [...win32.extname(cmd) ? [base] : [], ...exts.map((ext) => base + ext.toLowerCase())];
|
|
104
|
+
}).find(exists) ?? cmd;
|
|
105
|
+
if (!/\.(cmd|bat)$/i.test(file)) return {
|
|
106
|
+
file,
|
|
107
|
+
args,
|
|
108
|
+
verbatim: false
|
|
109
|
+
};
|
|
110
|
+
const twice = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(file);
|
|
111
|
+
const line = [win32.normalize(file).replace(CMD_META, "^$1"), ...args.map((a) => cmdEscape(a, twice))].join(" ");
|
|
112
|
+
return {
|
|
113
|
+
file: env.ComSpec ?? "cmd.exe",
|
|
114
|
+
args: [
|
|
115
|
+
"/d",
|
|
116
|
+
"/s",
|
|
117
|
+
"/c",
|
|
118
|
+
`"${line}"`
|
|
119
|
+
],
|
|
120
|
+
verbatim: true
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Kills a process and everything it started: its process group on POSIX (a detached child leads
|
|
125
|
+
* one), its process tree with `taskkill` on Windows, which has neither groups nor signals.
|
|
126
|
+
*/
|
|
127
|
+
function killTree(pid) {
|
|
128
|
+
if (process.platform === "win32") {
|
|
129
|
+
spawn("taskkill", [
|
|
130
|
+
"/pid",
|
|
131
|
+
String(pid),
|
|
132
|
+
"/t",
|
|
133
|
+
"/f"
|
|
134
|
+
], {
|
|
135
|
+
stdio: "ignore",
|
|
136
|
+
windowsHide: true
|
|
137
|
+
}).on("error", () => {});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
process.kill(-pid, "SIGKILL");
|
|
142
|
+
} catch {
|
|
143
|
+
try {
|
|
144
|
+
process.kill(pid, "SIGKILL");
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** `s` as one single-quoted POSIX shell word. */
|
|
149
|
+
const shellQuote = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
|
|
150
|
+
function run(cmd, args, options = {}) {
|
|
151
|
+
const { signal } = options;
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
if (signal?.aborted) return reject(signal.reason);
|
|
154
|
+
const detached = options.detached ?? true;
|
|
155
|
+
const target = process.platform === "win32" ? windowsSpawn(cmd, args, options.env ? {
|
|
156
|
+
...process.env,
|
|
157
|
+
...options.env
|
|
158
|
+
} : process.env) : {
|
|
159
|
+
file: cmd,
|
|
160
|
+
args,
|
|
161
|
+
verbatim: false
|
|
162
|
+
};
|
|
163
|
+
const child = spawn(target.file, target.args, {
|
|
164
|
+
cwd: options.cwd,
|
|
165
|
+
stdio: "pipe",
|
|
166
|
+
detached: detached && !target.verbatim,
|
|
167
|
+
windowsHide: true,
|
|
168
|
+
windowsVerbatimArguments: target.verbatim,
|
|
169
|
+
env: options.env ? {
|
|
170
|
+
...process.env,
|
|
171
|
+
...options.env
|
|
172
|
+
} : process.env
|
|
173
|
+
});
|
|
174
|
+
const out = [];
|
|
175
|
+
let stderr = "";
|
|
176
|
+
child.stdout.on("data", (d) => out.push(d));
|
|
177
|
+
child.stderr.on("data", (d) => {
|
|
178
|
+
stderr += d;
|
|
179
|
+
});
|
|
180
|
+
const onAbort = () => {
|
|
181
|
+
if ((detached || process.platform === "win32") && child.pid) killTree(child.pid);
|
|
182
|
+
else child.kill("SIGKILL");
|
|
183
|
+
};
|
|
184
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
185
|
+
child.on("error", reject);
|
|
186
|
+
child.on("close", (code) => {
|
|
187
|
+
signal?.removeEventListener("abort", onAbort);
|
|
188
|
+
if (signal?.aborted) return reject(signal.reason);
|
|
189
|
+
const stdout = Buffer.concat(out);
|
|
190
|
+
resolve({
|
|
191
|
+
exitCode: code ?? 1,
|
|
192
|
+
stdout: options.binary ? stdout : stdout.toString(),
|
|
193
|
+
stderr
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
if (child.pid) options.onSpawn?.(child.pid);
|
|
197
|
+
child.stdin.on("error", () => {});
|
|
198
|
+
child.stdin.end(options.input ?? "");
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region ../sandbox-docker/src/trace.ts
|
|
203
|
+
/** Most of each stream a trace keeps; the rest is counted. */
|
|
204
|
+
const MAX_STREAM = 4096;
|
|
205
|
+
/** Most of each argument a trace keeps, e.g. the gateway's forwarder script. */
|
|
206
|
+
const MAX_ARG = 300;
|
|
207
|
+
let listener;
|
|
208
|
+
/** Calls `fn` for every docker command this process runs, or stops with `undefined`. */
|
|
209
|
+
function onDockerCommand(fn) {
|
|
210
|
+
listener = fn;
|
|
211
|
+
}
|
|
212
|
+
const clip = (text, max) => text.length > max ? `${text.slice(0, max)}… (${text.length - max} more)` : text;
|
|
213
|
+
/** Text as is (clipped), or a byte count for binary output such as a file read with `cat`. */
|
|
214
|
+
function streamPreview(data) {
|
|
215
|
+
if (typeof data === "string") return clip(data, MAX_STREAM);
|
|
216
|
+
if (data.includes(0) || !isUtf8(data)) return `<${data.length} bytes>`;
|
|
217
|
+
return clip(data.toString("utf8"), MAX_STREAM);
|
|
218
|
+
}
|
|
219
|
+
function traceDocker(args, startedAt, result) {
|
|
220
|
+
if (!listener) return;
|
|
221
|
+
const base = {
|
|
222
|
+
args: args.map((a) => clip(a, MAX_ARG)),
|
|
223
|
+
ms: Date.now() - startedAt
|
|
224
|
+
};
|
|
225
|
+
try {
|
|
226
|
+
if ("error" in result) {
|
|
227
|
+
const error = result.error instanceof Error ? result.error.message : String(result.error);
|
|
228
|
+
listener({
|
|
229
|
+
...base,
|
|
230
|
+
exitCode: null,
|
|
231
|
+
stdout: "",
|
|
232
|
+
stderr: "",
|
|
233
|
+
error
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
listener({
|
|
238
|
+
...base,
|
|
239
|
+
exitCode: result.exitCode,
|
|
240
|
+
stdout: result.stdout === void 0 ? "" : streamPreview(result.stdout),
|
|
241
|
+
stderr: clip(result.stderr ?? "", MAX_STREAM),
|
|
242
|
+
...result.stdin ? { stdin: result.stdin } : {}
|
|
243
|
+
});
|
|
244
|
+
} catch {}
|
|
245
|
+
}
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region ../sandbox-docker/src/docker-cli.ts
|
|
248
|
+
/** Runs the docker CLI. `env` values are passed via the CLI's environment, never argv. */
|
|
249
|
+
async function docker(args, options = {}) {
|
|
250
|
+
const startedAt = Date.now();
|
|
251
|
+
const stdin = options.input?.length ?? 0;
|
|
252
|
+
try {
|
|
253
|
+
const r = await run("docker", args, {
|
|
254
|
+
...options,
|
|
255
|
+
binary: true
|
|
256
|
+
});
|
|
257
|
+
traceDocker(args, startedAt, {
|
|
258
|
+
...r,
|
|
259
|
+
stdin
|
|
260
|
+
});
|
|
261
|
+
return r;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
traceDocker(args, startedAt, { error });
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function dockerOk(args, opts) {
|
|
268
|
+
const r = await docker(args, opts);
|
|
269
|
+
if (r.exitCode !== 0) throw new Error(`docker ${args[0]} failed: ${r.stderr.trim()}`);
|
|
270
|
+
return r.stdout.toString("utf8").trim();
|
|
271
|
+
}
|
|
272
|
+
/** Removes a container in its own process group, so removal survives this process being killed. */
|
|
273
|
+
function removeContainerDetached(id) {
|
|
274
|
+
return new Promise((resolve) => {
|
|
275
|
+
const keepAlive = setInterval(() => {}, 1e3);
|
|
276
|
+
const startedAt = Date.now();
|
|
277
|
+
const args = [
|
|
278
|
+
"rm",
|
|
279
|
+
"-f",
|
|
280
|
+
id
|
|
281
|
+
];
|
|
282
|
+
const done = (exitCode) => {
|
|
283
|
+
clearInterval(keepAlive);
|
|
284
|
+
traceDocker(args, startedAt, { exitCode });
|
|
285
|
+
resolve();
|
|
286
|
+
};
|
|
287
|
+
const child = spawn("docker", args, {
|
|
288
|
+
detached: true,
|
|
289
|
+
stdio: "ignore",
|
|
290
|
+
windowsHide: true
|
|
291
|
+
});
|
|
292
|
+
child.on("error", () => done(null));
|
|
293
|
+
child.on("close", done);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Removes every container with `label` (`key=value`) for the next few seconds, in its own process
|
|
298
|
+
* group so it outlives this process. For a `docker run` killed mid-way: the daemon may create the
|
|
299
|
+
* container after the CLI dies, and its id never came back.
|
|
300
|
+
*/
|
|
301
|
+
function sweepLabelDetached(label, seconds = 15) {
|
|
302
|
+
const child = spawn(process.execPath, [
|
|
303
|
+
"-e",
|
|
304
|
+
`const { execFileSync } = require('node:child_process')
|
|
305
|
+
const [filter, seconds] = process.argv.slice(1)
|
|
306
|
+
const o = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }
|
|
307
|
+
for (let i = 0; i < Number(seconds); i++) {
|
|
308
|
+
try {
|
|
309
|
+
const ids = execFileSync('docker', ['ps', '-aq', '--filter', filter], o).split(/\\s+/).filter(Boolean)
|
|
310
|
+
if (ids.length) execFileSync('docker', ['rm', '-f', ...ids], o)
|
|
311
|
+
} catch {}
|
|
312
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000)
|
|
313
|
+
}`,
|
|
314
|
+
`label=${label}`,
|
|
315
|
+
String(seconds)
|
|
316
|
+
], {
|
|
317
|
+
detached: true,
|
|
318
|
+
stdio: "ignore",
|
|
319
|
+
windowsHide: true,
|
|
320
|
+
env: {
|
|
321
|
+
...process.env,
|
|
322
|
+
BUN_BE_BUN: "1"
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
child.on("error", () => {});
|
|
326
|
+
child.unref();
|
|
327
|
+
}
|
|
328
|
+
async function dockerState() {
|
|
329
|
+
const r = await docker([
|
|
330
|
+
"info",
|
|
331
|
+
"--format",
|
|
332
|
+
"{{.ServerVersion}} {{.OSType}}"
|
|
333
|
+
]).catch(() => null);
|
|
334
|
+
const [version, os] = r?.exitCode === 0 ? r.stdout.toString("utf8").trim().split(/\s+/) : [];
|
|
335
|
+
if (!version) return "down";
|
|
336
|
+
return os === "windows" ? "windows-containers" : "running";
|
|
337
|
+
}
|
|
338
|
+
/** Whether Docker is running Linux containers, as every sandbox needs. */
|
|
339
|
+
async function dockerAvailable() {
|
|
340
|
+
return await dockerState() === "running";
|
|
341
|
+
}
|
|
342
|
+
/** CPUs and memory of the machine containers run on (the VM, under Docker Desktop). */
|
|
343
|
+
async function dockerCapacity() {
|
|
344
|
+
const r = await docker([
|
|
345
|
+
"info",
|
|
346
|
+
"--format",
|
|
347
|
+
"{{.NCPU}} {{.MemTotal}}"
|
|
348
|
+
]).catch(() => null);
|
|
349
|
+
if (r?.exitCode !== 0) return null;
|
|
350
|
+
const [cpus, memoryBytes] = r.stdout.toString("utf8").trim().split(/\s+/).map(Number);
|
|
351
|
+
return cpus && memoryBytes ? {
|
|
352
|
+
cpus,
|
|
353
|
+
memoryBytes
|
|
354
|
+
} : null;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Whether a RepoDigest shows the image is in a registry someone can pull from. The containerd
|
|
358
|
+
* image store (Docker 29+, recent Docker Desktop) also gives locally built images a digest, under
|
|
359
|
+
* their bare local name (`reeval-sandbox@sha256:…`). A bare name is Docker Hub's `library/`
|
|
360
|
+
* namespace, which only official images can push to, so only a digest whose name has a registry
|
|
361
|
+
* host or a namespace counts. Official images such as `busybox` count as local; no image with
|
|
362
|
+
* the harness baked in is one.
|
|
363
|
+
*/
|
|
364
|
+
const isRegistryDigest = (digest) => (digest.split("@")[0] ?? "").includes("/");
|
|
365
|
+
async function imageInfo(image) {
|
|
366
|
+
const r = await docker([
|
|
367
|
+
"image",
|
|
368
|
+
"inspect",
|
|
369
|
+
"--format",
|
|
370
|
+
"{\"config\":{{json .Config}},\"id\":{{json .Id}},\"repoDigests\":{{json .RepoDigests}}}",
|
|
371
|
+
image
|
|
372
|
+
]).catch(() => null);
|
|
373
|
+
if (r?.exitCode !== 0) return null;
|
|
374
|
+
const info = JSON.parse(r.stdout.toString("utf8"));
|
|
375
|
+
const repoDigests = (info.repoDigests ?? []).filter(isRegistryDigest);
|
|
376
|
+
return {
|
|
377
|
+
labels: info.config?.Labels ?? {},
|
|
378
|
+
id: info.id,
|
|
379
|
+
repoDigests
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region ../sandbox-docker/src/image.ts
|
|
384
|
+
/**
|
|
385
|
+
* The harness adapters an image can bake. Each pin must match the exact dependency (or
|
|
386
|
+
* `workspace:<version>` for this repo's own adapters); a test enforces it. `patch` is the first 8
|
|
387
|
+
* hex of the sha256 of the adapter's pnpm patch (`patchedDependencies` in pnpm-workspace.yaml),
|
|
388
|
+
* whose bridge the image bakes; a test holds it to the patch file.
|
|
389
|
+
*/
|
|
390
|
+
const HARNESS_PACKAGES = {
|
|
391
|
+
cursor: {
|
|
392
|
+
name: "@reeval/harness-cursor-sdk",
|
|
393
|
+
version: "0.1.0"
|
|
394
|
+
},
|
|
395
|
+
"claude-code": {
|
|
396
|
+
name: "@ai-sdk/harness-claude-code",
|
|
397
|
+
version: "1.0.128",
|
|
398
|
+
patch: "5d1c985c"
|
|
399
|
+
},
|
|
400
|
+
antigravity: {
|
|
401
|
+
name: "@reeval/harness-antigravity",
|
|
402
|
+
version: "0.1.0"
|
|
403
|
+
},
|
|
404
|
+
opencode: {
|
|
405
|
+
name: "@reeval/harness-opencode",
|
|
406
|
+
version: "0.2.0"
|
|
407
|
+
},
|
|
408
|
+
goose: {
|
|
409
|
+
name: "@reeval/harness-goose",
|
|
410
|
+
version: "0.1.0"
|
|
411
|
+
},
|
|
412
|
+
pi: {
|
|
413
|
+
name: "@reeval/harness-pi",
|
|
414
|
+
version: "0.1.0"
|
|
415
|
+
},
|
|
416
|
+
codex: {
|
|
417
|
+
name: "@reeval/harness-codex",
|
|
418
|
+
version: "0.1.0"
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
/** Every harness with an image, in {@link HARNESS_PACKAGES} order. */
|
|
422
|
+
const BAKED_HARNESSES = Object.keys(HARNESS_PACKAGES);
|
|
423
|
+
const isBakedHarness = (kind) => Object.hasOwn(HARNESS_PACKAGES, kind);
|
|
424
|
+
/** The adapter build reeval runs for a harness: its version, and its patch if reeval patches it. */
|
|
425
|
+
const adapterVersion = (harness) => {
|
|
426
|
+
const { version, patch } = HARNESS_PACKAGES[harness];
|
|
427
|
+
return patch ? `${version}-patch.${patch}` : version;
|
|
428
|
+
};
|
|
429
|
+
const BASE_IMAGE = "reeval-sandbox-base:latest";
|
|
430
|
+
/** reeval's image for a harness; the tag pins the adapter, whose bootstrap pins the CLI. */
|
|
431
|
+
const imageFor = (harness) => `reeval-sandbox:${harness}-${adapterVersion(harness)}`;
|
|
432
|
+
/** Cursor's image, which the fake harness also runs in by default. */
|
|
433
|
+
const DEFAULT_IMAGE = imageFor("cursor");
|
|
434
|
+
/** The `ai.reeval.harness` label value an image baked for `harness` carries. */
|
|
435
|
+
const harnessLabel = (harness) => `${HARNESS_PACKAGES[harness].name}@${adapterVersion(harness)}`;
|
|
436
|
+
const LABEL = {
|
|
437
|
+
managed: "ai.reeval.managed",
|
|
438
|
+
run: "ai.reeval.run",
|
|
439
|
+
/** Unique per session, so a create cut short can find what it made. */
|
|
440
|
+
session: "ai.reeval.session",
|
|
441
|
+
agentVersion: "ai.reeval.agent-version",
|
|
442
|
+
harness: "ai.reeval.harness",
|
|
443
|
+
/** On reeval's own images: the hash of the base image's build context, to tell a stale one. */
|
|
444
|
+
context: "ai.reeval.context",
|
|
445
|
+
/** On a `from` image: the digest (or, for a local-only base, the image id) it was built on. */
|
|
446
|
+
fromDigest: "ai.reeval.from-digest"
|
|
447
|
+
};
|
|
448
|
+
/**
|
|
449
|
+
* The user the agent, setup steps and checks run as, which every image reeval builds creates. Image
|
|
450
|
+
* builds (`install`, Dockerfiles) stay root.
|
|
451
|
+
*/
|
|
452
|
+
const SANDBOX_USER = "reeval";
|
|
453
|
+
/** {@link SANDBOX_USER}'s home. */
|
|
454
|
+
const SANDBOX_HOME = `/home/${SANDBOX_USER}`;
|
|
455
|
+
/** The container's default working directory, as seen by the harness. */
|
|
456
|
+
const SANDBOX_ROOT = "/reeval";
|
|
457
|
+
/** The agent's working directory, relative to {@link SANDBOX_ROOT}. */
|
|
458
|
+
const WORKSPACE_DIR = "workspace";
|
|
459
|
+
const BRIDGE_PORT = 43210;
|
|
460
|
+
//#endregion
|
|
461
|
+
//#region ../sandbox-docker/src/egress/cert.ts
|
|
462
|
+
const der = (tag, ...content) => {
|
|
463
|
+
const body = Buffer.concat(content);
|
|
464
|
+
const n = body.length;
|
|
465
|
+
const length = n < 128 ? Buffer.from([n]) : n < 256 ? Buffer.from([129, n]) : n < 65536 ? Buffer.from([
|
|
466
|
+
130,
|
|
467
|
+
n >> 8,
|
|
468
|
+
n & 255
|
|
469
|
+
]) : Buffer.from([
|
|
470
|
+
131,
|
|
471
|
+
n >> 16,
|
|
472
|
+
n >> 8 & 255,
|
|
473
|
+
n & 255
|
|
474
|
+
]);
|
|
475
|
+
return Buffer.concat([
|
|
476
|
+
Buffer.from([tag]),
|
|
477
|
+
length,
|
|
478
|
+
body
|
|
479
|
+
]);
|
|
480
|
+
};
|
|
481
|
+
const seq = (...items) => der(48, ...items);
|
|
482
|
+
const set = (...items) => der(49, ...items);
|
|
483
|
+
const octets = (bytes) => der(4, bytes);
|
|
484
|
+
const utf8 = (text) => der(12, Buffer.from(text, "utf8"));
|
|
485
|
+
const bool = (value) => der(1, Buffer.from([value ? 255 : 0]));
|
|
486
|
+
const explicit = (n, inner) => der(160 + n, inner);
|
|
487
|
+
const integer = (bytes) => {
|
|
488
|
+
let i = 0;
|
|
489
|
+
while (i < bytes.length - 1 && bytes[i] === 0 && bytes[i + 1] < 128) i++;
|
|
490
|
+
const trimmed = bytes.subarray(i);
|
|
491
|
+
return der(2, trimmed[0] >= 128 ? Buffer.concat([Buffer.from([0]), trimmed]) : trimmed);
|
|
492
|
+
};
|
|
493
|
+
const bits = (bytes, unused = 0) => der(3, Buffer.from([unused]), bytes);
|
|
494
|
+
const oid = (dotted) => {
|
|
495
|
+
const [a = 0, b = 0, ...rest] = dotted.split(".").map(Number);
|
|
496
|
+
const out = [40 * a + b];
|
|
497
|
+
for (const arc of rest) {
|
|
498
|
+
const chunk = [arc & 127];
|
|
499
|
+
for (let v = arc >> 7; v > 0; v >>= 7) chunk.unshift(v & 127 | 128);
|
|
500
|
+
out.push(...chunk);
|
|
501
|
+
}
|
|
502
|
+
return der(6, Buffer.from(out));
|
|
503
|
+
};
|
|
504
|
+
const time = (date) => {
|
|
505
|
+
const iso = date.toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
506
|
+
return date.getUTCFullYear() < 2050 ? der(23, Buffer.from(`${iso.slice(2)}Z`)) : der(24, Buffer.from(`${iso}Z`));
|
|
507
|
+
};
|
|
508
|
+
const ECDSA_SHA256 = seq(oid("1.2.840.10045.4.3.2"));
|
|
509
|
+
const name = (commonName) => seq(set(seq(oid("2.5.4.10"), utf8("reeval"))), set(seq(oid("2.5.4.3"), utf8(commonName))));
|
|
510
|
+
const extension = (id, critical, value) => seq(oid(id), ...critical ? [bool(true)] : [], octets(value));
|
|
511
|
+
/** Key usage bits, most significant first, with DER's minimal "unused bits" count. */
|
|
512
|
+
const keyUsage = (usage) => {
|
|
513
|
+
let unused = 0;
|
|
514
|
+
while (unused < 7 && !(usage & 1 << unused)) unused++;
|
|
515
|
+
return bits(Buffer.from([usage]), unused);
|
|
516
|
+
};
|
|
517
|
+
const keyId = (spki) => createHash("sha1").update(spki).digest();
|
|
518
|
+
const pem = (bytes) => `-----BEGIN CERTIFICATE-----\n${bytes.toString("base64").replace(/.{64}/g, "$&\n").replace(/\n?$/, "\n")}-----END CERTIFICATE-----\n`;
|
|
519
|
+
function issue(options) {
|
|
520
|
+
const spki = options.publicKey.export({
|
|
521
|
+
type: "spki",
|
|
522
|
+
format: "der"
|
|
523
|
+
});
|
|
524
|
+
const serial = randomBytes(16);
|
|
525
|
+
serial[0] = serial[0] & 127;
|
|
526
|
+
const notBefore = /* @__PURE__ */ new Date(Date.now() - 36e5);
|
|
527
|
+
const notAfter = new Date(notBefore.getTime() + options.days * 864e5);
|
|
528
|
+
const tbs = seq(explicit(0, integer(Buffer.from([2]))), integer(serial), ECDSA_SHA256, name(options.issuer), seq(time(notBefore), time(notAfter)), name(options.subject), spki, explicit(3, seq(extension("2.5.29.14", false, octets(keyId(spki))), extension("2.5.29.35", false, seq(der(128, options.authorityKeyId))), ...options.extensions)));
|
|
529
|
+
return pem(seq(tbs, ECDSA_SHA256, bits(sign("sha256", tbs, options.signer))));
|
|
530
|
+
}
|
|
531
|
+
const newKeyPair = () => generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
532
|
+
/** A CA for one gateway. Its private key only ever exists in this process's memory. */
|
|
533
|
+
function createCertificateAuthority(days = 7) {
|
|
534
|
+
const { publicKey, privateKey } = newKeyPair();
|
|
535
|
+
const spki = publicKey.export({
|
|
536
|
+
type: "spki",
|
|
537
|
+
format: "der"
|
|
538
|
+
});
|
|
539
|
+
const subject = `reeval egress CA ${randomBytes(4).toString("hex")}`;
|
|
540
|
+
return {
|
|
541
|
+
cert: issue({
|
|
542
|
+
subject,
|
|
543
|
+
issuer: subject,
|
|
544
|
+
publicKey,
|
|
545
|
+
signer: privateKey,
|
|
546
|
+
authorityKeyId: keyId(spki),
|
|
547
|
+
days,
|
|
548
|
+
extensions: [extension("2.5.29.19", true, seq(bool(true), integer(Buffer.from([0])))), extension("2.5.29.15", true, keyUsage(134))]
|
|
549
|
+
}),
|
|
550
|
+
key: privateKey,
|
|
551
|
+
spki,
|
|
552
|
+
subject
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/** A TLS server certificate for `host` (a DNS name or an IP address), signed by `ca`. */
|
|
556
|
+
function issueServerCertificate(ca, host) {
|
|
557
|
+
const { publicKey, privateKey } = newKeyPair();
|
|
558
|
+
const version = isIP(host);
|
|
559
|
+
const altName = version ? der(135, Buffer.from(version === 4 ? host.split(".").map(Number) : ipv6Bytes(host))) : der(130, Buffer.from(host, "ascii"));
|
|
560
|
+
return {
|
|
561
|
+
cert: issue({
|
|
562
|
+
subject: host,
|
|
563
|
+
issuer: ca.subject,
|
|
564
|
+
publicKey,
|
|
565
|
+
signer: ca.key,
|
|
566
|
+
authorityKeyId: keyId(ca.spki),
|
|
567
|
+
days: 7,
|
|
568
|
+
extensions: [
|
|
569
|
+
extension("2.5.29.19", true, seq()),
|
|
570
|
+
extension("2.5.29.15", true, keyUsage(128)),
|
|
571
|
+
extension("2.5.29.37", false, seq(oid("1.3.6.1.5.5.7.3.1"))),
|
|
572
|
+
extension("2.5.29.17", false, seq(altName))
|
|
573
|
+
]
|
|
574
|
+
}),
|
|
575
|
+
key: privateKey
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function ipv6Bytes(address) {
|
|
579
|
+
const [head = "", tail = ""] = address.split("::");
|
|
580
|
+
const parts = (s) => s ? s.split(":") : [];
|
|
581
|
+
const h = parts(head);
|
|
582
|
+
const t = parts(tail);
|
|
583
|
+
return [
|
|
584
|
+
...h,
|
|
585
|
+
...Array(8 - h.length - t.length).fill("0"),
|
|
586
|
+
...t
|
|
587
|
+
].flatMap((g) => {
|
|
588
|
+
const v = Number.parseInt(g, 16);
|
|
589
|
+
return [v >> 8, v & 255];
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
//#endregion
|
|
593
|
+
//#region ../sandbox-docker/src/egress/mux.ts
|
|
594
|
+
/**
|
|
595
|
+
* Many TCP streams over one byte pipe (a `docker exec` stdio), with per-stream credit so a slow
|
|
596
|
+
* reader only stalls its own stream. Frames are `type u8, id u32, length u32, payload`.
|
|
597
|
+
*
|
|
598
|
+
* Must stay self-contained, with no imports or references to module scope: the forwarder runs
|
|
599
|
+
* this function's source inside the container.
|
|
600
|
+
*/
|
|
601
|
+
function createMux(options) {
|
|
602
|
+
const OPEN = 1;
|
|
603
|
+
const DATA = 2;
|
|
604
|
+
const FIN = 3;
|
|
605
|
+
const RST = 4;
|
|
606
|
+
const WINDOW = 5;
|
|
607
|
+
const READY = 6;
|
|
608
|
+
const windowSize = options.window ?? 262144;
|
|
609
|
+
const maxPayload = 65536;
|
|
610
|
+
const streams = /* @__PURE__ */ new Map();
|
|
611
|
+
let nextId = options.parity === 0 ? 2 : 1;
|
|
612
|
+
let pending = Buffer.alloc(0);
|
|
613
|
+
const frame = (type, id, payload = Buffer.alloc(0)) => {
|
|
614
|
+
const header = Buffer.alloc(9);
|
|
615
|
+
header.writeUInt8(type, 0);
|
|
616
|
+
header.writeUInt32BE(id, 1);
|
|
617
|
+
header.writeUInt32BE(payload.length, 5);
|
|
618
|
+
options.send(Buffer.concat([header, payload]));
|
|
619
|
+
};
|
|
620
|
+
const u32 = (n) => {
|
|
621
|
+
const b = Buffer.alloc(4);
|
|
622
|
+
b.writeUInt32BE(n, 0);
|
|
623
|
+
return b;
|
|
624
|
+
};
|
|
625
|
+
const create = (id) => {
|
|
626
|
+
const grant = () => {
|
|
627
|
+
if (state.unacked > 0 && !state.reset) frame(WINDOW, id, u32(state.unacked));
|
|
628
|
+
state.unacked = 0;
|
|
629
|
+
};
|
|
630
|
+
const stream = new options.Duplex({
|
|
631
|
+
allowHalfOpen: true,
|
|
632
|
+
read() {
|
|
633
|
+
grant();
|
|
634
|
+
},
|
|
635
|
+
write(chunk, _encoding, callback) {
|
|
636
|
+
for (let i = 0; i < chunk.length; i += maxPayload) frame(DATA, id, chunk.subarray(i, i + maxPayload));
|
|
637
|
+
state.credit -= chunk.length;
|
|
638
|
+
if (state.credit > 0) callback();
|
|
639
|
+
else state.blocked = callback;
|
|
640
|
+
},
|
|
641
|
+
final(callback) {
|
|
642
|
+
state.localEnded = true;
|
|
643
|
+
frame(FIN, id);
|
|
644
|
+
callback();
|
|
645
|
+
},
|
|
646
|
+
destroy(error, callback) {
|
|
647
|
+
streams.delete(id);
|
|
648
|
+
if (!state.reset && !(state.localEnded && state.remoteEnded)) frame(RST, id);
|
|
649
|
+
state.blocked = null;
|
|
650
|
+
callback(error);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
const state = {
|
|
654
|
+
stream,
|
|
655
|
+
credit: windowSize,
|
|
656
|
+
unacked: 0,
|
|
657
|
+
blocked: null,
|
|
658
|
+
localEnded: false,
|
|
659
|
+
remoteEnded: false,
|
|
660
|
+
reset: false
|
|
661
|
+
};
|
|
662
|
+
streams.set(id, state);
|
|
663
|
+
return stream;
|
|
664
|
+
};
|
|
665
|
+
const handle = (type, id, payload) => {
|
|
666
|
+
if (type === READY) return options.onReady?.();
|
|
667
|
+
if (type === OPEN) return options.onOpen(create(id), payload.readUInt16BE(0));
|
|
668
|
+
const state = streams.get(id);
|
|
669
|
+
if (!state) return;
|
|
670
|
+
if (type === DATA) {
|
|
671
|
+
state.unacked += payload.length;
|
|
672
|
+
if (state.stream.push(payload) && state.unacked >= windowSize / 2) {
|
|
673
|
+
frame(WINDOW, id, u32(state.unacked));
|
|
674
|
+
state.unacked = 0;
|
|
675
|
+
}
|
|
676
|
+
} else if (type === WINDOW) {
|
|
677
|
+
state.credit += payload.readUInt32BE(0);
|
|
678
|
+
const blocked = state.blocked;
|
|
679
|
+
if (state.credit > 0 && blocked) {
|
|
680
|
+
state.blocked = null;
|
|
681
|
+
blocked();
|
|
682
|
+
}
|
|
683
|
+
} else if (type === FIN) {
|
|
684
|
+
state.remoteEnded = true;
|
|
685
|
+
state.stream.push(null);
|
|
686
|
+
} else if (type === RST) {
|
|
687
|
+
state.reset = true;
|
|
688
|
+
state.stream.destroy();
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
return {
|
|
692
|
+
receive(chunk) {
|
|
693
|
+
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
|
|
694
|
+
while (pending.length >= 9) {
|
|
695
|
+
const length = pending.readUInt32BE(5);
|
|
696
|
+
if (pending.length < 9 + length) break;
|
|
697
|
+
const type = pending.readUInt8(0);
|
|
698
|
+
const id = pending.readUInt32BE(1);
|
|
699
|
+
const payload = pending.subarray(9, 9 + length);
|
|
700
|
+
pending = pending.subarray(9 + length);
|
|
701
|
+
handle(type, id, payload);
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
open(port) {
|
|
705
|
+
const id = nextId;
|
|
706
|
+
nextId += 2;
|
|
707
|
+
const stream = create(id);
|
|
708
|
+
const p = Buffer.alloc(2);
|
|
709
|
+
p.writeUInt16BE(port, 0);
|
|
710
|
+
frame(OPEN, id, p);
|
|
711
|
+
return stream;
|
|
712
|
+
},
|
|
713
|
+
ready() {
|
|
714
|
+
frame(READY, 0);
|
|
715
|
+
},
|
|
716
|
+
destroy() {
|
|
717
|
+
for (const state of [...streams.values()]) state.stream.destroy();
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Pipes two duplexes into each other. An error, or one side closing before its end, tears both
|
|
723
|
+
* down; a clean end half-closes the other side through `pipe`.
|
|
724
|
+
*/
|
|
725
|
+
function link(a, b) {
|
|
726
|
+
a.pipe(b);
|
|
727
|
+
b.pipe(a);
|
|
728
|
+
const kill = () => {
|
|
729
|
+
a.destroy();
|
|
730
|
+
b.destroy();
|
|
731
|
+
};
|
|
732
|
+
for (const s of [a, b]) {
|
|
733
|
+
s.on("error", kill);
|
|
734
|
+
s.on("close", () => {
|
|
735
|
+
if (!s.readableEnded) kill();
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* The container side: listens on 127.0.0.1:`proxyPort` and sends each connection to the host,
|
|
741
|
+
* and connects the host's streams to 127.0.0.1:<port> (the harness bridge). Self-contained for
|
|
742
|
+
* the same reason as {@link createMux}.
|
|
743
|
+
*/
|
|
744
|
+
function forwarderMain(io, makeMux, linkStreams, proxyPort) {
|
|
745
|
+
const mux = makeMux({
|
|
746
|
+
Duplex: io.Duplex,
|
|
747
|
+
parity: 1,
|
|
748
|
+
send: (f) => io.output.write(f),
|
|
749
|
+
onOpen(stream, port) {
|
|
750
|
+
linkStreams(io.net.connect({
|
|
751
|
+
port,
|
|
752
|
+
host: "127.0.0.1",
|
|
753
|
+
allowHalfOpen: true
|
|
754
|
+
}), stream);
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
io.input.on("data", (chunk) => mux.receive(chunk));
|
|
758
|
+
io.input.on("end", () => io.exit(0));
|
|
759
|
+
const server = io.net.createServer({ allowHalfOpen: true }, (socket) => linkStreams(socket, mux.open(proxyPort)));
|
|
760
|
+
server.on("error", (error) => {
|
|
761
|
+
io.error(`reeval egress forwarder: ${error.message}`);
|
|
762
|
+
io.exit(1);
|
|
763
|
+
});
|
|
764
|
+
server.listen(proxyPort, "127.0.0.1", () => mux.ready());
|
|
765
|
+
}
|
|
766
|
+
/** The `node -e` script that runs {@link forwarderMain} in the container. */
|
|
767
|
+
const forwarderScript = (proxyPort) => `(${forwarderMain.toString()})({ net: require('node:net'), Duplex: require('node:stream').Duplex, input: process.stdin, output: process.stdout, error: (m) => process.stderr.write(m + '\\n'), exit: (c) => process.exit(c) }, ${createMux.toString()}, ${link.toString()}, ${proxyPort})`;
|
|
768
|
+
//#endregion
|
|
769
|
+
//#region ../sandbox-docker/src/egress/policy.ts
|
|
770
|
+
/** The Docker sandbox enforces every policy through its egress gateway, and records what passes. */
|
|
771
|
+
const DOCKER_EGRESS = {
|
|
772
|
+
enforce: "allowlist",
|
|
773
|
+
record: true
|
|
774
|
+
};
|
|
775
|
+
/** The hosts each harness's CLI talks to at runtime. */
|
|
776
|
+
const HARNESS_HOSTS = {
|
|
777
|
+
cursor: ["api2.cursor.sh", "*.cursor.sh"],
|
|
778
|
+
"claude-code": ["api.anthropic.com", "*.anthropic.com"],
|
|
779
|
+
antigravity: ["generativelanguage.googleapis.com"],
|
|
780
|
+
opencode: ["generativelanguage.googleapis.com", "mcp.exa.ai"],
|
|
781
|
+
goose: ["generativelanguage.googleapis.com"],
|
|
782
|
+
pi: ["generativelanguage.googleapis.com"],
|
|
783
|
+
codex: ["api.openai.com"]
|
|
784
|
+
};
|
|
785
|
+
/** The package registries and GitHub, for a restricted policy that still installs. */
|
|
786
|
+
const PACKAGE_HOSTS = Object.freeze([
|
|
787
|
+
"registry.npmjs.org",
|
|
788
|
+
"registry.yarnpkg.com",
|
|
789
|
+
"github.com",
|
|
790
|
+
"*.github.com",
|
|
791
|
+
"*.githubusercontent.com",
|
|
792
|
+
"pypi.org",
|
|
793
|
+
"files.pythonhosted.org"
|
|
794
|
+
]);
|
|
795
|
+
/**
|
|
796
|
+
* The harness's own hosts plus `PACKAGE_HOSTS`. Only that harness's hosts: a Cursor attempt
|
|
797
|
+
* can't reach Anthropic.
|
|
798
|
+
*/
|
|
799
|
+
const defaultAllowedHosts = (harness) => [...HARNESS_HOSTS[harness], ...PACKAGE_HOSTS];
|
|
800
|
+
/**
|
|
801
|
+
* Never reachable through the gateway, whatever the policy: the worker's own loopback (the
|
|
802
|
+
* container's `localhost` is its own) and link-local addresses such as cloud metadata. A
|
|
803
|
+
* connection to an unspecified address (`0.0.0.0`, `::`) reaches the local host too.
|
|
804
|
+
*/
|
|
805
|
+
const ALWAYS_DENIED = [
|
|
806
|
+
"127.0.0.0/8",
|
|
807
|
+
"169.254.0.0/16",
|
|
808
|
+
"0.0.0.0/8",
|
|
809
|
+
"::/128",
|
|
810
|
+
"::1/128",
|
|
811
|
+
"fe80::/10"
|
|
812
|
+
];
|
|
813
|
+
const isRestricted = (policy) => policy.mode !== "allow-all";
|
|
814
|
+
const blockList = (cidrs = []) => {
|
|
815
|
+
const list = new BlockList();
|
|
816
|
+
for (const cidr of cidrs) {
|
|
817
|
+
const [net = "", prefix] = cidr.split("/");
|
|
818
|
+
const type = isIP(net) === 6 ? "ipv6" : "ipv4";
|
|
819
|
+
list.addSubnet(net, Number(prefix ?? (type === "ipv6" ? 128 : 32)), type);
|
|
820
|
+
}
|
|
821
|
+
return list;
|
|
822
|
+
};
|
|
823
|
+
const inList = (list, address) => {
|
|
824
|
+
const v = isIP(address);
|
|
825
|
+
return v !== 0 && list.check(address, v === 6 ? "ipv6" : "ipv4");
|
|
826
|
+
};
|
|
827
|
+
const normalise = (host) => host.toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
|
|
828
|
+
/**
|
|
829
|
+
* Private, carrier-grade NAT and unique-local ranges: a name matched only by a `*.` pattern
|
|
830
|
+
* mustn't resolve into them, or anyone who can add a DNS record under the pattern's domain
|
|
831
|
+
* reaches the worker's LAN.
|
|
832
|
+
*/
|
|
833
|
+
const PRIVATE = blockList([
|
|
834
|
+
"10.0.0.0/8",
|
|
835
|
+
"100.64.0.0/10",
|
|
836
|
+
"172.16.0.0/12",
|
|
837
|
+
"192.168.0.0/16",
|
|
838
|
+
"fc00::/7"
|
|
839
|
+
]);
|
|
840
|
+
/** How `host` matches: listed as it is, only by a `*.` pattern (never an IP address), or not. */
|
|
841
|
+
function hostMatch(patterns, host) {
|
|
842
|
+
const h = normalise(host);
|
|
843
|
+
const ps = patterns.map(normalise);
|
|
844
|
+
if (ps.includes(h)) return "exact";
|
|
845
|
+
if (isIP(h) || /(?:^|\.)\d+$/.test(h)) return void 0;
|
|
846
|
+
return ps.some((p) => p.startsWith("*.") && h.endsWith(p.slice(1))) ? "wildcard" : void 0;
|
|
847
|
+
}
|
|
848
|
+
/** `*.example.com` matches any subdomain of example.com, not example.com itself or an IP. */
|
|
849
|
+
const hostAllowed = (patterns, host) => hostMatch(patterns, host) !== void 0;
|
|
850
|
+
const defaultLookup = async (host) => (await lookup(host, {
|
|
851
|
+
all: true,
|
|
852
|
+
verbatim: true
|
|
853
|
+
})).map((a) => a.address);
|
|
854
|
+
/**
|
|
855
|
+
* Decides whether `host` may be reached and which address to connect to. The gateway connects
|
|
856
|
+
* to the address it checked, so a DNS answer can't change between the check and the connect.
|
|
857
|
+
*/
|
|
858
|
+
function createPolicyCheck(policy, options = {}) {
|
|
859
|
+
const lookup = options.lookup ?? defaultLookup;
|
|
860
|
+
const always = blockList(options.allowLoopback ? [] : ALWAYS_DENIED);
|
|
861
|
+
const custom = policy.mode === "custom" ? policy : void 0;
|
|
862
|
+
const allowedCidrs = blockList(custom?.allowedCIDRs);
|
|
863
|
+
const deniedCidrs = blockList(custom?.deniedCIDRs);
|
|
864
|
+
return async (rawHost) => {
|
|
865
|
+
const host = normalise(rawHost);
|
|
866
|
+
if (policy.mode === "deny-all") return {
|
|
867
|
+
allowed: false,
|
|
868
|
+
status: 403,
|
|
869
|
+
reason: "the policy denies all egress"
|
|
870
|
+
};
|
|
871
|
+
const listed = hostMatch(custom?.allowedHosts ?? [], host);
|
|
872
|
+
if (custom && !listed && !custom.allowedCIDRs?.length) return {
|
|
873
|
+
allowed: false,
|
|
874
|
+
status: 403,
|
|
875
|
+
reason: `${host} isn't in the allowlist`
|
|
876
|
+
};
|
|
877
|
+
let addresses;
|
|
878
|
+
try {
|
|
879
|
+
addresses = isIP(host) ? [host] : await lookup(host);
|
|
880
|
+
} catch (error) {
|
|
881
|
+
return {
|
|
882
|
+
allowed: false,
|
|
883
|
+
status: 502,
|
|
884
|
+
reason: `can't resolve ${host}: ${error.message}`
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
const first = addresses[0];
|
|
888
|
+
if (!first) return {
|
|
889
|
+
allowed: false,
|
|
890
|
+
status: 502,
|
|
891
|
+
reason: `${host} has no addresses`
|
|
892
|
+
};
|
|
893
|
+
if (addresses.some((a) => inList(always, a))) return {
|
|
894
|
+
allowed: false,
|
|
895
|
+
status: 403,
|
|
896
|
+
reason: `${host} is a loopback or link-local address`
|
|
897
|
+
};
|
|
898
|
+
if (!custom) return {
|
|
899
|
+
allowed: true,
|
|
900
|
+
address: first
|
|
901
|
+
};
|
|
902
|
+
if (addresses.some((a) => inList(deniedCidrs, a))) return {
|
|
903
|
+
allowed: false,
|
|
904
|
+
status: 403,
|
|
905
|
+
reason: `${host} is in a denied range`
|
|
906
|
+
};
|
|
907
|
+
if (listed === "wildcard" && addresses.some((a) => inList(PRIVATE, a))) return {
|
|
908
|
+
allowed: false,
|
|
909
|
+
status: 403,
|
|
910
|
+
reason: `${host} resolves to a private address, which a *. pattern doesn't allow; list the host itself`
|
|
911
|
+
};
|
|
912
|
+
if (listed) return {
|
|
913
|
+
allowed: true,
|
|
914
|
+
address: first
|
|
915
|
+
};
|
|
916
|
+
if (custom.allowedCIDRs?.length && addresses.every((a) => inList(allowedCidrs, a))) return {
|
|
917
|
+
allowed: true,
|
|
918
|
+
address: first
|
|
919
|
+
};
|
|
920
|
+
return {
|
|
921
|
+
allowed: false,
|
|
922
|
+
status: 403,
|
|
923
|
+
reason: `${host} isn't in the allowlist`
|
|
924
|
+
};
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
//#endregion
|
|
928
|
+
//#region ../sandbox-docker/src/egress/rules.ts
|
|
929
|
+
const normaliseHost = (host) => host.toLowerCase().replace(/\.$/, "");
|
|
930
|
+
function matchPart(matcher, value, ignoreCase = false) {
|
|
931
|
+
const v = ignoreCase ? value.toLowerCase() : value;
|
|
932
|
+
if ("exact" in matcher) return v === (ignoreCase ? matcher.exact.toLowerCase() : matcher.exact);
|
|
933
|
+
if ("startsWith" in matcher) return v.startsWith(ignoreCase ? matcher.startsWith.toLowerCase() : matcher.startsWith);
|
|
934
|
+
return new RegExp(matcher.regex, ignoreCase ? "i" : "").test(value);
|
|
935
|
+
}
|
|
936
|
+
/** Every matcher must match at least one pair; keys compare case-insensitively for headers. */
|
|
937
|
+
function matchPairs(matchers, pairs, ignoreKeyCase) {
|
|
938
|
+
return (matchers ?? []).every((m) => pairs.some(([k, v]) => (!m.key || matchPart(m.key, k, ignoreKeyCase)) && (!m.value || matchPart(m.value, v))));
|
|
939
|
+
}
|
|
940
|
+
const headerPairs = (headers) => Object.entries(headers).flatMap(([k, v]) => v === void 0 ? [] : (Array.isArray(v) ? v : [v]).map((x) => [k, x]));
|
|
941
|
+
function matches(rule, request) {
|
|
942
|
+
const { match } = rule;
|
|
943
|
+
if (normaliseHost(match.host) !== normaliseHost(request.host)) return false;
|
|
944
|
+
const url = new URL(request.path, "https://placeholder.invalid");
|
|
945
|
+
if (match.path && !matchPart(match.path, url.pathname)) return false;
|
|
946
|
+
if (match.method && !match.method.some((m) => m.toUpperCase() === request.method.toUpperCase())) return false;
|
|
947
|
+
if (match.queryString && !matchPairs(match.queryString, [...url.searchParams], false)) return false;
|
|
948
|
+
return !match.headers || matchPairs(match.headers, headerPairs(request.headers), true);
|
|
949
|
+
}
|
|
950
|
+
/** Whether the request is one of the endpoints (host, path and method, where given). */
|
|
951
|
+
const onEndpoint = (endpoints, request) => endpoints.some((match) => matches({
|
|
952
|
+
match,
|
|
953
|
+
transform: { headers: {} }
|
|
954
|
+
}, request));
|
|
955
|
+
/** Whether the gateway must decrypt connections to `host`: some rule could apply there. */
|
|
956
|
+
const decrypts = (rules, host) => rules.some((r) => normaliseHost(r.match.host) === normaliseHost(host));
|
|
957
|
+
/**
|
|
958
|
+
* The request's headers after every matching rule, in order, so later rules win. Returns the
|
|
959
|
+
* (lower-case) names that were replaced, for logging without values. With `endpoints`, no rule
|
|
960
|
+
* applies off them, and a request carrying a rule's placeholder there is `refused`.
|
|
961
|
+
*/
|
|
962
|
+
function applyTransformations(rules, request, endpoints) {
|
|
963
|
+
const headers = { ...request.headers };
|
|
964
|
+
const replaced = /* @__PURE__ */ new Set();
|
|
965
|
+
const matching = rules.filter((rule) => matches(rule, request));
|
|
966
|
+
if (endpoints && matching.length && !onEndpoint(endpoints, request)) return {
|
|
967
|
+
headers,
|
|
968
|
+
replaced: [],
|
|
969
|
+
refused: matching.some((r) => r.match.headers?.length)
|
|
970
|
+
};
|
|
971
|
+
for (const rule of matching) for (const [name, value] of Object.entries(rule.transform.headers)) {
|
|
972
|
+
headers[name.toLowerCase()] = value;
|
|
973
|
+
replaced.add(name.toLowerCase());
|
|
974
|
+
}
|
|
975
|
+
return {
|
|
976
|
+
headers,
|
|
977
|
+
replaced: [...replaced],
|
|
978
|
+
refused: false
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
//#endregion
|
|
982
|
+
//#region ../sandbox-docker/src/egress/substitution.ts
|
|
983
|
+
const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
984
|
+
/** A fresh `reeval_secret_<32 base62>`: URL-safe, so it survives any encoding a client applies. */
|
|
985
|
+
function placeholder() {
|
|
986
|
+
let out = "reeval_secret_";
|
|
987
|
+
for (let i = 0; i < 32; i++) out += BASE62[randomInt(62)];
|
|
988
|
+
return out;
|
|
989
|
+
}
|
|
990
|
+
/** The substitutions whose hosts include `host`. */
|
|
991
|
+
const substitutionsFor = (subs, host) => subs.filter((s) => hostAllowed(s.hosts, host));
|
|
992
|
+
/** Whether the gateway must decrypt connections to `host` for some substitution. */
|
|
993
|
+
const substitutes = (subs, host) => substitutionsFor(subs, host).length > 0;
|
|
994
|
+
const replaceIn = (value, from, to) => value.includes(from) ? value.split(from).join(to) : value;
|
|
995
|
+
/**
|
|
996
|
+
* The request after its host's substitutions: placeholders replaced in header values, and in the
|
|
997
|
+
* query string (URL-encoded) for those opted in. Returns the names substituted, for logging without values.
|
|
998
|
+
*/
|
|
999
|
+
function substituteRequest(subs, request) {
|
|
1000
|
+
const own = substitutionsFor(subs, request.host);
|
|
1001
|
+
const names = /* @__PURE__ */ new Set();
|
|
1002
|
+
const headers = {};
|
|
1003
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
1004
|
+
if (value === void 0) continue;
|
|
1005
|
+
const one = (v) => {
|
|
1006
|
+
let out = v;
|
|
1007
|
+
for (const s of own) {
|
|
1008
|
+
if (!s.in.includes("header") || !out.includes(s.placeholder)) continue;
|
|
1009
|
+
out = replaceIn(out, s.placeholder, s.value);
|
|
1010
|
+
names.add(s.name);
|
|
1011
|
+
}
|
|
1012
|
+
return out;
|
|
1013
|
+
};
|
|
1014
|
+
headers[key] = Array.isArray(value) ? value.map(one) : one(value);
|
|
1015
|
+
}
|
|
1016
|
+
let path = request.path;
|
|
1017
|
+
const q = path.indexOf("?");
|
|
1018
|
+
if (q !== -1) {
|
|
1019
|
+
let query = path.slice(q);
|
|
1020
|
+
for (const s of own) {
|
|
1021
|
+
if (!s.in.includes("query") || !query.includes(s.placeholder)) continue;
|
|
1022
|
+
query = replaceIn(query, s.placeholder, encodeURIComponent(s.value));
|
|
1023
|
+
names.add(s.name);
|
|
1024
|
+
}
|
|
1025
|
+
path = path.slice(0, q) + query;
|
|
1026
|
+
}
|
|
1027
|
+
return {
|
|
1028
|
+
headers,
|
|
1029
|
+
path,
|
|
1030
|
+
names: [...names]
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Where a request to the secrets' host still carries a placeholder the gateway leaves in place,
|
|
1035
|
+
* because the secret doesn't substitute there: the query without `in: ['query']`, or a header
|
|
1036
|
+
* with only `in: ['query']` or `['body']`. Such a request fails upstream; this names why.
|
|
1037
|
+
*/
|
|
1038
|
+
function unsubstituted(subs, request) {
|
|
1039
|
+
const out = [];
|
|
1040
|
+
const q = request.path.indexOf("?");
|
|
1041
|
+
const query = q === -1 ? "" : request.path.slice(q);
|
|
1042
|
+
const headers = Object.values(request.headers).flat().filter(Boolean).join("\n");
|
|
1043
|
+
for (const s of substitutionsFor(subs, request.host)) {
|
|
1044
|
+
if (!s.in.includes("query") && query.includes(s.placeholder)) out.push({
|
|
1045
|
+
name: s.name,
|
|
1046
|
+
where: "query",
|
|
1047
|
+
in: s.in
|
|
1048
|
+
});
|
|
1049
|
+
if (!s.in.includes("header") && headers.includes(s.placeholder)) out.push({
|
|
1050
|
+
name: s.name,
|
|
1051
|
+
where: "header",
|
|
1052
|
+
in: s.in
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
return out;
|
|
1056
|
+
}
|
|
1057
|
+
/** A response header value with every real value on the host put back as its placeholder. */
|
|
1058
|
+
const scrubValue = (subs, value) => subs.reduce((out, s) => replaceIn(out, s.value, s.placeholder), value);
|
|
1059
|
+
/** Response headers with every real value on the host put back as its placeholder. */
|
|
1060
|
+
function scrubHeaders(subs, headers) {
|
|
1061
|
+
if (subs.length === 0) return headers;
|
|
1062
|
+
const one = (v) => scrubValue(subs, v);
|
|
1063
|
+
return Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, v === void 0 ? v : Array.isArray(v) ? v.map(one) : one(String(v))]));
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Replaces byte strings in a stream, including across chunk boundaries: it holds back the
|
|
1067
|
+
* longest `from` less one byte, so a match split between chunks is still found.
|
|
1068
|
+
*/
|
|
1069
|
+
var ReplaceStream = class extends Transform {
|
|
1070
|
+
#pairs;
|
|
1071
|
+
#hold;
|
|
1072
|
+
#tail = Buffer.alloc(0);
|
|
1073
|
+
/** How many replacements were made. */
|
|
1074
|
+
count = 0;
|
|
1075
|
+
constructor(pairs) {
|
|
1076
|
+
super();
|
|
1077
|
+
this.#pairs = pairs.filter(([from]) => from.length > 0).map(([from, to]) => [Buffer.from(from), Buffer.from(to)]);
|
|
1078
|
+
this.#hold = Math.max(0, ...this.#pairs.map(([from]) => from.length - 1));
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Replaces every whole match in `data` (original bytes only: output is never re-scanned), and
|
|
1082
|
+
* returns where the unscanned tail starts, which is `data.length` when `final`.
|
|
1083
|
+
*/
|
|
1084
|
+
#scan(data, final) {
|
|
1085
|
+
const parts = [];
|
|
1086
|
+
let pos = 0;
|
|
1087
|
+
for (;;) {
|
|
1088
|
+
let best = -1;
|
|
1089
|
+
let pair;
|
|
1090
|
+
for (const p of this.#pairs) {
|
|
1091
|
+
const i = data.indexOf(p[0], pos);
|
|
1092
|
+
if (i !== -1 && (best === -1 || i < best)) {
|
|
1093
|
+
best = i;
|
|
1094
|
+
pair = p;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
if (best === -1 || !pair) break;
|
|
1098
|
+
parts.push(data.subarray(pos, best), pair[1]);
|
|
1099
|
+
pos = best + pair[0].length;
|
|
1100
|
+
this.count++;
|
|
1101
|
+
}
|
|
1102
|
+
const rest = final ? data.length : Math.max(pos, data.length - this.#hold);
|
|
1103
|
+
parts.push(data.subarray(pos, rest));
|
|
1104
|
+
return {
|
|
1105
|
+
out: Buffer.concat(parts),
|
|
1106
|
+
rest
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
_transform(chunk, _enc, done) {
|
|
1110
|
+
const data = Buffer.concat([this.#tail, chunk]);
|
|
1111
|
+
const { out, rest } = this.#scan(data, false);
|
|
1112
|
+
this.#tail = Buffer.from(data.subarray(rest));
|
|
1113
|
+
if (out.length) this.push(out);
|
|
1114
|
+
done();
|
|
1115
|
+
}
|
|
1116
|
+
_flush(done) {
|
|
1117
|
+
const { out } = this.#scan(this.#tail, true);
|
|
1118
|
+
if (out.length) this.push(out);
|
|
1119
|
+
done();
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
/** The substitutions whose values are scrubbed from response bodies (see `scrubBodies`). */
|
|
1123
|
+
const scrubbedInBodies = (subs) => subs.filter((s) => s.scrubBodies !== false);
|
|
1124
|
+
/** A stream putting each real value back as its placeholder: for responses from brokered hosts. */
|
|
1125
|
+
const scrubber = (subs) => new ReplaceStream(scrubbedInBodies(subs).map((s) => [s.value, s.placeholder]));
|
|
1126
|
+
/** A stream swapping placeholders for values, for request bodies of secrets that opt in. */
|
|
1127
|
+
const bodySubstituter = (subs) => new ReplaceStream(subs.filter((s) => s.in.includes("body")).map((s) => [s.placeholder, s.value]));
|
|
1128
|
+
//#endregion
|
|
1129
|
+
//#region ../sandbox-docker/src/egress/tokens.ts
|
|
1130
|
+
const JWT = /^[\w-]+\.[\w-]+\.[\w-]+$/;
|
|
1131
|
+
/**
|
|
1132
|
+
* A placeholder for a token. A JWT keeps its header and claims, so a client reading its `exp` sees
|
|
1133
|
+
* the real one, and gets `reeval_secret_…` for a signature; anything else is a plain `reeval_secret_…`.
|
|
1134
|
+
*/
|
|
1135
|
+
function tokenPlaceholder(token) {
|
|
1136
|
+
if (!JWT.test(token)) return placeholder();
|
|
1137
|
+
const [header, claims] = token.split(".");
|
|
1138
|
+
return `${header}.${claims}.${placeholder()}`;
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* The exchange response with each token field replaced by a placeholder, and a substitution per
|
|
1142
|
+
* token. A token already seen keeps its placeholder. Throws when the body isn't a JSON object.
|
|
1143
|
+
*/
|
|
1144
|
+
function tokeniseResponse(derived, body, known) {
|
|
1145
|
+
const parsed = JSON.parse(body);
|
|
1146
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("the response is not a JSON object");
|
|
1147
|
+
const out = { ...parsed };
|
|
1148
|
+
const added = [];
|
|
1149
|
+
const fields = [];
|
|
1150
|
+
for (const field of derived.fields) {
|
|
1151
|
+
const value = out[field];
|
|
1152
|
+
if (typeof value !== "string" || value === "") continue;
|
|
1153
|
+
const existing = [...known, ...added].find((s) => s.value === value);
|
|
1154
|
+
const sub = existing ?? {
|
|
1155
|
+
name: `${derived.name} ${field}`,
|
|
1156
|
+
placeholder: tokenPlaceholder(value),
|
|
1157
|
+
value,
|
|
1158
|
+
hosts: derived.hosts,
|
|
1159
|
+
in: ["header"],
|
|
1160
|
+
scrubBodies: false
|
|
1161
|
+
};
|
|
1162
|
+
if (!existing) added.push(sub);
|
|
1163
|
+
out[field] = sub.placeholder;
|
|
1164
|
+
fields.push(field);
|
|
1165
|
+
}
|
|
1166
|
+
return {
|
|
1167
|
+
body: JSON.stringify(out),
|
|
1168
|
+
added,
|
|
1169
|
+
fields
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
//#endregion
|
|
1173
|
+
//#region ../sandbox-docker/src/egress/proxy.ts
|
|
1174
|
+
const HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
1175
|
+
"connection",
|
|
1176
|
+
"keep-alive",
|
|
1177
|
+
"proxy-connection",
|
|
1178
|
+
"proxy-authorization",
|
|
1179
|
+
"proxy-authenticate",
|
|
1180
|
+
"te",
|
|
1181
|
+
"trailer",
|
|
1182
|
+
"transfer-encoding",
|
|
1183
|
+
"upgrade",
|
|
1184
|
+
"http2-settings",
|
|
1185
|
+
"host"
|
|
1186
|
+
]);
|
|
1187
|
+
const endToEnd = (headers, keep = []) => Object.fromEntries(Object.entries(headers).filter(([k, v]) => v !== void 0 && !k.startsWith(":") && (!HOP_BY_HOP.has(k) || keep.includes(k))));
|
|
1188
|
+
/** `host:port` from a CONNECT target or an authority, IPv6 in brackets. */
|
|
1189
|
+
function parseAuthority(authority, defaultPort) {
|
|
1190
|
+
const m = /^\[([^\]]+)\](?::(\d+))?$/.exec(authority) ?? /^([^:]+)(?::(\d+))?$/.exec(authority);
|
|
1191
|
+
return {
|
|
1192
|
+
host: (m?.[1] ?? authority).toLowerCase(),
|
|
1193
|
+
port: Number(m?.[2] ?? defaultPort)
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
const pathOnly = (path) => path.split("?")[0];
|
|
1197
|
+
function reply(socket, status, reason) {
|
|
1198
|
+
const text = http.STATUS_CODES[status] ?? "";
|
|
1199
|
+
socket.end(`HTTP/1.1 ${status} ${text}\r\ncontent-type: text/plain\r\nconnection: close\r\n\r\n${reason}\n`);
|
|
1200
|
+
}
|
|
1201
|
+
/** Counts the bytes passing through, without changing the flow. */
|
|
1202
|
+
const counter = (count) => new Transform({ transform(chunk, _encoding, done) {
|
|
1203
|
+
count(chunk.length);
|
|
1204
|
+
done(null, chunk);
|
|
1205
|
+
} });
|
|
1206
|
+
const plainHeaders = (headers) => Object.fromEntries(Object.entries(headers).flatMap(([k, v]) => v === void 0 ? [] : [[k, Array.isArray(v) ? v.map(String) : String(v)]]));
|
|
1207
|
+
function createEgressProxy(options) {
|
|
1208
|
+
const log = options.log ?? (() => {});
|
|
1209
|
+
const emit = (event) => {
|
|
1210
|
+
try {
|
|
1211
|
+
options.onEvent?.(event);
|
|
1212
|
+
} catch {}
|
|
1213
|
+
};
|
|
1214
|
+
let rules = [];
|
|
1215
|
+
let subs = [];
|
|
1216
|
+
/** Substitutions for derived tokens, added as their exchanges answer; kept by setSubstitutions. */
|
|
1217
|
+
let tokens = [];
|
|
1218
|
+
const allSubs = () => tokens.length ? [...subs, ...tokens] : subs;
|
|
1219
|
+
const derived = options.derivedTokens ?? [];
|
|
1220
|
+
const tokenHosts = derived.flatMap((d) => [d.endpoint.host, ...d.hosts]);
|
|
1221
|
+
let policyCheck = createPolicyCheck(options.policy, options);
|
|
1222
|
+
const check = async (host) => {
|
|
1223
|
+
const verdict = await policyCheck(host);
|
|
1224
|
+
emit(verdict.allowed ? {
|
|
1225
|
+
type: "dns",
|
|
1226
|
+
host,
|
|
1227
|
+
allowed: true,
|
|
1228
|
+
address: verdict.address
|
|
1229
|
+
} : {
|
|
1230
|
+
type: "dns",
|
|
1231
|
+
host,
|
|
1232
|
+
allowed: false,
|
|
1233
|
+
reason: verdict.reason
|
|
1234
|
+
});
|
|
1235
|
+
return verdict;
|
|
1236
|
+
};
|
|
1237
|
+
/** Bytes each sandbox connection carried, both ways, for its tunnel's event. */
|
|
1238
|
+
const carried = /* @__PURE__ */ new WeakMap();
|
|
1239
|
+
const leaves = /* @__PURE__ */ new Map();
|
|
1240
|
+
const open = /* @__PURE__ */ new Set();
|
|
1241
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
1242
|
+
const agent = new https.Agent({ keepAlive: true });
|
|
1243
|
+
const ca = options.upstreamCA ? [...tls.rootCertificates, options.upstreamCA] : void 0;
|
|
1244
|
+
const track = (s) => {
|
|
1245
|
+
open.add(s);
|
|
1246
|
+
s.once("close", () => open.delete(s));
|
|
1247
|
+
return s;
|
|
1248
|
+
};
|
|
1249
|
+
const contacted = /* @__PURE__ */ new Map();
|
|
1250
|
+
const blocked = /* @__PURE__ */ new Map();
|
|
1251
|
+
/** The host each sandbox connection's bytes count towards: its latest allowed request's. */
|
|
1252
|
+
const counting = /* @__PURE__ */ new WeakMap();
|
|
1253
|
+
/** Bytes a connection sent before the policy decided whose they are: the request itself. */
|
|
1254
|
+
const unclaimed = /* @__PURE__ */ new WeakMap();
|
|
1255
|
+
const contact = (connection, host, port) => {
|
|
1256
|
+
const key = `${host}:${port}`;
|
|
1257
|
+
let entry = contacted.get(key);
|
|
1258
|
+
if (!entry) {
|
|
1259
|
+
entry = {
|
|
1260
|
+
host,
|
|
1261
|
+
port,
|
|
1262
|
+
connections: 0,
|
|
1263
|
+
sent: 0,
|
|
1264
|
+
received: 0
|
|
1265
|
+
};
|
|
1266
|
+
contacted.set(key, entry);
|
|
1267
|
+
}
|
|
1268
|
+
entry.connections++;
|
|
1269
|
+
entry.sent += unclaimed.get(connection) ?? 0;
|
|
1270
|
+
unclaimed.delete(connection);
|
|
1271
|
+
counting.set(connection, entry);
|
|
1272
|
+
};
|
|
1273
|
+
const block = (connection, host, port, reason) => {
|
|
1274
|
+
const key = `${host}:${port}`;
|
|
1275
|
+
const entry = blocked.get(key) ?? {
|
|
1276
|
+
host,
|
|
1277
|
+
port,
|
|
1278
|
+
connections: 0,
|
|
1279
|
+
reason
|
|
1280
|
+
};
|
|
1281
|
+
entry.connections++;
|
|
1282
|
+
blocked.set(key, entry);
|
|
1283
|
+
counting.delete(connection);
|
|
1284
|
+
unclaimed.delete(connection);
|
|
1285
|
+
};
|
|
1286
|
+
/** Counts what the connection carries each way, without reading it or changing its flow. */
|
|
1287
|
+
const countBytes = (connection) => {
|
|
1288
|
+
const total = {
|
|
1289
|
+
sent: 0,
|
|
1290
|
+
received: 0
|
|
1291
|
+
};
|
|
1292
|
+
carried.set(connection, total);
|
|
1293
|
+
const push = connection.push.bind(connection);
|
|
1294
|
+
connection.push = (chunk, encoding) => {
|
|
1295
|
+
const entry = counting.get(connection);
|
|
1296
|
+
const n = chunk ? Buffer.byteLength(chunk) : 0;
|
|
1297
|
+
total.sent += n;
|
|
1298
|
+
if (entry) entry.sent += n;
|
|
1299
|
+
else unclaimed.set(connection, (unclaimed.get(connection) ?? 0) + n);
|
|
1300
|
+
return push(chunk, encoding);
|
|
1301
|
+
};
|
|
1302
|
+
const write = connection.write.bind(connection);
|
|
1303
|
+
connection.write = ((chunk, ...rest) => {
|
|
1304
|
+
const entry = counting.get(connection);
|
|
1305
|
+
const n = chunk ? Buffer.byteLength(chunk) : 0;
|
|
1306
|
+
total.received += n;
|
|
1307
|
+
if (entry) entry.received += n;
|
|
1308
|
+
return write(chunk, ...rest);
|
|
1309
|
+
});
|
|
1310
|
+
};
|
|
1311
|
+
const byHost = (entries) => [...entries].map((e) => ({ ...e })).sort((a, b) => a.host.localeCompare(b.host) || a.port - b.port);
|
|
1312
|
+
/** Collects one request's event as it goes, and emits it when the response closes. */
|
|
1313
|
+
const requestEvent = (res, base) => {
|
|
1314
|
+
const startedAt = Date.now();
|
|
1315
|
+
const event = {
|
|
1316
|
+
type: "request",
|
|
1317
|
+
...base,
|
|
1318
|
+
allowed: true,
|
|
1319
|
+
sent: 0,
|
|
1320
|
+
received: 0,
|
|
1321
|
+
substituted: [],
|
|
1322
|
+
credentials: [],
|
|
1323
|
+
ms: 0
|
|
1324
|
+
};
|
|
1325
|
+
let emitted = false;
|
|
1326
|
+
res.once("close", () => {
|
|
1327
|
+
if (emitted) return;
|
|
1328
|
+
emitted = true;
|
|
1329
|
+
emit({
|
|
1330
|
+
...event,
|
|
1331
|
+
status: event.status ?? res.statusCode,
|
|
1332
|
+
ms: Date.now() - startedAt
|
|
1333
|
+
});
|
|
1334
|
+
});
|
|
1335
|
+
return {
|
|
1336
|
+
refuse(reason) {
|
|
1337
|
+
event.allowed = false;
|
|
1338
|
+
event.reason = reason;
|
|
1339
|
+
},
|
|
1340
|
+
set(fields) {
|
|
1341
|
+
Object.assign(event, fields);
|
|
1342
|
+
},
|
|
1343
|
+
sent: (n) => {
|
|
1344
|
+
event.sent += n;
|
|
1345
|
+
},
|
|
1346
|
+
received: (n) => {
|
|
1347
|
+
event.received += n;
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
};
|
|
1351
|
+
const credentialsFor = (host) => {
|
|
1352
|
+
let creds = leaves.get(host);
|
|
1353
|
+
if (!creds) {
|
|
1354
|
+
const leaf = issueServerCertificate(options.ca, host);
|
|
1355
|
+
creds = {
|
|
1356
|
+
key: leaf.key.export({
|
|
1357
|
+
type: "pkcs8",
|
|
1358
|
+
format: "pem"
|
|
1359
|
+
}),
|
|
1360
|
+
cert: leaf.cert + options.ca.cert
|
|
1361
|
+
};
|
|
1362
|
+
leaves.set(host, creds);
|
|
1363
|
+
}
|
|
1364
|
+
return creds;
|
|
1365
|
+
};
|
|
1366
|
+
const front = http.createServer();
|
|
1367
|
+
front.on("connect", (req, socket, head) => {
|
|
1368
|
+
const { host, port } = parseAuthority(req.url ?? "", 443);
|
|
1369
|
+
const startedAt = Date.now();
|
|
1370
|
+
const tunnel = {
|
|
1371
|
+
type: "request",
|
|
1372
|
+
method: "CONNECT",
|
|
1373
|
+
url: `${host}:${port}`,
|
|
1374
|
+
host,
|
|
1375
|
+
port,
|
|
1376
|
+
via: "tunnel",
|
|
1377
|
+
substituted: [],
|
|
1378
|
+
credentials: [],
|
|
1379
|
+
requestHeaders: plainHeaders(req.headers)
|
|
1380
|
+
};
|
|
1381
|
+
check(host).then((verdict) => {
|
|
1382
|
+
if (!verdict.allowed) {
|
|
1383
|
+
log(`reeval:egress CONNECT ${host}:${port} denied (${verdict.reason})`);
|
|
1384
|
+
block(socket, host, port, verdict.reason);
|
|
1385
|
+
emit({
|
|
1386
|
+
...tunnel,
|
|
1387
|
+
allowed: false,
|
|
1388
|
+
status: verdict.status,
|
|
1389
|
+
reason: verdict.reason,
|
|
1390
|
+
sent: 0,
|
|
1391
|
+
received: 0,
|
|
1392
|
+
ms: Date.now() - startedAt
|
|
1393
|
+
});
|
|
1394
|
+
return reply(socket, verdict.status, `blocked by the reeval egress policy: ${verdict.reason}`);
|
|
1395
|
+
}
|
|
1396
|
+
contact(socket, host, port);
|
|
1397
|
+
const target = {
|
|
1398
|
+
host,
|
|
1399
|
+
port,
|
|
1400
|
+
address: verdict.address
|
|
1401
|
+
};
|
|
1402
|
+
if (decrypts(rules, host) || substitutes(allSubs(), host) || hostAllowed(tokenHosts, host)) {
|
|
1403
|
+
log(`reeval:egress CONNECT ${host}:${port} decrypt`);
|
|
1404
|
+
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
1405
|
+
if (head.length) socket.unshift(head);
|
|
1406
|
+
return decrypt(socket, target);
|
|
1407
|
+
}
|
|
1408
|
+
log(`reeval:egress CONNECT ${host}:${port} tunnel`);
|
|
1409
|
+
let status = 200;
|
|
1410
|
+
let reason;
|
|
1411
|
+
const before = { ...carried.get(socket) ?? {
|
|
1412
|
+
sent: 0,
|
|
1413
|
+
received: 0
|
|
1414
|
+
} };
|
|
1415
|
+
socket.once("close", () => {
|
|
1416
|
+
const after = carried.get(socket) ?? before;
|
|
1417
|
+
emit({
|
|
1418
|
+
...tunnel,
|
|
1419
|
+
allowed: true,
|
|
1420
|
+
status,
|
|
1421
|
+
...reason ? { reason } : {},
|
|
1422
|
+
sent: after.sent - before.sent,
|
|
1423
|
+
received: after.received - before.received,
|
|
1424
|
+
ms: Date.now() - startedAt
|
|
1425
|
+
});
|
|
1426
|
+
});
|
|
1427
|
+
const upstream = track(net.connect({
|
|
1428
|
+
host: target.address,
|
|
1429
|
+
port,
|
|
1430
|
+
allowHalfOpen: true
|
|
1431
|
+
}));
|
|
1432
|
+
upstream.once("connect", () => {
|
|
1433
|
+
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
1434
|
+
if (head.length) upstream.write(head);
|
|
1435
|
+
link(socket, upstream);
|
|
1436
|
+
});
|
|
1437
|
+
upstream.once("error", (error) => {
|
|
1438
|
+
log(`reeval:egress CONNECT ${host}:${port} failed (${error.message})`);
|
|
1439
|
+
status = 502;
|
|
1440
|
+
reason = `couldn't reach ${host}:${port}: ${error.message}`;
|
|
1441
|
+
reply(socket, 502, `reeval egress gateway couldn't reach ${host}:${port}: ${error.message}`);
|
|
1442
|
+
});
|
|
1443
|
+
});
|
|
1444
|
+
});
|
|
1445
|
+
front.on("request", (req, res) => {
|
|
1446
|
+
let url;
|
|
1447
|
+
try {
|
|
1448
|
+
url = new URL(req.url ?? "");
|
|
1449
|
+
} catch {
|
|
1450
|
+
return res.writeHead(400).end("reeval egress gateway: expected an absolute URL\n");
|
|
1451
|
+
}
|
|
1452
|
+
if (url.protocol !== "http:") return res.writeHead(400).end("reeval egress gateway: http only\n");
|
|
1453
|
+
const { host, port } = parseAuthority(url.host, 80);
|
|
1454
|
+
const done = requestEvent(res, {
|
|
1455
|
+
method: req.method ?? "GET",
|
|
1456
|
+
url: `http://${host}:${port}${url.pathname}`,
|
|
1457
|
+
host,
|
|
1458
|
+
port,
|
|
1459
|
+
via: "http",
|
|
1460
|
+
requestHeaders: plainHeaders(req.headers)
|
|
1461
|
+
});
|
|
1462
|
+
check(host).then((verdict) => {
|
|
1463
|
+
if (!verdict.allowed) {
|
|
1464
|
+
log(`reeval:egress ${req.method} http://${host}:${port} denied (${verdict.reason})`);
|
|
1465
|
+
block(req.socket, host, port, verdict.reason);
|
|
1466
|
+
done.refuse(verdict.reason);
|
|
1467
|
+
return res.writeHead(verdict.status).end(`blocked by the reeval egress policy: ${verdict.reason}\n`);
|
|
1468
|
+
}
|
|
1469
|
+
log(`reeval:egress ${req.method} http://${host}:${port}${url.pathname}`);
|
|
1470
|
+
contact(req.socket, host, port);
|
|
1471
|
+
const upstream = http.request({
|
|
1472
|
+
host: verdict.address,
|
|
1473
|
+
port,
|
|
1474
|
+
method: req.method,
|
|
1475
|
+
path: url.pathname + url.search,
|
|
1476
|
+
headers: {
|
|
1477
|
+
...endToEnd(req.headers),
|
|
1478
|
+
host: url.host
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
forwardHttp1(req.pipe(counter(done.sent)), res, upstream, [], void 0, done);
|
|
1482
|
+
});
|
|
1483
|
+
});
|
|
1484
|
+
front.on("upgrade", (_req, socket) => {
|
|
1485
|
+
reply(socket, 501, "reeval egress gateway: use CONNECT for websockets");
|
|
1486
|
+
});
|
|
1487
|
+
/**
|
|
1488
|
+
* Terminates TLS with a certificate for the CONNECT host. One server per connection, since the
|
|
1489
|
+
* host comes from CONNECT; it serves HTTP/2 and HTTP/1.1 (with upgrades) by ALPN.
|
|
1490
|
+
*/
|
|
1491
|
+
function decrypt(socket, target) {
|
|
1492
|
+
const server = http2.createSecureServer({
|
|
1493
|
+
...credentialsFor(target.host),
|
|
1494
|
+
allowHTTP1: true
|
|
1495
|
+
}, (req, res) => void onDecrypted(target, req, res));
|
|
1496
|
+
server.on("upgrade", (req, s, head) => onUpgrade(target, req, track(s), head));
|
|
1497
|
+
server.on("tlsClientError", () => socket.destroy());
|
|
1498
|
+
server.on("sessionError", () => socket.destroy());
|
|
1499
|
+
server.emit("connection", socket);
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* The headers and path to send upstream, the substitutions that apply there, or why the request
|
|
1503
|
+
* is refused (already logged): a Host that isn't the CONNECT target, or an unbrokered endpoint.
|
|
1504
|
+
*/
|
|
1505
|
+
function transformed(target, method, path, headers, version) {
|
|
1506
|
+
const named = String(headers[":authority"] ?? headers.host ?? "");
|
|
1507
|
+
const asked = parseAuthority(named, target.port).host;
|
|
1508
|
+
if (named && asked !== target.host) {
|
|
1509
|
+
const reason = `reeval egress gateway: the request names ${asked}, but the connection is to ${target.host}; send it on a connection to ${asked}.`;
|
|
1510
|
+
log(`reeval:egress ${method} https://${target.host}${pathOnly(path)} refused (Host ${asked})`);
|
|
1511
|
+
return {
|
|
1512
|
+
headers: {},
|
|
1513
|
+
path,
|
|
1514
|
+
own: [],
|
|
1515
|
+
names: [],
|
|
1516
|
+
credentials: [],
|
|
1517
|
+
refused: {
|
|
1518
|
+
status: 421,
|
|
1519
|
+
reason
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
const { headers: out, replaced, refused } = applyTransformations(rules, {
|
|
1524
|
+
host: target.host,
|
|
1525
|
+
method,
|
|
1526
|
+
path,
|
|
1527
|
+
headers: endToEnd(headers, ["upgrade", "connection"])
|
|
1528
|
+
}, options.endpoints);
|
|
1529
|
+
const url = `https://${target.host}${pathOnly(path)}`;
|
|
1530
|
+
if (refused) {
|
|
1531
|
+
log(`reeval:egress ${method} ${url} HTTP/${version} refused (not a brokered endpoint)`);
|
|
1532
|
+
options.onRefused?.({
|
|
1533
|
+
method,
|
|
1534
|
+
url
|
|
1535
|
+
});
|
|
1536
|
+
return {
|
|
1537
|
+
headers: {},
|
|
1538
|
+
path,
|
|
1539
|
+
own: [],
|
|
1540
|
+
names: [],
|
|
1541
|
+
credentials: [],
|
|
1542
|
+
refused: {
|
|
1543
|
+
status: 403,
|
|
1544
|
+
reason: `reeval egress gateway: refused ${method} ${url}. It carries a brokered credential, and it isn't one of the endpoints reeval brokers it for.`
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
const own = substitutionsFor(allSubs(), target.host);
|
|
1549
|
+
const request = {
|
|
1550
|
+
host: target.host,
|
|
1551
|
+
path,
|
|
1552
|
+
headers: out
|
|
1553
|
+
};
|
|
1554
|
+
const substituted = substituteRequest(own, request);
|
|
1555
|
+
const kept = unsubstituted(own, request);
|
|
1556
|
+
for (const k of kept) options.onUnsubstituted?.({
|
|
1557
|
+
...k,
|
|
1558
|
+
method,
|
|
1559
|
+
url
|
|
1560
|
+
});
|
|
1561
|
+
const exchange = derived.find((d) => onEndpoint([d.endpoint], {
|
|
1562
|
+
host: target.host,
|
|
1563
|
+
method,
|
|
1564
|
+
path,
|
|
1565
|
+
headers: {}
|
|
1566
|
+
}));
|
|
1567
|
+
const readsModel = onEndpoint(options.modelResponses ?? [], {
|
|
1568
|
+
host: target.host,
|
|
1569
|
+
method,
|
|
1570
|
+
path,
|
|
1571
|
+
headers: {}
|
|
1572
|
+
});
|
|
1573
|
+
if (scrubbedInBodies(own).length || exchange || readsModel) delete substituted.headers["accept-encoding"];
|
|
1574
|
+
const notes = [
|
|
1575
|
+
replaced.length ? `set ${replaced.join(", ")}` : "",
|
|
1576
|
+
substituted.names.length ? `substituted ${substituted.names.join(", ")}` : "",
|
|
1577
|
+
...kept.map((k) => `kept ${k.name}'s placeholder in the ${k.where}`)
|
|
1578
|
+
].filter(Boolean);
|
|
1579
|
+
log(`reeval:egress ${method} ${url} HTTP/${version}${notes.length ? ` (${notes.join("; ")})` : ""}`);
|
|
1580
|
+
return {
|
|
1581
|
+
headers: substituted.headers,
|
|
1582
|
+
path: substituted.path,
|
|
1583
|
+
own,
|
|
1584
|
+
names: substituted.names,
|
|
1585
|
+
credentials: replaced,
|
|
1586
|
+
...exchange ? { exchange } : {},
|
|
1587
|
+
...readsModel ? { readsModel } : {}
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* The request body as sent upstream: through the substituter when a secret there opts in to
|
|
1592
|
+
* bodies, unless the body is encoded (gzip and the like), which is sent as is and fails closed.
|
|
1593
|
+
*/
|
|
1594
|
+
function requestBody(req, headers, own, t) {
|
|
1595
|
+
const counted = t ? req.pipe(counter(t.sent)) : req;
|
|
1596
|
+
if (String(headers["content-encoding"] ?? "identity") !== "identity" || !own.some((s) => s.in.includes("body"))) return counted;
|
|
1597
|
+
delete headers["content-length"];
|
|
1598
|
+
return counted.pipe(bodySubstituter(own));
|
|
1599
|
+
}
|
|
1600
|
+
/** The response body as sent to the sandbox: real values put back as placeholders. */
|
|
1601
|
+
const scrubs = options.scrubResponses !== false;
|
|
1602
|
+
function responseBody(up, own, t, readsModel = false) {
|
|
1603
|
+
if (readsModel && t) readModel(up, t);
|
|
1604
|
+
const body = scrubbedInBodies(own).length && scrubs ? up.pipe(scrubber(own)) : up;
|
|
1605
|
+
return t ? body.pipe(counter(t.received)) : body;
|
|
1606
|
+
}
|
|
1607
|
+
const responseHeaders = (headers, own) => {
|
|
1608
|
+
const out = endToEnd(headers);
|
|
1609
|
+
if (!own.length || !scrubs) return out;
|
|
1610
|
+
if (scrubbedInBodies(own).length) delete out["content-length"];
|
|
1611
|
+
return scrubHeaders(own, out);
|
|
1612
|
+
};
|
|
1613
|
+
/**
|
|
1614
|
+
* How far into a response the gateway looks for the model that served it: OpenAI's first event
|
|
1615
|
+
* echoes the request's instructions and tools before it.
|
|
1616
|
+
*/
|
|
1617
|
+
const MODEL_WINDOW = 524288;
|
|
1618
|
+
/**
|
|
1619
|
+
* Records the first `"model"` value in the response body, and the `"effort"` of the
|
|
1620
|
+
* `"reasoning"` object after it, reading alongside its flow. Inside a JSON string the quotes are
|
|
1621
|
+
* escaped, so text that merely mentions them doesn't match.
|
|
1622
|
+
*/
|
|
1623
|
+
function readModel(up, t) {
|
|
1624
|
+
let seen = "";
|
|
1625
|
+
const onData = (chunk) => {
|
|
1626
|
+
seen += chunk.toString("utf8");
|
|
1627
|
+
const model = /"model"\s*:\s*"([^"\\]{1,200})"/.exec(seen);
|
|
1628
|
+
const effort = model ? /"reasoning"\s*:\s*\{[^{}]*?"effort"\s*:\s*"(\w{1,40})"/.exec(seen.slice(model.index)) : null;
|
|
1629
|
+
if (model) t.set({ responseModel: model[1] });
|
|
1630
|
+
if (effort) t.set({ responseEffort: effort[1] });
|
|
1631
|
+
if (effort || seen.length > MODEL_WINDOW) up.off("data", onData);
|
|
1632
|
+
};
|
|
1633
|
+
up.on("data", onData);
|
|
1634
|
+
}
|
|
1635
|
+
/** Most an exchange's response may be; it's a few hundred bytes of JSON. */
|
|
1636
|
+
const EXCHANGE_LIMIT = 1 << 20;
|
|
1637
|
+
/**
|
|
1638
|
+
* A token exchange's response, read whole, with each token replaced by a placeholder. A success
|
|
1639
|
+
* that can't be read (compressed, too big, not JSON) is a 502: the real tokens never go through.
|
|
1640
|
+
*/
|
|
1641
|
+
function tokenised(exchange, url, status, headers, up, res, own, t) {
|
|
1642
|
+
const chunks = [];
|
|
1643
|
+
let size = 0;
|
|
1644
|
+
const refuse = (why) => {
|
|
1645
|
+
log(`reeval:egress ${url} response refused (${why})`);
|
|
1646
|
+
if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain" }).end(`reeval egress gateway: couldn't keep ${exchange.name}'s tokens out of the sandbox: ${why}
|
|
1647
|
+
`);
|
|
1648
|
+
else res.destroy();
|
|
1649
|
+
};
|
|
1650
|
+
const scrubbed = responseBody(up, own, t);
|
|
1651
|
+
scrubbed.on("data", (chunk) => {
|
|
1652
|
+
size += chunk.length;
|
|
1653
|
+
if (size <= EXCHANGE_LIMIT) chunks.push(chunk);
|
|
1654
|
+
});
|
|
1655
|
+
up.once("error", (error) => refuse(error.message));
|
|
1656
|
+
scrubbed.once("end", () => {
|
|
1657
|
+
const body = Buffer.concat(chunks);
|
|
1658
|
+
const out = responseHeaders(headers, own);
|
|
1659
|
+
t?.set({ responseHeaders: plainHeaders(out) });
|
|
1660
|
+
if (status < 200 || status >= 300) return res.writeHead(status, out).end(body);
|
|
1661
|
+
const encoding = String(headers["content-encoding"] ?? "identity");
|
|
1662
|
+
if (encoding !== "identity") return refuse(`the response is ${encoding}-encoded`);
|
|
1663
|
+
if (size > EXCHANGE_LIMIT) return refuse(`the response is over ${EXCHANGE_LIMIT} bytes`);
|
|
1664
|
+
let result;
|
|
1665
|
+
try {
|
|
1666
|
+
result = tokeniseResponse(exchange, body.toString("utf8"), tokens);
|
|
1667
|
+
} catch (error) {
|
|
1668
|
+
return refuse(error.message);
|
|
1669
|
+
}
|
|
1670
|
+
tokens = [...tokens, ...result.added];
|
|
1671
|
+
log(`reeval:egress ${url} tokenised ${result.fields.join(", ") || "nothing"} (${exchange.name})`);
|
|
1672
|
+
const text = Buffer.from(result.body);
|
|
1673
|
+
delete out["content-encoding"];
|
|
1674
|
+
res.writeHead(status, {
|
|
1675
|
+
...out,
|
|
1676
|
+
"content-length": String(text.length)
|
|
1677
|
+
}).end(text);
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
async function onDecrypted(target, req, res) {
|
|
1681
|
+
const method = req.method ?? "GET";
|
|
1682
|
+
const url = `https://${target.host}${pathOnly(req.url ?? "/")}`;
|
|
1683
|
+
const t = requestEvent(res, {
|
|
1684
|
+
method,
|
|
1685
|
+
url,
|
|
1686
|
+
host: target.host,
|
|
1687
|
+
port: target.port,
|
|
1688
|
+
via: "decrypted",
|
|
1689
|
+
requestHeaders: plainHeaders(req.headers)
|
|
1690
|
+
});
|
|
1691
|
+
const { headers, path, own, names, credentials, refused, exchange, readsModel } = transformed(target, method, req.url ?? "/", req.headers, req.httpVersion);
|
|
1692
|
+
t.set({
|
|
1693
|
+
substituted: names,
|
|
1694
|
+
credentials
|
|
1695
|
+
});
|
|
1696
|
+
if (refused) {
|
|
1697
|
+
t.refuse(refused.reason);
|
|
1698
|
+
req.resume();
|
|
1699
|
+
return res.writeHead(refused.status, { "content-type": "text/plain" }).end(`${refused.reason}\n`);
|
|
1700
|
+
}
|
|
1701
|
+
delete headers.upgrade;
|
|
1702
|
+
delete headers.connection;
|
|
1703
|
+
const body = requestBody(req, headers, own, t);
|
|
1704
|
+
const authority = target.port === 443 ? target.host : `${target.host}:${target.port}`;
|
|
1705
|
+
const session = req.httpVersionMajor === 2 ? await upstreamSession(target) : null;
|
|
1706
|
+
if (!session) return forwardHttp1(body, res, https.request({
|
|
1707
|
+
host: target.address,
|
|
1708
|
+
servername: net.isIP(target.host) ? void 0 : target.host,
|
|
1709
|
+
port: target.port,
|
|
1710
|
+
method,
|
|
1711
|
+
path,
|
|
1712
|
+
headers: {
|
|
1713
|
+
...headers,
|
|
1714
|
+
host: authority
|
|
1715
|
+
},
|
|
1716
|
+
agent,
|
|
1717
|
+
ca
|
|
1718
|
+
}), own, exchange && {
|
|
1719
|
+
exchange,
|
|
1720
|
+
url
|
|
1721
|
+
}, t, readsModel);
|
|
1722
|
+
const stream = session.request({
|
|
1723
|
+
...headers,
|
|
1724
|
+
":method": method,
|
|
1725
|
+
":path": path,
|
|
1726
|
+
":scheme": "https",
|
|
1727
|
+
":authority": authority
|
|
1728
|
+
});
|
|
1729
|
+
const fail = (error) => {
|
|
1730
|
+
if (!res.headersSent) res.writeHead(502).end(`reeval egress gateway: ${error.message}\n`);
|
|
1731
|
+
else res.destroy();
|
|
1732
|
+
};
|
|
1733
|
+
stream.once("error", fail);
|
|
1734
|
+
stream.once("response", (h) => {
|
|
1735
|
+
if (exchange) return tokenised(exchange, url, Number(h[":status"]), h, stream, res, own, t);
|
|
1736
|
+
const out = responseHeaders(h, own);
|
|
1737
|
+
t.set({ responseHeaders: plainHeaders(out) });
|
|
1738
|
+
res.writeHead(Number(h[":status"]), out);
|
|
1739
|
+
stream.once("trailers", (trailers) => res.addTrailers(responseHeaders(trailers, own)));
|
|
1740
|
+
responseBody(stream, own, t, readsModel).pipe(res);
|
|
1741
|
+
});
|
|
1742
|
+
body.pipe(stream);
|
|
1743
|
+
req.once("aborted", () => stream.close(http2.constants.NGHTTP2_CANCEL));
|
|
1744
|
+
}
|
|
1745
|
+
function forwardHttp1(req, res, upstream, own = [], tokenise, t, readsModel = false) {
|
|
1746
|
+
upstream.once("response", (up) => {
|
|
1747
|
+
if (tokenise) {
|
|
1748
|
+
const { exchange, url } = tokenise;
|
|
1749
|
+
return tokenised(exchange, url, up.statusCode ?? 502, up.headers, up, res, own, t);
|
|
1750
|
+
}
|
|
1751
|
+
const out = responseHeaders(up.headers, own);
|
|
1752
|
+
t?.set({ responseHeaders: plainHeaders(out) });
|
|
1753
|
+
res.writeHead(up.statusCode ?? 502, out);
|
|
1754
|
+
up.once("end", () => {
|
|
1755
|
+
if (Object.keys(up.trailers).length) res.addTrailers(responseHeaders(up.trailers, own));
|
|
1756
|
+
});
|
|
1757
|
+
responseBody(up, own, t, readsModel).pipe(res);
|
|
1758
|
+
});
|
|
1759
|
+
upstream.once("error", (error) => {
|
|
1760
|
+
t?.set({
|
|
1761
|
+
status: 502,
|
|
1762
|
+
reason: error.message
|
|
1763
|
+
});
|
|
1764
|
+
if (!res.headersSent) res.writeHead(502).end(`reeval egress gateway: ${error.message}\n`);
|
|
1765
|
+
else res.destroy();
|
|
1766
|
+
});
|
|
1767
|
+
req.pipe(upstream);
|
|
1768
|
+
}
|
|
1769
|
+
function onUpgrade(target, req, socket, head) {
|
|
1770
|
+
const method = req.method ?? "GET";
|
|
1771
|
+
const startedAt = Date.now();
|
|
1772
|
+
const { headers, path, own, names, credentials, refused } = transformed(target, method, req.url ?? "/", req.headers, req.httpVersion);
|
|
1773
|
+
const scrubbed = (value) => scrubs ? scrubValue(own, value) : value;
|
|
1774
|
+
const upgraded = (fields) => emit({
|
|
1775
|
+
type: "request",
|
|
1776
|
+
method,
|
|
1777
|
+
url: `https://${target.host}${pathOnly(req.url ?? "/")}`,
|
|
1778
|
+
host: target.host,
|
|
1779
|
+
port: target.port,
|
|
1780
|
+
via: "decrypted",
|
|
1781
|
+
allowed: true,
|
|
1782
|
+
sent: 0,
|
|
1783
|
+
received: 0,
|
|
1784
|
+
substituted: names,
|
|
1785
|
+
credentials,
|
|
1786
|
+
requestHeaders: plainHeaders(req.headers),
|
|
1787
|
+
...fields,
|
|
1788
|
+
ms: Date.now() - startedAt
|
|
1789
|
+
});
|
|
1790
|
+
if (refused) {
|
|
1791
|
+
upgraded({
|
|
1792
|
+
allowed: false,
|
|
1793
|
+
status: refused.status,
|
|
1794
|
+
reason: refused.reason
|
|
1795
|
+
});
|
|
1796
|
+
return reply(socket, refused.status, refused.reason);
|
|
1797
|
+
}
|
|
1798
|
+
const upstream = https.request({
|
|
1799
|
+
host: target.address,
|
|
1800
|
+
servername: net.isIP(target.host) ? void 0 : target.host,
|
|
1801
|
+
port: target.port,
|
|
1802
|
+
method,
|
|
1803
|
+
path,
|
|
1804
|
+
headers: {
|
|
1805
|
+
...headers,
|
|
1806
|
+
host: target.host
|
|
1807
|
+
},
|
|
1808
|
+
ca
|
|
1809
|
+
});
|
|
1810
|
+
upstream.once("upgrade", (up, upSocket, upHead) => {
|
|
1811
|
+
const out = scrubs ? scrubHeaders(own, up.headers) : up.headers;
|
|
1812
|
+
upgraded({
|
|
1813
|
+
status: up.statusCode,
|
|
1814
|
+
responseHeaders: plainHeaders(out)
|
|
1815
|
+
});
|
|
1816
|
+
const lines = [`HTTP/1.1 ${up.statusCode} ${up.statusMessage}`];
|
|
1817
|
+
for (let i = 0; i < up.rawHeaders.length; i += 2) lines.push(`${up.rawHeaders[i]}: ${scrubbed(up.rawHeaders[i + 1] ?? "")}`);
|
|
1818
|
+
socket.write(`${lines.join("\r\n")}\r\n\r\n`);
|
|
1819
|
+
if (upHead.length) socket.write(upHead);
|
|
1820
|
+
if (head.length) upSocket.write(head);
|
|
1821
|
+
link(socket, track(upSocket));
|
|
1822
|
+
});
|
|
1823
|
+
upstream.once("response", (up) => {
|
|
1824
|
+
reply(socket, up.statusCode ?? 502, `upstream refused the upgrade (${up.statusCode})`);
|
|
1825
|
+
up.resume();
|
|
1826
|
+
});
|
|
1827
|
+
upstream.once("error", (error) => reply(socket, 502, `reeval egress gateway: ${error.message}`));
|
|
1828
|
+
upstream.end();
|
|
1829
|
+
}
|
|
1830
|
+
/** One HTTP/2 session per upstream, or null when it doesn't speak h2. */
|
|
1831
|
+
function upstreamSession(target) {
|
|
1832
|
+
const key = `${target.host}:${target.port}`;
|
|
1833
|
+
const existing = sessions.get(key);
|
|
1834
|
+
if (existing && !existing.closed && !existing.destroyed) return Promise.resolve(existing);
|
|
1835
|
+
return new Promise((resolve) => {
|
|
1836
|
+
const socket = tls.connect({
|
|
1837
|
+
host: target.address,
|
|
1838
|
+
port: target.port,
|
|
1839
|
+
servername: net.isIP(target.host) ? void 0 : target.host,
|
|
1840
|
+
ALPNProtocols: ["h2", "http/1.1"],
|
|
1841
|
+
...ca ? { ca } : {}
|
|
1842
|
+
});
|
|
1843
|
+
socket.once("error", () => resolve(null));
|
|
1844
|
+
socket.once("secureConnect", () => {
|
|
1845
|
+
if (socket.alpnProtocol !== "h2") {
|
|
1846
|
+
socket.destroy();
|
|
1847
|
+
return resolve(null);
|
|
1848
|
+
}
|
|
1849
|
+
const session = http2.connect(`https://${key}`, { createConnection: () => socket });
|
|
1850
|
+
session.on("error", () => {});
|
|
1851
|
+
session.once("close", () => sessions.get(key) === session && sessions.delete(key));
|
|
1852
|
+
session.once("goaway", () => sessions.get(key) === session && sessions.delete(key));
|
|
1853
|
+
sessions.set(key, session);
|
|
1854
|
+
resolve(session);
|
|
1855
|
+
});
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
return {
|
|
1859
|
+
handle(connection) {
|
|
1860
|
+
track(connection);
|
|
1861
|
+
countBytes(connection);
|
|
1862
|
+
connection.on("error", () => connection.destroy());
|
|
1863
|
+
front.emit("connection", connection);
|
|
1864
|
+
},
|
|
1865
|
+
traffic: () => ({
|
|
1866
|
+
contacted: byHost(contacted.values()),
|
|
1867
|
+
blocked: byHost(blocked.values())
|
|
1868
|
+
}),
|
|
1869
|
+
addTransformations(more) {
|
|
1870
|
+
rules = [...rules, ...more];
|
|
1871
|
+
},
|
|
1872
|
+
setTransformations(all) {
|
|
1873
|
+
rules = [...all];
|
|
1874
|
+
},
|
|
1875
|
+
setPolicy(policy) {
|
|
1876
|
+
policyCheck = createPolicyCheck(policy, options);
|
|
1877
|
+
},
|
|
1878
|
+
setSubstitutions(all) {
|
|
1879
|
+
subs = [...all];
|
|
1880
|
+
},
|
|
1881
|
+
close() {
|
|
1882
|
+
for (const s of open) s.destroy();
|
|
1883
|
+
for (const s of sessions.values()) s.destroy();
|
|
1884
|
+
sessions.clear();
|
|
1885
|
+
agent.destroy();
|
|
1886
|
+
}
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
//#endregion
|
|
1890
|
+
//#region ../sandbox-docker/src/egress/gateway.ts
|
|
1891
|
+
/** Where the forwarder listens inside the container; every exec gets it as its HTTP(S) proxy. */
|
|
1892
|
+
const PROXY_PORT = 43128;
|
|
1893
|
+
/** The gateway's CA certificate in the container. The private key never leaves the host. */
|
|
1894
|
+
const CA_PATH = "/usr/local/share/ca-certificates/reeval-egress.crt";
|
|
1895
|
+
/** Debian's system bundle, which `update-ca-certificates` rebuilds with the gateway's CA in it. */
|
|
1896
|
+
const CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
|
|
1897
|
+
const BUNDLED = "es:ca-bundle";
|
|
1898
|
+
const READY_TIMEOUT_MS = 15e3;
|
|
1899
|
+
/**
|
|
1900
|
+
* The gateway's side of the container runs as root, whoever the sandbox runs as: the sandbox user
|
|
1901
|
+
* can't signal, trace or replace the forwarder, or change the CA it installed.
|
|
1902
|
+
*/
|
|
1903
|
+
const AS_ROOT = ["--user", "0:0"];
|
|
1904
|
+
const installCa = (containerId, cert, signal) => docker([
|
|
1905
|
+
"exec",
|
|
1906
|
+
"--interactive",
|
|
1907
|
+
...AS_ROOT,
|
|
1908
|
+
containerId,
|
|
1909
|
+
"sh",
|
|
1910
|
+
"-c",
|
|
1911
|
+
`umask 022 && mkdir -p "$(dirname ${CA_PATH})" && cat > ${CA_PATH} && { command -v update-ca-certificates >/dev/null && update-ca-certificates >/dev/null 2>&1 && grep -q "$(sed -n 2p ${CA_PATH})" ${CA_BUNDLE} && echo ${BUNDLED}; true; }`
|
|
1912
|
+
], {
|
|
1913
|
+
input: cert,
|
|
1914
|
+
signal
|
|
1915
|
+
});
|
|
1916
|
+
/**
|
|
1917
|
+
* Starts the egress gateway for one container: a forwarder inside it, tunnelled over one
|
|
1918
|
+
* `docker exec -i`, and the proxy in this process, which holds the real credentials.
|
|
1919
|
+
*/
|
|
1920
|
+
async function startGateway(containerId, options, signal) {
|
|
1921
|
+
const ca = createCertificateAuthority();
|
|
1922
|
+
const installed = await installCa(containerId, ca.cert, signal);
|
|
1923
|
+
if (installed.exitCode !== 0) throw new Error(`couldn't install the gateway CA: ${installed.stderr.trim()}`);
|
|
1924
|
+
const bundled = installed.stdout.toString("utf8").includes(BUNDLED);
|
|
1925
|
+
const proxy = createEgressProxy({
|
|
1926
|
+
...options,
|
|
1927
|
+
ca
|
|
1928
|
+
});
|
|
1929
|
+
const args = [
|
|
1930
|
+
"exec",
|
|
1931
|
+
"--interactive",
|
|
1932
|
+
...AS_ROOT,
|
|
1933
|
+
containerId,
|
|
1934
|
+
"node",
|
|
1935
|
+
"-e",
|
|
1936
|
+
forwarderScript(PROXY_PORT)
|
|
1937
|
+
];
|
|
1938
|
+
const startedAt = Date.now();
|
|
1939
|
+
const child = spawn("docker", args, {
|
|
1940
|
+
stdio: "pipe",
|
|
1941
|
+
detached: true
|
|
1942
|
+
});
|
|
1943
|
+
child.once("close", (code) => traceDocker(args, startedAt, {
|
|
1944
|
+
exitCode: code,
|
|
1945
|
+
stderr
|
|
1946
|
+
}));
|
|
1947
|
+
let stderr = "";
|
|
1948
|
+
child.stderr.on("data", (d) => {
|
|
1949
|
+
stderr += d;
|
|
1950
|
+
});
|
|
1951
|
+
child.stdin.on("error", () => {});
|
|
1952
|
+
let closed = false;
|
|
1953
|
+
const mux = createMux({
|
|
1954
|
+
Duplex,
|
|
1955
|
+
parity: 0,
|
|
1956
|
+
send: (frame) => {
|
|
1957
|
+
if (!closed) child.stdin.write(frame);
|
|
1958
|
+
},
|
|
1959
|
+
onOpen(stream, port) {
|
|
1960
|
+
if (port === 43128) proxy.handle(stream);
|
|
1961
|
+
else stream.destroy();
|
|
1962
|
+
},
|
|
1963
|
+
onReady: () => ready.resolve()
|
|
1964
|
+
});
|
|
1965
|
+
const ready = {};
|
|
1966
|
+
ready.promise = new Promise((resolve, reject) => Object.assign(ready, {
|
|
1967
|
+
resolve,
|
|
1968
|
+
reject
|
|
1969
|
+
}));
|
|
1970
|
+
child.stdout.on("data", (chunk) => mux.receive(chunk));
|
|
1971
|
+
child.once("error", (error) => ready.reject(error));
|
|
1972
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
1973
|
+
const tunnels = /* @__PURE__ */ new Set();
|
|
1974
|
+
let stopped;
|
|
1975
|
+
child.once("close", (code) => {
|
|
1976
|
+
const asked = closed;
|
|
1977
|
+
closed = true;
|
|
1978
|
+
mux.destroy();
|
|
1979
|
+
const reason = `the forwarder exited (${code}): ${stderr.trim().slice(-500)}`.replace(/: $/, "");
|
|
1980
|
+
ready.reject(new Error(reason));
|
|
1981
|
+
if (asked) return;
|
|
1982
|
+
stopped = reason;
|
|
1983
|
+
for (const socket of tunnels) socket.destroy();
|
|
1984
|
+
for (const l of listeners.values()) l.then((s) => s.close());
|
|
1985
|
+
});
|
|
1986
|
+
const timer = setTimeout(() => ready.reject(/* @__PURE__ */ new Error(`the forwarder didn't start within ${READY_TIMEOUT_MS / 1e3}s`)), READY_TIMEOUT_MS);
|
|
1987
|
+
const onAbort = () => ready.reject(signal?.reason);
|
|
1988
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1989
|
+
const close = () => {
|
|
1990
|
+
closed = true;
|
|
1991
|
+
child.kill("SIGKILL");
|
|
1992
|
+
mux.destroy();
|
|
1993
|
+
proxy.close();
|
|
1994
|
+
for (const l of listeners.values()) l.then((s) => s.close());
|
|
1995
|
+
};
|
|
1996
|
+
try {
|
|
1997
|
+
await ready.promise;
|
|
1998
|
+
} catch (error) {
|
|
1999
|
+
close();
|
|
2000
|
+
throw error;
|
|
2001
|
+
} finally {
|
|
2002
|
+
clearTimeout(timer);
|
|
2003
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2004
|
+
}
|
|
2005
|
+
const proxyUrl = `http://127.0.0.1:${PROXY_PORT}`;
|
|
2006
|
+
const noProxy = "localhost,127.0.0.1,::1";
|
|
2007
|
+
return {
|
|
2008
|
+
env: {
|
|
2009
|
+
HTTPS_PROXY: proxyUrl,
|
|
2010
|
+
HTTP_PROXY: proxyUrl,
|
|
2011
|
+
https_proxy: proxyUrl,
|
|
2012
|
+
http_proxy: proxyUrl,
|
|
2013
|
+
NO_PROXY: noProxy,
|
|
2014
|
+
no_proxy: noProxy,
|
|
2015
|
+
NODE_EXTRA_CA_CERTS: CA_PATH,
|
|
2016
|
+
NODE_USE_ENV_PROXY: "1",
|
|
2017
|
+
...bundled ? {
|
|
2018
|
+
SSL_CERT_FILE: CA_BUNDLE,
|
|
2019
|
+
REQUESTS_CA_BUNDLE: CA_BUNDLE,
|
|
2020
|
+
CURL_CA_BUNDLE: CA_BUNDLE,
|
|
2021
|
+
GIT_SSL_CAINFO: CA_BUNDLE
|
|
2022
|
+
} : {}
|
|
2023
|
+
},
|
|
2024
|
+
addTransformations: proxy.addTransformations,
|
|
2025
|
+
setTransformations: proxy.setTransformations,
|
|
2026
|
+
setPolicy: proxy.setPolicy,
|
|
2027
|
+
setSubstitutions: proxy.setSubstitutions,
|
|
2028
|
+
traffic: proxy.traffic,
|
|
2029
|
+
get stopped() {
|
|
2030
|
+
return stopped;
|
|
2031
|
+
},
|
|
2032
|
+
async portUrl(port, protocol) {
|
|
2033
|
+
if (stopped) throw new Error(`the egress gateway stopped: ${stopped}`);
|
|
2034
|
+
let listening = listeners.get(port);
|
|
2035
|
+
if (!listening) {
|
|
2036
|
+
listening = new Promise((resolve, reject) => {
|
|
2037
|
+
const server = net.createServer({ allowHalfOpen: true }, (socket) => {
|
|
2038
|
+
if (stopped) return socket.destroy();
|
|
2039
|
+
tunnels.add(socket);
|
|
2040
|
+
socket.once("close", () => tunnels.delete(socket));
|
|
2041
|
+
link(socket, mux.open(port));
|
|
2042
|
+
});
|
|
2043
|
+
server.once("error", reject);
|
|
2044
|
+
server.listen(0, "127.0.0.1", () => resolve(server));
|
|
2045
|
+
});
|
|
2046
|
+
listeners.set(port, listening);
|
|
2047
|
+
}
|
|
2048
|
+
return `${protocol}://127.0.0.1:${(await listening).address().port}`;
|
|
2049
|
+
},
|
|
2050
|
+
close
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
//#endregion
|
|
2054
|
+
//#region ../sandbox-docker/src/session.ts
|
|
2055
|
+
const DEFAULT_LIMITS = {
|
|
2056
|
+
memory: "4g",
|
|
2057
|
+
cpus: 2,
|
|
2058
|
+
pids: 1024
|
|
2059
|
+
};
|
|
2060
|
+
/** What `root` adds back: what `apt-get` (and its `_apt` sandbox) and `dpkg` need as root. */
|
|
2061
|
+
const ROOT_CAPABILITIES = [
|
|
2062
|
+
"CHOWN",
|
|
2063
|
+
"DAC_OVERRIDE",
|
|
2064
|
+
"FOWNER",
|
|
2065
|
+
"SETUID",
|
|
2066
|
+
"SETGID"
|
|
2067
|
+
];
|
|
2068
|
+
let hostCpus;
|
|
2069
|
+
/** Docker refuses `--cpus` above the host's count, so cap it there. */
|
|
2070
|
+
async function cpuLimit(cpus) {
|
|
2071
|
+
hostCpus ??= dockerCapacity().then((c) => c?.cpus, () => void 0);
|
|
2072
|
+
const available = await hostCpus;
|
|
2073
|
+
return String(available ? Math.min(cpus, available) : cpus);
|
|
2074
|
+
}
|
|
2075
|
+
/** The `docker run` flags that confine the container: its user, capabilities and limits. */
|
|
2076
|
+
async function confinementArgs(options) {
|
|
2077
|
+
const limits = {
|
|
2078
|
+
...DEFAULT_LIMITS,
|
|
2079
|
+
...options.limits
|
|
2080
|
+
};
|
|
2081
|
+
const caps = [...options.root ? ROOT_CAPABILITIES : [], ...options.capabilities ?? []];
|
|
2082
|
+
return [
|
|
2083
|
+
"--user",
|
|
2084
|
+
options.root ? "0:0" : SANDBOX_USER,
|
|
2085
|
+
"--cap-drop",
|
|
2086
|
+
"ALL",
|
|
2087
|
+
...[...new Set(caps)].flatMap((c) => ["--cap-add", c]),
|
|
2088
|
+
"--security-opt",
|
|
2089
|
+
"no-new-privileges",
|
|
2090
|
+
"--memory",
|
|
2091
|
+
String(limits.memory),
|
|
2092
|
+
"--memory-swap",
|
|
2093
|
+
String(limits.memory),
|
|
2094
|
+
"--pids-limit",
|
|
2095
|
+
String(limits.pids),
|
|
2096
|
+
"--cpus",
|
|
2097
|
+
await cpuLimit(limits.cpus)
|
|
2098
|
+
];
|
|
2099
|
+
}
|
|
2100
|
+
/** Runtime `npm i -g` goes to the sandbox user's own prefix, whose `bin` the images put on PATH. */
|
|
2101
|
+
const userEnv = (options) => options.root ? {} : { NPM_CONFIG_PREFIX: `${SANDBOX_HOME}/.local` };
|
|
2102
|
+
/** Set on every exec, so a kill can find the command's whole process tree in the container. */
|
|
2103
|
+
const EXEC_MARKER = "REEVAL_EXEC_ID";
|
|
2104
|
+
/** The Docker networks a container is attached to. */
|
|
2105
|
+
async function networksOf(id) {
|
|
2106
|
+
const out = await dockerOk([
|
|
2107
|
+
"inspect",
|
|
2108
|
+
"--format",
|
|
2109
|
+
"{{json .NetworkSettings.Networks}}",
|
|
2110
|
+
id
|
|
2111
|
+
]);
|
|
2112
|
+
return Object.keys(JSON.parse(out) ?? {});
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* A harness sandbox session backed by one local Docker container. Commands and file I/O go
|
|
2116
|
+
* through `docker exec`. With the egress gateway, ports are reached through its tunnel whatever
|
|
2117
|
+
* the network policy; without it, the bridge port is published on 127.0.0.1.
|
|
2118
|
+
*/
|
|
2119
|
+
async function createDockerSession(options) {
|
|
2120
|
+
const nonce = randomBytes(8).toString("hex");
|
|
2121
|
+
const labels = {
|
|
2122
|
+
[LABEL.managed]: "true",
|
|
2123
|
+
[LABEL.session]: nonce,
|
|
2124
|
+
...options.labels
|
|
2125
|
+
};
|
|
2126
|
+
const lifetime = Math.ceil((options.lifetimeMs ?? 864e5) / 1e3);
|
|
2127
|
+
const signal = options.abortSignal;
|
|
2128
|
+
const env = {
|
|
2129
|
+
...userEnv(options),
|
|
2130
|
+
...options.env
|
|
2131
|
+
};
|
|
2132
|
+
const id = await dockerOk([
|
|
2133
|
+
"run",
|
|
2134
|
+
"--detach",
|
|
2135
|
+
"--rm",
|
|
2136
|
+
"--init",
|
|
2137
|
+
...await confinementArgs(options),
|
|
2138
|
+
...Object.entries(labels).flatMap(([k, v]) => ["--label", `${k}=${v}`]),
|
|
2139
|
+
...Object.keys(env).flatMap((k) => ["--env", k]),
|
|
2140
|
+
"--publish",
|
|
2141
|
+
`127.0.0.1::${BRIDGE_PORT}`,
|
|
2142
|
+
"--workdir",
|
|
2143
|
+
SANDBOX_ROOT,
|
|
2144
|
+
"--entrypoint",
|
|
2145
|
+
"sleep",
|
|
2146
|
+
options.image,
|
|
2147
|
+
String(lifetime)
|
|
2148
|
+
], {
|
|
2149
|
+
signal,
|
|
2150
|
+
env
|
|
2151
|
+
}).catch((error) => {
|
|
2152
|
+
if (signal?.aborted) sweepLabelDetached(`${LABEL.session}=${nonce}`);
|
|
2153
|
+
if (/unable to find user/i.test(error.message)) {
|
|
2154
|
+
sweepLabelDetached(`${LABEL.session}=${nonce}`);
|
|
2155
|
+
throw new Error(`${options.image} has no ${SANDBOX_USER} user to run the sandbox as. Images reeval builds have one: rebuild it (reeval image build), or start from reeval's image or docker({ from }). An eval that needs root at run time can opt in with docker({ root: true }).`);
|
|
2156
|
+
}
|
|
2157
|
+
throw error;
|
|
2158
|
+
});
|
|
2159
|
+
let gateway;
|
|
2160
|
+
let removed;
|
|
2161
|
+
const remove = () => {
|
|
2162
|
+
gateway?.close();
|
|
2163
|
+
removed ??= removeContainerDetached(id);
|
|
2164
|
+
return removed;
|
|
2165
|
+
};
|
|
2166
|
+
if (signal?.aborted) {
|
|
2167
|
+
await remove();
|
|
2168
|
+
throw signal.reason;
|
|
2169
|
+
}
|
|
2170
|
+
const egressOptions = options.egress === true ? {} : options.egress || void 0;
|
|
2171
|
+
let egress;
|
|
2172
|
+
let detached = [];
|
|
2173
|
+
const attach = async (policy) => {
|
|
2174
|
+
if (isRestricted(policy) && detached.length === 0) {
|
|
2175
|
+
detached = await networksOf(id);
|
|
2176
|
+
for (const n of detached) await dockerOk([
|
|
2177
|
+
"network",
|
|
2178
|
+
"disconnect",
|
|
2179
|
+
"--force",
|
|
2180
|
+
n,
|
|
2181
|
+
id
|
|
2182
|
+
]);
|
|
2183
|
+
} else if (!isRestricted(policy) && detached.length > 0) {
|
|
2184
|
+
for (const n of detached) await dockerOk([
|
|
2185
|
+
"network",
|
|
2186
|
+
"connect",
|
|
2187
|
+
n,
|
|
2188
|
+
id
|
|
2189
|
+
]);
|
|
2190
|
+
detached = [];
|
|
2191
|
+
}
|
|
2192
|
+
};
|
|
2193
|
+
if (egressOptions) {
|
|
2194
|
+
const { policy = { mode: "allow-all" }, ...rest } = egressOptions;
|
|
2195
|
+
try {
|
|
2196
|
+
await attach(policy);
|
|
2197
|
+
gateway = await startGateway(id, {
|
|
2198
|
+
...rest,
|
|
2199
|
+
policy
|
|
2200
|
+
}, signal);
|
|
2201
|
+
egress = { active: true };
|
|
2202
|
+
} catch (error) {
|
|
2203
|
+
if (signal?.aborted || isRestricted(policy)) {
|
|
2204
|
+
await remove();
|
|
2205
|
+
if (signal?.aborted) throw signal.reason;
|
|
2206
|
+
throw new Error(`Can't enforce the egress policy: ${error.message}`);
|
|
2207
|
+
}
|
|
2208
|
+
egress = {
|
|
2209
|
+
active: false,
|
|
2210
|
+
reason: error.message
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
const envFor = (env) => gateway ? {
|
|
2215
|
+
...gateway.env,
|
|
2216
|
+
...env
|
|
2217
|
+
} : env;
|
|
2218
|
+
let execs = 0;
|
|
2219
|
+
const execArgs = (command, marker, cwd, env) => [
|
|
2220
|
+
"exec",
|
|
2221
|
+
"--interactive",
|
|
2222
|
+
"--workdir",
|
|
2223
|
+
cwd ? posix.resolve(SANDBOX_ROOT, cwd) : SANDBOX_ROOT,
|
|
2224
|
+
"--env",
|
|
2225
|
+
`${EXEC_MARKER}=${marker}`,
|
|
2226
|
+
...Object.keys(env ?? {}).flatMap((k) => ["--env", k]),
|
|
2227
|
+
id,
|
|
2228
|
+
"bash",
|
|
2229
|
+
"-c",
|
|
2230
|
+
command
|
|
2231
|
+
];
|
|
2232
|
+
const nextMarker = () => `${id.slice(0, 12)}-${++execs}`;
|
|
2233
|
+
/**
|
|
2234
|
+
* Killing the host's `docker exec` client doesn't stop the command in the container, so kill
|
|
2235
|
+
* every process there that inherited this exec's marker: the command and all it started.
|
|
2236
|
+
*/
|
|
2237
|
+
const killExec = (marker) => docker([
|
|
2238
|
+
"exec",
|
|
2239
|
+
id,
|
|
2240
|
+
"sh",
|
|
2241
|
+
"-c",
|
|
2242
|
+
`for e in /proc/[0-9]*/environ; do grep -qsxzF ${shellQuote(`${EXEC_MARKER}=${marker}`)} "$e" && kill -9 "$(basename "$(dirname "$e")")" 2>/dev/null; done; true`
|
|
2243
|
+
]).then(() => {}, () => {});
|
|
2244
|
+
const run = async (o) => {
|
|
2245
|
+
const env = envFor(o.env);
|
|
2246
|
+
const marker = nextMarker();
|
|
2247
|
+
try {
|
|
2248
|
+
const r = await docker(execArgs(o.command, marker, o.workingDirectory, env), {
|
|
2249
|
+
env,
|
|
2250
|
+
signal: o.abortSignal
|
|
2251
|
+
});
|
|
2252
|
+
return {
|
|
2253
|
+
exitCode: r.exitCode,
|
|
2254
|
+
stdout: r.stdout.toString("utf8"),
|
|
2255
|
+
stderr: r.stderr
|
|
2256
|
+
};
|
|
2257
|
+
} catch (error) {
|
|
2258
|
+
if (o.abortSignal?.aborted) await killExec(marker);
|
|
2259
|
+
throw error;
|
|
2260
|
+
}
|
|
2261
|
+
};
|
|
2262
|
+
const readBinaryFile = async (o) => {
|
|
2263
|
+
const r = await docker([
|
|
2264
|
+
"exec",
|
|
2265
|
+
id,
|
|
2266
|
+
"cat",
|
|
2267
|
+
"--",
|
|
2268
|
+
posix.resolve(SANDBOX_ROOT, o.path)
|
|
2269
|
+
], { signal: o.abortSignal });
|
|
2270
|
+
return r.exitCode === 0 ? new Uint8Array(r.stdout) : null;
|
|
2271
|
+
};
|
|
2272
|
+
const writeBinaryFile = async (o) => {
|
|
2273
|
+
const path = posix.resolve(SANDBOX_ROOT, o.path);
|
|
2274
|
+
await dockerOk([
|
|
2275
|
+
"exec",
|
|
2276
|
+
"--interactive",
|
|
2277
|
+
id,
|
|
2278
|
+
"sh",
|
|
2279
|
+
"-c",
|
|
2280
|
+
`mkdir -p ${shellQuote(posix.dirname(path))} && cat > ${shellQuote(path)}`
|
|
2281
|
+
], {
|
|
2282
|
+
input: Buffer.from(o.content),
|
|
2283
|
+
signal: o.abortSignal
|
|
2284
|
+
});
|
|
2285
|
+
};
|
|
2286
|
+
const brokering = gateway ? {
|
|
2287
|
+
async setNetworkPolicy(policy) {
|
|
2288
|
+
gateway?.setPolicy(policy);
|
|
2289
|
+
await attach(policy);
|
|
2290
|
+
},
|
|
2291
|
+
async addRequestTransformations(rules) {
|
|
2292
|
+
gateway?.addTransformations(rules);
|
|
2293
|
+
},
|
|
2294
|
+
async setRequestTransformations(rules) {
|
|
2295
|
+
gateway?.setTransformations(rules);
|
|
2296
|
+
}
|
|
2297
|
+
} : {};
|
|
2298
|
+
const substituting = gateway ? {
|
|
2299
|
+
async setSubstitutions(subs) {
|
|
2300
|
+
gateway?.setSubstitutions(subs);
|
|
2301
|
+
},
|
|
2302
|
+
traffic: () => gateway.traffic()
|
|
2303
|
+
} : {};
|
|
2304
|
+
const session = {
|
|
2305
|
+
id,
|
|
2306
|
+
description: `Docker container ${id.slice(0, 12)} (${options.image})`,
|
|
2307
|
+
defaultWorkingDirectory: SANDBOX_ROOT,
|
|
2308
|
+
ports: [BRIDGE_PORT],
|
|
2309
|
+
get egress() {
|
|
2310
|
+
if (egress?.active && gateway?.stopped) return {
|
|
2311
|
+
active: false,
|
|
2312
|
+
reason: gateway.stopped
|
|
2313
|
+
};
|
|
2314
|
+
return egress;
|
|
2315
|
+
},
|
|
2316
|
+
...brokering,
|
|
2317
|
+
...substituting,
|
|
2318
|
+
run,
|
|
2319
|
+
async spawn(o) {
|
|
2320
|
+
const env = envFor(o.env);
|
|
2321
|
+
const marker = nextMarker();
|
|
2322
|
+
const args = execArgs(o.command, marker, o.workingDirectory, env);
|
|
2323
|
+
const startedAt = Date.now();
|
|
2324
|
+
const child = spawn("docker", args, {
|
|
2325
|
+
stdio: [
|
|
2326
|
+
"ignore",
|
|
2327
|
+
"pipe",
|
|
2328
|
+
"pipe"
|
|
2329
|
+
],
|
|
2330
|
+
detached: true,
|
|
2331
|
+
env: env ? {
|
|
2332
|
+
...process.env,
|
|
2333
|
+
...env
|
|
2334
|
+
} : process.env
|
|
2335
|
+
});
|
|
2336
|
+
const exited = new Promise((resolve) => child.on("close", (code) => {
|
|
2337
|
+
traceDocker(args, startedAt, {
|
|
2338
|
+
exitCode: code,
|
|
2339
|
+
stdout: "<streamed>"
|
|
2340
|
+
});
|
|
2341
|
+
resolve({ exitCode: code ?? 1 });
|
|
2342
|
+
}));
|
|
2343
|
+
const kill = async () => {
|
|
2344
|
+
child.kill("SIGKILL");
|
|
2345
|
+
await killExec(marker);
|
|
2346
|
+
};
|
|
2347
|
+
o.abortSignal?.addEventListener("abort", () => void kill(), { once: true });
|
|
2348
|
+
return {
|
|
2349
|
+
pid: child.pid,
|
|
2350
|
+
stdout: Readable.toWeb(child.stdout),
|
|
2351
|
+
stderr: Readable.toWeb(child.stderr),
|
|
2352
|
+
wait: () => exited,
|
|
2353
|
+
kill
|
|
2354
|
+
};
|
|
2355
|
+
},
|
|
2356
|
+
readBinaryFile,
|
|
2357
|
+
async readFile(o) {
|
|
2358
|
+
const bytes = await readBinaryFile(o);
|
|
2359
|
+
return bytes === null ? null : Readable.toWeb(Readable.from([bytes]));
|
|
2360
|
+
},
|
|
2361
|
+
async readTextFile(o) {
|
|
2362
|
+
const bytes = await readBinaryFile(o);
|
|
2363
|
+
if (bytes === null) return null;
|
|
2364
|
+
const text = Buffer.from(bytes).toString("utf8");
|
|
2365
|
+
if (o.startLine === void 0 && o.endLine === void 0) return text;
|
|
2366
|
+
return text.split("\n").slice((o.startLine ?? 1) - 1, o.endLine).join("\n");
|
|
2367
|
+
},
|
|
2368
|
+
writeBinaryFile,
|
|
2369
|
+
writeTextFile: (o) => writeBinaryFile({
|
|
2370
|
+
...o,
|
|
2371
|
+
content: Buffer.from(o.content, "utf8")
|
|
2372
|
+
}),
|
|
2373
|
+
async writeFile(o) {
|
|
2374
|
+
const chunks = [];
|
|
2375
|
+
for await (const chunk of o.content) chunks.push(chunk);
|
|
2376
|
+
await writeBinaryFile({
|
|
2377
|
+
...o,
|
|
2378
|
+
content: Buffer.concat(chunks)
|
|
2379
|
+
});
|
|
2380
|
+
},
|
|
2381
|
+
async getPortEndpoint({ port, protocol = "ws" }) {
|
|
2382
|
+
const scheme = protocol === "ws" ? "ws" : "http";
|
|
2383
|
+
if (gateway?.stopped) throw new Error(`Port ${port} of ${id.slice(0, 12)} isn't reachable: the egress gateway stopped: ${gateway.stopped}`);
|
|
2384
|
+
if (gateway) return { url: await gateway.portUrl(port, scheme) };
|
|
2385
|
+
const mapped = await docker([
|
|
2386
|
+
"port",
|
|
2387
|
+
id,
|
|
2388
|
+
`${port}/tcp`
|
|
2389
|
+
]);
|
|
2390
|
+
const hostPort = mapped.stdout.toString("utf8").split("\n")[0]?.split(":").at(-1);
|
|
2391
|
+
if (mapped.exitCode !== 0 || !hostPort) {
|
|
2392
|
+
const reason = mapped.stderr.trim() || "Docker reports no host port for it";
|
|
2393
|
+
throw new Error(`Port ${port} of ${id.slice(0, 12)} isn't reachable: ${reason}`);
|
|
2394
|
+
}
|
|
2395
|
+
return { url: `${scheme}://127.0.0.1:${hostPort}` };
|
|
2396
|
+
},
|
|
2397
|
+
async getPortUrl(o) {
|
|
2398
|
+
return (await session.getPortEndpoint(o)).url;
|
|
2399
|
+
},
|
|
2400
|
+
stop: remove,
|
|
2401
|
+
destroy: remove,
|
|
2402
|
+
restricted: () => {
|
|
2403
|
+
const { description, readFile, readBinaryFile, readTextFile, writeFile } = session;
|
|
2404
|
+
const { writeBinaryFile, writeTextFile, spawn, run } = session;
|
|
2405
|
+
return {
|
|
2406
|
+
description,
|
|
2407
|
+
readFile,
|
|
2408
|
+
readBinaryFile,
|
|
2409
|
+
readTextFile,
|
|
2410
|
+
writeFile,
|
|
2411
|
+
writeBinaryFile,
|
|
2412
|
+
writeTextFile,
|
|
2413
|
+
spawn,
|
|
2414
|
+
run
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
};
|
|
2418
|
+
return session;
|
|
2419
|
+
}
|
|
2420
|
+
/** The session as an AI SDK harness sandbox provider. */
|
|
2421
|
+
const dockerSandboxProvider = (options) => ({
|
|
2422
|
+
specificationVersion: "harness-sandbox-v1",
|
|
2423
|
+
providerId: "reeval-docker",
|
|
2424
|
+
async createSession(o) {
|
|
2425
|
+
const session = await createDockerSession({
|
|
2426
|
+
...options,
|
|
2427
|
+
abortSignal: o?.abortSignal
|
|
2428
|
+
});
|
|
2429
|
+
await o?.onFirstCreate?.(session, { abortSignal: o.abortSignal });
|
|
2430
|
+
return session;
|
|
2431
|
+
}
|
|
2432
|
+
});
|
|
2433
|
+
//#endregion
|
|
2434
|
+
//#region ../sandbox-docker/src/build-image.ts
|
|
2435
|
+
const PNPM_VERSION = "10.33.3";
|
|
2436
|
+
/** npm before 11.6 (node:22 ships 10.9) can crash on circular peer sets (npm/cli#8261); the same as docker/Dockerfile's. */
|
|
2437
|
+
const NPM_VERSION = "11.20.0";
|
|
2438
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
2439
|
+
const slugify = (text, max) => text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/, "");
|
|
2440
|
+
/** The build context of reeval's base image, shipped in the package. */
|
|
2441
|
+
const DOCKER_CONTEXT = fileURLToPath(ownFileURL("docker"));
|
|
2442
|
+
/**
|
|
2443
|
+
* The image `docker({ from })` evals run in: `from` plus Node, git and the harness's CLI. The hash
|
|
2444
|
+
* covers the toolchain layer too, so a change to the sandbox user or paths builds anew.
|
|
2445
|
+
*/
|
|
2446
|
+
const harnessImageFor = (harness, from) => `${imageFor(harness)}-${slugify(from, 40) || "image"}-${sha256(toolchainDockerfile(from)).slice(0, 8)}`;
|
|
2447
|
+
/**
|
|
2448
|
+
* How each harness's CLI gets into an image: its adapter's bootstrap, and the binary it leaves
|
|
2449
|
+
* (or, for an adapter without one, a command that prints what it installed).
|
|
2450
|
+
*/
|
|
2451
|
+
const BOOTSTRAPS = {
|
|
2452
|
+
cursor: {
|
|
2453
|
+
name: "Cursor",
|
|
2454
|
+
cli: "@cursor/sdk",
|
|
2455
|
+
version: `node -p "'@cursor/sdk ' + require(process.env.HOME + '/.ai-sdk-harness/.harness-bootstrap/cursor-sdk/node_modules/@cursor/sdk/package.json').version"`,
|
|
2456
|
+
adapter: async () => (await import("./src-daS0wKH2.js")).createCursorSdk()
|
|
2457
|
+
},
|
|
2458
|
+
"claude-code": {
|
|
2459
|
+
name: "Claude Code",
|
|
2460
|
+
cli: "claude",
|
|
2461
|
+
adapter: async () => (await import("./dist-BZXYHN2X.js")).createClaudeCode()
|
|
2462
|
+
},
|
|
2463
|
+
antigravity: {
|
|
2464
|
+
name: "Antigravity",
|
|
2465
|
+
cli: "agy",
|
|
2466
|
+
adapter: async () => (await import("./src-W9g-y5X5.js")).createAntigravity()
|
|
2467
|
+
},
|
|
2468
|
+
opencode: {
|
|
2469
|
+
name: "OpenCode",
|
|
2470
|
+
cli: "opencode",
|
|
2471
|
+
adapter: async () => (await import("./src-DEgff9Z7.js")).createOpenCode({
|
|
2472
|
+
auth: { GOOGLE_GENERATIVE_AI_API_KEY: "unused" },
|
|
2473
|
+
model: "google/unused"
|
|
2474
|
+
})
|
|
2475
|
+
},
|
|
2476
|
+
goose: {
|
|
2477
|
+
name: "Goose",
|
|
2478
|
+
cli: "goose",
|
|
2479
|
+
adapter: async () => (await import("./src-C-LHyTSV.js")).createGoose({
|
|
2480
|
+
auth: { GOOGLE_API_KEY: "unused" },
|
|
2481
|
+
model: "unused"
|
|
2482
|
+
})
|
|
2483
|
+
},
|
|
2484
|
+
pi: {
|
|
2485
|
+
name: "pi",
|
|
2486
|
+
cli: "pi",
|
|
2487
|
+
adapter: async () => (await import("./src-BDwSuyIo.js")).createPi({ auth: { GEMINI_API_KEY: "unused" } })
|
|
2488
|
+
},
|
|
2489
|
+
codex: {
|
|
2490
|
+
name: "Codex",
|
|
2491
|
+
cli: "codex",
|
|
2492
|
+
adapter: async () => (await import("./src-55j1zNHH.js")).createCodex({ auth: { OPENAI_API_KEY: "unused" } })
|
|
2493
|
+
}
|
|
2494
|
+
};
|
|
2495
|
+
/**
|
|
2496
|
+
* Adds what the harness bridge needs to a Debian or Ubuntu based toolchain image: Node (copied
|
|
2497
|
+
* from the official image, so the base's own toolchain is untouched), pnpm, git, curl and bash.
|
|
2498
|
+
*/
|
|
2499
|
+
const toolchainDockerfile = (from) => `FROM ${from}
|
|
2500
|
+
SHELL ["/bin/sh", "-c"]
|
|
2501
|
+
RUN command -v apt-get >/dev/null || { echo "reeval needs a Debian or Ubuntu based image (glibc and apt-get)" >&2; exit 1; } \\
|
|
2502
|
+
&& apt-get update \\
|
|
2503
|
+
&& apt-get install -y --no-install-recommends bash ca-certificates curl git procps \\
|
|
2504
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
2505
|
+
COPY --from=node:22-bookworm-slim /usr/local/bin/node /usr/local/bin/node
|
|
2506
|
+
COPY --from=node:22-bookworm-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
|
|
2507
|
+
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \\
|
|
2508
|
+
&& ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \\
|
|
2509
|
+
&& npm install --global npm@${NPM_VERSION} \\
|
|
2510
|
+
&& npm install --global pnpm@${PNPM_VERSION} \\
|
|
2511
|
+
&& { id -u ${SANDBOX_USER} >/dev/null 2>&1 \\
|
|
2512
|
+
|| useradd --create-home --shell /bin/bash --uid 10001 --user-group ${SANDBOX_USER} 2>/dev/null \\
|
|
2513
|
+
|| useradd --create-home --shell /bin/bash --user-group ${SANDBOX_USER}; } \\
|
|
2514
|
+
&& mkdir -p ${SANDBOX_ROOT}/${WORKSPACE_DIR} && chown -R ${SANDBOX_USER}: ${SANDBOX_ROOT}
|
|
2515
|
+
ENV PATH=${SANDBOX_HOME}/.local/bin:$PATH
|
|
2516
|
+
WORKDIR ${SANDBOX_ROOT}/${WORKSPACE_DIR}
|
|
2517
|
+
`;
|
|
2518
|
+
const tail = (text) => text.trim().split("\n").slice(-15).join("\n");
|
|
2519
|
+
const clipLine = (text, max = 300) => text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
2520
|
+
/** Why a `docker build` failed, in a line: the failing step's exit, or BuildKit's own error. */
|
|
2521
|
+
function buildReason(error) {
|
|
2522
|
+
if (!error) return "docker build failed";
|
|
2523
|
+
const exit = /^process ".*" did not complete successfully: exit code: (\d+)$/s.exec(error);
|
|
2524
|
+
return exit ? `a RUN step exited ${exit[1]}` : clipLine(error.replace(/\s+/g, " "));
|
|
2525
|
+
}
|
|
2526
|
+
/**
|
|
2527
|
+
* A failed `docker build --progress plain` as an {@link ImageBuildError}: `headline` then the
|
|
2528
|
+
* reason, the named cause, and the failing step's own last lines.
|
|
2529
|
+
*/
|
|
2530
|
+
function buildError(output, what, reason, keep = () => true) {
|
|
2531
|
+
const parsed = parseBuildOutput(output);
|
|
2532
|
+
const text = [parsed.error ?? "", ...parsed.output].join("\n");
|
|
2533
|
+
const cause = failureCause(text, parsed.exitCode);
|
|
2534
|
+
parsed.output = parsed.output.filter(keep);
|
|
2535
|
+
const why = reason?.(parsed) ?? buildReason(parsed.error);
|
|
2536
|
+
return new ImageBuildError(failureMessage({
|
|
2537
|
+
headline: `${what.headline} failed: ${why}`,
|
|
2538
|
+
cause,
|
|
2539
|
+
output: parsed.output,
|
|
2540
|
+
log: what.log ?? null
|
|
2541
|
+
}), {
|
|
2542
|
+
stage: what.stage,
|
|
2543
|
+
ref: what.ref,
|
|
2544
|
+
transient: isTransient(text),
|
|
2545
|
+
cause,
|
|
2546
|
+
log: what.log ?? null
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
/** Every image reeval builds carries this label, so `reeval clean --images` can find what it left. */
|
|
2550
|
+
const BUILT_BY = ["--label", `${LABEL.managed}=image`];
|
|
2551
|
+
/**
|
|
2552
|
+
* Removes the images reeval built that nothing tags any more (a rebuild's old image) and the
|
|
2553
|
+
* intermediate `-base` tags of `from` builds. Returns how many it removed.
|
|
2554
|
+
*/
|
|
2555
|
+
async function pruneImages() {
|
|
2556
|
+
const list = async (args) => (await docker([
|
|
2557
|
+
"image",
|
|
2558
|
+
"ls",
|
|
2559
|
+
...args
|
|
2560
|
+
])).stdout.toString("utf8").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
2561
|
+
const dangling = [...await list([
|
|
2562
|
+
"-q",
|
|
2563
|
+
"--filter",
|
|
2564
|
+
"dangling=true",
|
|
2565
|
+
"--filter",
|
|
2566
|
+
`label=${LABEL.managed}=image`
|
|
2567
|
+
]), ...await list([
|
|
2568
|
+
"-q",
|
|
2569
|
+
"--filter",
|
|
2570
|
+
"dangling=true",
|
|
2571
|
+
"--filter",
|
|
2572
|
+
`label=${LABEL.harness}`
|
|
2573
|
+
])];
|
|
2574
|
+
const bases = (await list([
|
|
2575
|
+
"--format",
|
|
2576
|
+
"{{.Repository}}:{{.Tag}}",
|
|
2577
|
+
"reeval-sandbox"
|
|
2578
|
+
])).filter((ref) => ref.endsWith("-base"));
|
|
2579
|
+
let removed = 0;
|
|
2580
|
+
for (const ref of [...bases, ...new Set(dangling)]) if ((await docker([
|
|
2581
|
+
"image",
|
|
2582
|
+
"rm",
|
|
2583
|
+
ref
|
|
2584
|
+
]).catch(() => null))?.exitCode === 0) removed++;
|
|
2585
|
+
return removed;
|
|
2586
|
+
}
|
|
2587
|
+
/**
|
|
2588
|
+
* What a `from` names now, without pulling: its registry digest, or for an image that's only
|
|
2589
|
+
* local, its id (`local`). `error` when neither resolves, e.g. a typo or no network.
|
|
2590
|
+
*/
|
|
2591
|
+
async function resolveFrom(from, signal) {
|
|
2592
|
+
const pinned = /@(sha256:[0-9a-f]{64})$/.exec(from)?.[1];
|
|
2593
|
+
if (pinned) return {
|
|
2594
|
+
digest: pinned,
|
|
2595
|
+
local: false
|
|
2596
|
+
};
|
|
2597
|
+
const remote = await docker([
|
|
2598
|
+
"buildx",
|
|
2599
|
+
"imagetools",
|
|
2600
|
+
"inspect",
|
|
2601
|
+
"--format",
|
|
2602
|
+
"{{json .Manifest.Digest}}",
|
|
2603
|
+
from
|
|
2604
|
+
], { signal }).catch(() => null);
|
|
2605
|
+
const digest = remote?.exitCode === 0 ? JSON.parse(remote.stdout.toString("utf8").trim()) : null;
|
|
2606
|
+
if (typeof digest === "string" && digest.startsWith("sha256:")) return {
|
|
2607
|
+
digest,
|
|
2608
|
+
local: false
|
|
2609
|
+
};
|
|
2610
|
+
const local = await imageInfo(from);
|
|
2611
|
+
if (local) return {
|
|
2612
|
+
digest: local.id,
|
|
2613
|
+
local: true
|
|
2614
|
+
};
|
|
2615
|
+
return { error: remote?.stderr.trim().replace(/^ERROR: /, "").replace(/\s+/g, " ") || `${from} doesn't resolve` };
|
|
2616
|
+
}
|
|
2617
|
+
/** Removes a tag or an image reeval replaced, unless something still uses it. */
|
|
2618
|
+
const removeQuietly = async (ref) => {
|
|
2619
|
+
await docker([
|
|
2620
|
+
"image",
|
|
2621
|
+
"rm",
|
|
2622
|
+
ref
|
|
2623
|
+
]).catch(() => {});
|
|
2624
|
+
};
|
|
2625
|
+
/** The hash of reeval's base image build context, which its images carry as a label. */
|
|
2626
|
+
function contextHash() {
|
|
2627
|
+
const hash = createHash("sha256");
|
|
2628
|
+
for (const path of contextFiles(DOCKER_CONTEXT, join(DOCKER_CONTEXT, "Dockerfile"))) {
|
|
2629
|
+
hash.update(`${path}\0`);
|
|
2630
|
+
hash.update(readFileSync(join(DOCKER_CONTEXT, path)));
|
|
2631
|
+
hash.update("\0");
|
|
2632
|
+
}
|
|
2633
|
+
return hash.digest("hex").slice(0, 16);
|
|
2634
|
+
}
|
|
2635
|
+
/**
|
|
2636
|
+
* Bakes a harness's bootstrap (which installs its agent's CLI) into an image, so the image tag
|
|
2637
|
+
* pins the CLI build. Rebuild after bumping the adapter. With `from`, the base is that toolchain
|
|
2638
|
+
* image instead of reeval's Node image. `log` gets progress lines, `output` the build's full output
|
|
2639
|
+
* (for a log file, whose path failures name). The previous image under the tag, and the
|
|
2640
|
+
* intermediate `-base` tag of a `from` build, are removed once the new one is committed.
|
|
2641
|
+
*/
|
|
2642
|
+
async function buildImage(log, options) {
|
|
2643
|
+
const { harness, from, signal } = options;
|
|
2644
|
+
const resolved = from ? await resolveFrom(from, signal) : null;
|
|
2645
|
+
const digest = resolved && "digest" in resolved ? resolved.digest : null;
|
|
2646
|
+
const pinned = from && digest && resolved && "local" in resolved && !resolved.local && !from.includes("@") ? `${from}@${digest}` : from;
|
|
2647
|
+
const bootstrap = BOOTSTRAPS[harness];
|
|
2648
|
+
const base = from ? `${harnessImageFor(harness, from)}-base` : BASE_IMAGE;
|
|
2649
|
+
const target = from ? harnessImageFor(harness, from) : imageFor(harness);
|
|
2650
|
+
const previous = [(await imageInfo(target))?.id, from ? null : (await imageInfo(base))?.id];
|
|
2651
|
+
log(`Building ${base}${from ? ` from ${from}` : ""}`);
|
|
2652
|
+
const build = from ? await docker([
|
|
2653
|
+
"build",
|
|
2654
|
+
"--progress",
|
|
2655
|
+
"plain",
|
|
2656
|
+
...BUILT_BY,
|
|
2657
|
+
...options.pull ? ["--pull"] : [],
|
|
2658
|
+
"--tag",
|
|
2659
|
+
base,
|
|
2660
|
+
"-"
|
|
2661
|
+
], {
|
|
2662
|
+
input: toolchainDockerfile(pinned ?? from),
|
|
2663
|
+
signal
|
|
2664
|
+
}) : await docker([
|
|
2665
|
+
"build",
|
|
2666
|
+
"--progress",
|
|
2667
|
+
"plain",
|
|
2668
|
+
...BUILT_BY,
|
|
2669
|
+
"--tag",
|
|
2670
|
+
base,
|
|
2671
|
+
DOCKER_CONTEXT
|
|
2672
|
+
], { signal });
|
|
2673
|
+
const output = `${build.stdout.toString("utf8")}${build.stderr}`;
|
|
2674
|
+
options.output?.write(output);
|
|
2675
|
+
if (build.exitCode !== 0) throw buildError(output, {
|
|
2676
|
+
stage: from ? "from" : "harness",
|
|
2677
|
+
ref: target,
|
|
2678
|
+
headline: from ? `Building the toolchain layer FROM ${from}` : "Building reeval's base image",
|
|
2679
|
+
log: options.output?.path ?? null
|
|
2680
|
+
}, (parsed) => parsed.output.find((l) => l.startsWith("reeval needs a Debian")) ?? null);
|
|
2681
|
+
log(`Installing the ${bootstrap.name} harness bootstrap`);
|
|
2682
|
+
const session = await createDockerSession({
|
|
2683
|
+
image: base,
|
|
2684
|
+
lifetimeMs: 18e5,
|
|
2685
|
+
...signal ? { abortSignal: signal } : {}
|
|
2686
|
+
});
|
|
2687
|
+
let agentVersion;
|
|
2688
|
+
try {
|
|
2689
|
+
agentVersion = await bake(session, bootstrap);
|
|
2690
|
+
log(`${bootstrap.name} ${bootstrap.version ? "" : "CLI "}${agentVersion}`);
|
|
2691
|
+
await dockerOk([
|
|
2692
|
+
"commit",
|
|
2693
|
+
"--change",
|
|
2694
|
+
`LABEL ${LABEL.agentVersion}="${agentVersion}" ${LABEL.harness}="${harnessLabel(harness)}"${from ? digest ? ` ${LABEL.fromDigest}="${digest}"` : "" : ` ${LABEL.context}="${contextHash()}"`}`,
|
|
2695
|
+
"--change",
|
|
2696
|
+
`WORKDIR ${SANDBOX_ROOT}`,
|
|
2697
|
+
"--change",
|
|
2698
|
+
"USER root",
|
|
2699
|
+
"--change",
|
|
2700
|
+
"ENTRYPOINT []",
|
|
2701
|
+
"--change",
|
|
2702
|
+
"CMD [\"bash\"]",
|
|
2703
|
+
session.id,
|
|
2704
|
+
target
|
|
2705
|
+
]);
|
|
2706
|
+
} catch (error) {
|
|
2707
|
+
if (signal?.aborted || error instanceof ImageBuildError) throw error;
|
|
2708
|
+
const [first = "", ...rest] = error.message.split("\n");
|
|
2709
|
+
const cause = failureCause(error.message, null);
|
|
2710
|
+
options.output?.write(`${output}\n${error.message}\n`);
|
|
2711
|
+
throw new ImageBuildError(failureMessage({
|
|
2712
|
+
headline: `Baking the ${bootstrap.name} CLI onto ${from ?? "reeval-sandbox-base:latest"} failed: ${clipLine(first)}`,
|
|
2713
|
+
cause,
|
|
2714
|
+
output: outputTail(rest.join("\n")),
|
|
2715
|
+
log: options.output?.path ?? null
|
|
2716
|
+
}), {
|
|
2717
|
+
stage: "harness",
|
|
2718
|
+
ref: target,
|
|
2719
|
+
transient: isTransient(error.message),
|
|
2720
|
+
cause,
|
|
2721
|
+
log: options.output?.path ?? null
|
|
2722
|
+
});
|
|
2723
|
+
} finally {
|
|
2724
|
+
await session.destroy();
|
|
2725
|
+
}
|
|
2726
|
+
if (from) await removeQuietly(base);
|
|
2727
|
+
const now = (await imageInfo(target))?.id;
|
|
2728
|
+
for (const id of previous) if (id && id !== now) await removeQuietly(id);
|
|
2729
|
+
return {
|
|
2730
|
+
image: target,
|
|
2731
|
+
agentVersion
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
/** Runs the harness bootstrap in `session` and returns the CLI version it installed. */
|
|
2735
|
+
async function bake(session, bootstrap) {
|
|
2736
|
+
await prepareSandboxForHarness({
|
|
2737
|
+
session,
|
|
2738
|
+
harnesses: [await bootstrap.adapter()],
|
|
2739
|
+
sandboxConfig: { workDir: WORKSPACE_DIR }
|
|
2740
|
+
});
|
|
2741
|
+
let probe = bootstrap.version;
|
|
2742
|
+
if (!probe) {
|
|
2743
|
+
const bin = (await session.run({ command: `find "$HOME/.ai-sdk-harness" -name ${bootstrap.cli} \\( -type f -o -type l \\) -perm -u+x | head -1` })).stdout.trim();
|
|
2744
|
+
if (!bin) throw new Error(`The harness bootstrap did not install the ${bootstrap.name} CLI`);
|
|
2745
|
+
probe = `'${bin}' --version`;
|
|
2746
|
+
}
|
|
2747
|
+
const version = await session.run({ command: probe });
|
|
2748
|
+
if (bootstrap.version && version.exitCode !== 0) throw new Error(`The harness bootstrap did not install ${bootstrap.cli}:\n${tail(version.stderr)}`);
|
|
2749
|
+
return version.stdout.trim() || "unknown";
|
|
2750
|
+
}
|
|
2751
|
+
/**
|
|
2752
|
+
* The image `docker({ install })` runs in: the commands on top of `parent`, tagged by the parent's
|
|
2753
|
+
* id and the exact list, so it's built once. Secrets and the workspace never exist here.
|
|
2754
|
+
*/
|
|
2755
|
+
const installLayerTag = (parent, steps) => `reeval-install:${sha256(JSON.stringify({
|
|
2756
|
+
parent: parent.id,
|
|
2757
|
+
steps
|
|
2758
|
+
})).slice(0, 16)}`;
|
|
2759
|
+
/** The commands as one bash script: in order, from `/`, stopping at the first that fails. */
|
|
2760
|
+
function installScript(steps) {
|
|
2761
|
+
const lines = ["cd /"];
|
|
2762
|
+
for (const [i, step] of steps.entries()) {
|
|
2763
|
+
const n = `${i + 1}/${steps.length}`;
|
|
2764
|
+
const run = step.timeoutMs ? `timeout --kill-after=10 ${Math.ceil(step.timeoutMs / 1e3)} bash -c ${shellQuote(step.run)}` : `bash -c ${shellQuote(step.run)}`;
|
|
2765
|
+
const label = shellQuote(step.run.split("\n")[0] ?? "");
|
|
2766
|
+
lines.push(`echo "reeval: install ${n}: "${label}`, `${run} || { code=$?; echo "reeval: install ${n} exited $code: "${label} >&2; exit $code; }`);
|
|
2767
|
+
}
|
|
2768
|
+
return `${lines.join("\n")}\n`;
|
|
2769
|
+
}
|
|
2770
|
+
/** Builds the install layer if it's missing. `output` gets the build's output, for a log file. */
|
|
2771
|
+
async function buildInstallLayer(parent, steps, options = {}) {
|
|
2772
|
+
const { signal } = options;
|
|
2773
|
+
const tag = installLayerTag(parent, steps);
|
|
2774
|
+
if ((await docker([
|
|
2775
|
+
"image",
|
|
2776
|
+
"inspect",
|
|
2777
|
+
"--format",
|
|
2778
|
+
"{{.Id}}",
|
|
2779
|
+
tag
|
|
2780
|
+
], { signal })).exitCode === 0) return tag;
|
|
2781
|
+
const script = installScript(steps);
|
|
2782
|
+
const encoded = Buffer.from(script).toString("base64");
|
|
2783
|
+
const home = `$(getent passwd ${SANDBOX_USER} | cut -d: -f6)`;
|
|
2784
|
+
const dockerfile = [
|
|
2785
|
+
`FROM ${parent.ref}`,
|
|
2786
|
+
"USER root",
|
|
2787
|
+
`RUN echo ${encoded} | base64 -d > /tmp/reeval-install.sh \\`,
|
|
2788
|
+
` && if id -u ${SANDBOX_USER} >/dev/null 2>&1; then export HOME="${home}"; fi \\`,
|
|
2789
|
+
" && bash /tmp/reeval-install.sh && rm /tmp/reeval-install.sh \\",
|
|
2790
|
+
` && { ! id -u ${SANDBOX_USER} >/dev/null 2>&1 || find "$HOME" -xdev ! -user ${SANDBOX_USER} -exec chown -h ${SANDBOX_USER}: {} +; }`,
|
|
2791
|
+
`WORKDIR ${SANDBOX_ROOT}`
|
|
2792
|
+
].join("\n");
|
|
2793
|
+
const build = await docker([
|
|
2794
|
+
"build",
|
|
2795
|
+
"--progress",
|
|
2796
|
+
"plain",
|
|
2797
|
+
...BUILT_BY,
|
|
2798
|
+
"--tag",
|
|
2799
|
+
tag,
|
|
2800
|
+
"-"
|
|
2801
|
+
], {
|
|
2802
|
+
input: dockerfile,
|
|
2803
|
+
signal
|
|
2804
|
+
});
|
|
2805
|
+
const output = `${build.stdout.toString("utf8")}${build.stderr}`;
|
|
2806
|
+
options.output?.write(`${script}\n${dockerfile}\n\n${output}`);
|
|
2807
|
+
if (build.exitCode !== 0) {
|
|
2808
|
+
const failed = /reeval: install (\d+\/\d+) exited (\d+): (.*)/.exec(output);
|
|
2809
|
+
throw buildError(output, {
|
|
2810
|
+
stage: "install",
|
|
2811
|
+
ref: tag,
|
|
2812
|
+
headline: `Building the install layer (${`${steps.length} command${steps.length === 1 ? "" : "s"}`})`,
|
|
2813
|
+
log: options.output?.path ?? null
|
|
2814
|
+
}, () => failed ? `install ${failed[1]} exited ${failed[2]}: ${clipLine(failed[3] ?? "")}` : null, (line) => !line.startsWith("reeval: install "));
|
|
2815
|
+
}
|
|
2816
|
+
return tag;
|
|
2817
|
+
}
|
|
2818
|
+
/** The build arg a Dockerfile can start `FROM` to layer on reeval's sandbox image. */
|
|
2819
|
+
const REEVAL_BASE_ARG = "REEVAL_BASE";
|
|
2820
|
+
/**
|
|
2821
|
+
* `.dockerignore` rules as Docker reads them: paths from the context root, `*`, `?` and `**`
|
|
2822
|
+
* globs, a pattern that matches a directory excludes what's in it, `!` re-includes, and the last
|
|
2823
|
+
* matching rule wins.
|
|
2824
|
+
*/
|
|
2825
|
+
function dockerignoreMatcher(text) {
|
|
2826
|
+
const rules = text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#")).map((line) => {
|
|
2827
|
+
const negate = line.startsWith("!");
|
|
2828
|
+
const body = (negate ? line.slice(1) : line).trim().replace(/^(?:\.?\/)+/, "").replace(/\/+$/, "");
|
|
2829
|
+
let re = "";
|
|
2830
|
+
for (let i = 0; i < body.length; i++) {
|
|
2831
|
+
const c = body[i];
|
|
2832
|
+
if (c === "*" && body[i + 1] === "*") {
|
|
2833
|
+
i++;
|
|
2834
|
+
if (body[i + 1] === "/") {
|
|
2835
|
+
i++;
|
|
2836
|
+
re += "(?:.*/)?";
|
|
2837
|
+
} else re += ".*";
|
|
2838
|
+
} else if (c === "*") re += "[^/]*";
|
|
2839
|
+
else if (c === "?") re += "[^/]";
|
|
2840
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
2841
|
+
}
|
|
2842
|
+
return {
|
|
2843
|
+
negate,
|
|
2844
|
+
re: new RegExp(`^${re}$`)
|
|
2845
|
+
};
|
|
2846
|
+
});
|
|
2847
|
+
return (path) => {
|
|
2848
|
+
const parts = path.split("/");
|
|
2849
|
+
const prefixes = parts.map((_, i) => parts.slice(0, i + 1).join("/"));
|
|
2850
|
+
let excluded = false;
|
|
2851
|
+
for (const rule of rules) if (prefixes.some((p) => rule.re.test(p))) excluded = !rule.negate;
|
|
2852
|
+
return excluded;
|
|
2853
|
+
};
|
|
2854
|
+
}
|
|
2855
|
+
/** Files in the build context that `docker build` would send, sorted, relative to `context`. */
|
|
2856
|
+
function contextFiles(context, dockerfile) {
|
|
2857
|
+
const own = [`${dockerfile}.dockerignore`, join(context, ".dockerignore")].find(existsSync);
|
|
2858
|
+
const ignored = dockerignoreMatcher(own ? readFileSync(own, "utf8") : "");
|
|
2859
|
+
const always = /* @__PURE__ */ new Set([relative(context, dockerfile), ".dockerignore"]);
|
|
2860
|
+
const files = [];
|
|
2861
|
+
const walk = (rel) => {
|
|
2862
|
+
for (const entry of readdirSync(join(context, rel), { withFileTypes: true })) {
|
|
2863
|
+
const path = rel ? `${rel}/${entry.name}` : entry.name;
|
|
2864
|
+
if (ignored(path) && !always.has(path)) continue;
|
|
2865
|
+
if (entry.isDirectory()) walk(path);
|
|
2866
|
+
else files.push(path);
|
|
2867
|
+
}
|
|
2868
|
+
};
|
|
2869
|
+
walk("");
|
|
2870
|
+
return files.sort();
|
|
2871
|
+
}
|
|
2872
|
+
/** Whether the Dockerfile starts from reeval's sandbox image, which then has to exist first. */
|
|
2873
|
+
const usesEsBase = (dockerfile) => new RegExp(`\\$\\{?${REEVAL_BASE_ARG}\\b`).test(readFileSync(dockerfile, "utf8"));
|
|
2874
|
+
/**
|
|
2875
|
+
* The tag of a `docker({ dockerfile })` build for a harness: a hash of the Dockerfile, every file
|
|
2876
|
+
* of its build context (its directory, less `.dockerignore`) and, for a Dockerfile `FROM
|
|
2877
|
+
* ${REEVAL_BASE}`, the id of the harness's image it names, so an edit to any of them, or a rebuilt reeval
|
|
2878
|
+
* image, builds anew. `esBase` is that id (null when it isn't built).
|
|
2879
|
+
*/
|
|
2880
|
+
function dockerfileTag(dockerfile, harness, esBase = null) {
|
|
2881
|
+
const context = dirname(dockerfile);
|
|
2882
|
+
const base = usesEsBase(dockerfile) ? `${imageFor(harness)}@${esBase ?? "unbuilt"}` : "";
|
|
2883
|
+
const hash = createHash("sha256").update(`${REEVAL_BASE_ARG}=${base}\0`);
|
|
2884
|
+
for (const path of contextFiles(context, dockerfile)) {
|
|
2885
|
+
const full = join(context, path);
|
|
2886
|
+
const stat = lstatSync(full);
|
|
2887
|
+
hash.update(`${path}\0${stat.mode & 73 ? "x" : "-"}\0`);
|
|
2888
|
+
hash.update(stat.isSymbolicLink() ? readlinkSync(full) : readFileSync(full));
|
|
2889
|
+
hash.update("\0");
|
|
2890
|
+
}
|
|
2891
|
+
const digest = hash.digest("hex");
|
|
2892
|
+
return {
|
|
2893
|
+
tag: `reeval-dockerfile:${slugify(basename(dockerfile) === "Dockerfile" ? basename(context) : basename(dockerfile), 40) || "image"}-${digest.slice(0, 16)}`,
|
|
2894
|
+
sha256: digest
|
|
2895
|
+
};
|
|
2896
|
+
}
|
|
2897
|
+
/**
|
|
2898
|
+
* Builds the user's Dockerfile with plain `docker build` (so Docker's layer cache applies), from
|
|
2899
|
+
* its directory, with `REEVAL_BASE` set to the harness's sandbox image (whose id is `esBase`).
|
|
2900
|
+
* Rebuilds even when the tag exists with `force`, as `reeval image build` does, and then removes the
|
|
2901
|
+
* image the tag pointed at before. `name` is the path failures name.
|
|
2902
|
+
*/
|
|
2903
|
+
async function buildDockerfile(dockerfile, options) {
|
|
2904
|
+
const { harness, signal } = options;
|
|
2905
|
+
const { tag } = dockerfileTag(dockerfile, harness, options.esBase ?? null);
|
|
2906
|
+
const previous = (await imageInfo(tag))?.id;
|
|
2907
|
+
if (previous && !options.force) return tag;
|
|
2908
|
+
const build = await docker([
|
|
2909
|
+
"build",
|
|
2910
|
+
"--progress",
|
|
2911
|
+
"plain",
|
|
2912
|
+
...BUILT_BY,
|
|
2913
|
+
...options.pull ? ["--pull"] : [],
|
|
2914
|
+
"--file",
|
|
2915
|
+
dockerfile,
|
|
2916
|
+
"--build-arg",
|
|
2917
|
+
`${REEVAL_BASE_ARG}=${imageFor(harness)}`,
|
|
2918
|
+
"--tag",
|
|
2919
|
+
tag,
|
|
2920
|
+
dirname(dockerfile)
|
|
2921
|
+
], { signal });
|
|
2922
|
+
const output = `${build.stdout.toString("utf8")}${build.stderr}`;
|
|
2923
|
+
options.output?.write(output);
|
|
2924
|
+
if (build.exitCode !== 0) throw buildError(output, {
|
|
2925
|
+
stage: "dockerfile",
|
|
2926
|
+
ref: tag,
|
|
2927
|
+
headline: `Building ${options.name ?? dockerfile}`,
|
|
2928
|
+
log: options.output?.path ?? null
|
|
2929
|
+
});
|
|
2930
|
+
const now = (await imageInfo(tag))?.id;
|
|
2931
|
+
if (previous && previous !== now) await removeQuietly(previous);
|
|
2932
|
+
return tag;
|
|
2933
|
+
}
|
|
2934
|
+
//#endregion
|
|
2935
|
+
export { onDockerCommand as $, hostAllowed as A, WORKSPACE_DIR as B, applyTransformations as C, HARNESS_HOSTS as D, DOCKER_EGRESS as E, HARNESS_PACKAGES as F, docker as G, harnessLabel as H, LABEL as I, dockerOk as J, dockerAvailable as K, SANDBOX_HOME as L, BASE_IMAGE as M, BRIDGE_PORT as N, PACKAGE_HOSTS as O, DEFAULT_IMAGE as P, removeContainerDetached as Q, SANDBOX_ROOT as R, placeholder as S, onEndpoint as T, imageFor as U, adapterVersion as V, isBakedHarness as W, imageInfo as X, dockerState as Y, isRegistryDigest as Z, ROOT_CAPABILITIES as _, buildInstallLayer as a, failureCause as at, CA_PATH as b, dockerfileTag as c, retryTransient as ct, installLayerTag as d, run as et, installScript as f, DEFAULT_LIMITS as g, usesEsBase as h, buildImage as i, causeLine as it, BAKED_HARNESSES as j, defaultAllowedHosts as k, dockerignoreMatcher as l, resolveFrom as m, REEVAL_BASE_ARG as n, ImageBuildError as nt, contextFiles as o, isTransient as ot, pruneImages as p, dockerCapacity as q, buildDockerfile as r, RETRY_DELAYS as rt, contextHash as s, parseBuildOutput as st, DOCKER_CONTEXT as t, shellQuote as tt, harnessImageFor as u, createDockerSession as v, matches as w, PROXY_PORT as x, dockerSandboxProvider as y, SANDBOX_USER as z };
|
|
2936
|
+
|
|
2937
|
+
//# sourceMappingURL=src-DwwiVLqv.js.map
|