| 1 | import { accessSync, constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; |
| 2 | import { spawn, spawnSync } from "node:child_process"; |
| 3 | import { createServer, request as httpRequest, type Server } from "node:http"; |
| 4 | import net from "node:net"; |
| 5 | import os from "node:os"; |
| 6 | import path from "node:path"; |
| 7 | |
| 8 | /** |
| 9 | * The jail an agent task runs in when it runs in someone's web terminal (phase 4b; Security's design). |
| 10 | * |
| 11 | * The terminal's sandbox holds the person's own Claude or ChatGPT sign-in, so the agent, which content in the |
| 12 | * repository can steer, runs under bubblewrap: no network of its own (a new network namespace with only |
| 13 | * loopback), a new PID namespace, no capabilities, an empty $HOME except the CLI's own sign-in, and nothing of |
| 14 | * the sandbox's work directory but the task's own clone. The person's checkout, its .git (with the sandbox's |
| 15 | * git credential) and the runner's state are not there; the checkout's objects are, read-only, because the |
| 16 | * task's clone borrows them (`git clone --shared`). |
| 17 | * |
| 18 | * The jail's only way out is a CONNECT proxy, outside it, that opens tunnels to the hosts in JAIL_HOSTS and |
| 19 | * nothing else. It never terminates TLS and handles no tokens: the CLI signs itself in, as in the terminal. |
| 20 | * Several of those hosts sit behind CDNs that serve every site they host on the same addresses, so a tunnel |
| 21 | * opens only once the client's TLS ClientHello names the same host (see `clientHelloServerName`). |
| 22 | * Inside, a relay on 127.0.0.1 forwards to the proxy's Unix socket and HTTPS_PROXY points at it. |
| 23 | * |
| 24 | * `finish` jails have no way out at all: they commit what the agent left and write a git bundle, which the |
| 25 | * runner, outside, pushes with the sandbox's credential. |
| 26 | * |
| 27 | * agent.mjs --jail --root /workspace agent --dir /workspace/task-1-1 --out /workspace/task-1-1.out --cli claude-code -- claude -p ... |
| 28 | */ |
| 29 | |
| 30 | /** Where a terminal task may connect, by name (port 443, or 80 for package mirrors' plain HTTP). */ |
| 31 | export const JAIL_HOSTS = [ |
| 32 | // Claude Code: the API, and its sign-in's token refresh. |
| 33 | "api.anthropic.com", |
| 34 | "console.anthropic.com", |
| 35 | "platform.claude.com", |
| 36 | // Codex on a ChatGPT plan: its backend and token refresh. |
| 37 | "chatgpt.com", |
| 38 | "auth.openai.com", |
| 39 | // Package registries, download hosts where they have their own (uploads go to other hosts). |
| 40 | "registry.npmjs.org", |
| 41 | "registry.yarnpkg.com", |
| 42 | "pypi.org", |
| 43 | "files.pythonhosted.org", |
| 44 | "index.crates.io", |
| 45 | "static.crates.io", |
| 46 | "rubygems.org", |
| 47 | "index.rubygems.org", |
| 48 | "repo1.maven.org", |
| 49 | "repo.maven.apache.org", |
| 50 | "deb.debian.org", |
| 51 | "security.debian.org", |
| 52 | "archive.ubuntu.com", |
| 53 | "security.ubuntu.com", |
| 54 | ]; |
| 55 | |
| 56 | /** Hosts the plain-HTTP side of the proxy serves (apt's default sources), for GET and HEAD only. */ |
| 57 | const PLAIN_HTTP_HOSTS = new Set(["deb.debian.org", "security.debian.org", "archive.ubuntu.com", "security.ubuntu.com"]); |
| 58 | |
| 59 | export function jailAllows(host: string, port: number): boolean { |
| 60 | const h = host.toLowerCase().replace(/\.$/, ""); |
| 61 | return (port === 443 && JAIL_HOSTS.includes(h)) || (port === 80 && PLAIN_HTTP_HOSTS.has(h)); |
| 62 | } |
| 63 | |
| 64 | /** Exit code when the jail itself can't start: nothing ran. */ |
| 65 | export const JAIL_FAILED = 125; |
| 66 | |
| 67 | const RELAY_PORT = 3128; |
| 68 | |
| 69 | /** Runs inside the jail: listens on 127.0.0.1 for the proxy, then runs the command and exits with it. */ |
| 70 | const RELAY = String.raw` |
| 71 | import net from "node:net"; |
| 72 | import os from "node:os"; |
| 73 | import { spawn } from "node:child_process"; |
| 74 | const [sock, port, ...cmd] = process.argv.slice(2); |
| 75 | const server = net.createServer((c) => { |
| 76 | const u = net.connect(sock); |
| 77 | c.pipe(u); |
| 78 | u.pipe(c); |
| 79 | c.on("error", () => u.destroy()); |
| 80 | u.on("error", () => c.destroy()); |
| 81 | }); |
| 82 | server.on("error", (e) => { |
| 83 | process.stderr.write("diffinery: the jail's proxy relay failed: " + e.message + "\n"); |
| 84 | process.exit(${JAIL_FAILED}); |
| 85 | }); |
| 86 | server.listen(Number(port), "127.0.0.1", () => { |
| 87 | const child = spawn(cmd[0], cmd.slice(1), { stdio: "inherit" }); |
| 88 | child.on("error", (e) => { |
| 89 | process.stderr.write("diffinery: " + cmd[0] + ": " + e.message + "\n"); |
| 90 | process.exit(127); |
| 91 | }); |
| 92 | child.on("exit", (code, signal) => process.exit(code ?? 128 + (os.constants.signals[signal] ?? 1))); |
| 93 | }); |
| 94 | `; |
| 95 | |
| 96 | export interface JailArgs { |
| 97 | mode: "agent" | "finish"; |
| 98 | root: string; |
| 99 | dir: string; |
| 100 | out: string; |
| 101 | cli?: "claude-code" | "codex"; |
| 102 | cmd: string[]; |
| 103 | } |
| 104 | |
| 105 | export function parseJailArgs(argv: string[], cwd = process.cwd()): JailArgs { |
| 106 | const sep = argv.indexOf("--"); |
| 107 | if (sep < 0 || sep === argv.length - 1) throw new Error("usage: --jail --root <dir> agent|finish --dir <dir> --out <dir> [--cli claude-code|codex] -- <command...>"); |
| 108 | const opts = argv.slice(0, sep); |
| 109 | const cmd = argv.slice(sep + 1); |
| 110 | const get = (name: string) => { |
| 111 | const i = opts.indexOf(name); |
| 112 | return i >= 0 ? opts[i + 1] : undefined; |
| 113 | }; |
| 114 | const mode = opts.find((o) => o === "agent" || o === "finish") as JailArgs["mode"] | undefined; |
| 115 | const root = get("--root"); |
| 116 | const dir = get("--dir"); |
| 117 | const out = get("--out"); |
| 118 | const cli = get("--cli"); |
| 119 | if (!mode || !root || !dir || !out) throw new Error("--jail needs agent or finish, --root, --dir and --out"); |
| 120 | if (cli !== undefined && cli !== "claude-code" && cli !== "codex") throw new Error(`unknown --cli ${cli}`); |
| 121 | const abs = (p: string) => path.resolve(cwd, p); |
| 122 | const a: JailArgs = { mode, root: abs(root), dir: abs(dir), out: abs(out), cli, cmd }; |
| 123 | // The task's own directories: inside the work root, beside the person's checkout and never in it. |
| 124 | const repo = path.join(a.root, "repo"); |
| 125 | for (const p of [a.dir, a.out]) { |
| 126 | if (path.dirname(p) !== a.root || p === repo || path.basename(p).startsWith(".")) throw new Error(`${p} isn't a task directory of ${a.root}`); |
| 127 | } |
| 128 | if (a.dir === a.out) throw new Error("--dir and --out must differ"); |
| 129 | return a; |
| 130 | } |
| 131 | |
| 132 | const under = (p: string, root: string) => p === root || p.startsWith(root.endsWith("/") ? root : `${root}/`); |
| 133 | |
| 134 | function which(name: string, PATH: string): string | null { |
| 135 | if (name.includes("/")) return existsSync(name) ? name : null; |
| 136 | for (const d of PATH.split(":").filter(Boolean)) { |
| 137 | const p = path.join(d, name); |
| 138 | try { |
| 139 | accessSync(p, constants.X_OK); |
| 140 | if (statSync(p).isFile()) return p; |
| 141 | } catch { |
| 142 | // not here |
| 143 | } |
| 144 | } |
| 145 | return null; |
| 146 | } |
| 147 | |
| 148 | function findBwrap(): string | null { |
| 149 | return which("bwrap", process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"); |
| 150 | } |
| 151 | |
| 152 | /** The files of its home directory each CLI gets in the jail, as copies (paths relative to $HOME). */ |
| 153 | export const CLI_FILES: Record<"claude-code" | "codex", string[]> = { |
| 154 | "claude-code": [".claude/.credentials.json", ".claude.json"], |
| 155 | codex: [".codex/auth.json", ".codex/config.toml"], |
| 156 | }; |
| 157 | |
| 158 | /** Variables the jail keeps from the sandbox's environment; everything else is cleared. */ |
| 159 | const KEEP_ENV = ["LANG", "LC_ALL", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "PIP_BREAK_SYSTEM_PACKAGES", "COREPACK_ENABLE_DOWNLOAD_PROMPT", "DIFFINERY_TASK_MESSAGE", "DIFFINERY_TASK_START"]; |
| 160 | |
| 161 | /** The bubblewrap command line for a jail (exported for tests). */ |
| 162 | export function bwrapArgs(a: JailArgs, o: { home: string; tools: string; env: NodeJS.ProcessEnv; cliPath?: string | null; nodePath: string }): string[] { |
| 163 | // The home directory (shell history, other sign-ins, other checkouts), the work root (the person's checkout and |
| 164 | // the runner's state) and /tmp are empty in the jail; /root too, where it isn't the home directory. |
| 165 | const hidden = [...new Set(["/tmp", o.home, a.root, ...(existsSync("/root") ? ["/root"] : [])])].sort((x, y) => x.length - y.length); |
| 166 | const args = ["--unshare-all", "--die-with-parent", "--new-session", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc"]; |
| 167 | for (const h of hidden) args.push("--tmpfs", h); |
| 168 | const isHidden = (p: string) => hidden.some((h) => under(p, h)); |
| 169 | // What the jail does see: the task's clone and its output directory, the checkout's objects read-only. |
| 170 | args.push("--bind", a.dir, a.dir, "--bind", a.out, a.out, "--ro-bind", path.join(a.root, "repo", ".git", "objects"), path.join(a.root, "repo", ".git", "objects")); |
| 171 | // The relay, the proxy's socket and the CLI's link: read-only (connecting to a socket needs no write). |
| 172 | args.push("--ro-bind", o.tools, o.tools); |
| 173 | const reveal = (p: string) => { |
| 174 | if (isHidden(p)) args.push("--ro-bind", p, p); |
| 175 | }; |
| 176 | // Node (for the relay) and the CLI, wherever they are installed. |
| 177 | reveal(path.dirname(path.dirname(o.nodePath))); |
| 178 | if (o.cliPath) reveal(path.dirname(o.cliPath)); |
| 179 | // CA bundles the sandbox trusts (the egress CA under web-only egress), wherever they are. |
| 180 | for (const k of ["NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"]) { |
| 181 | const f = o.env[k]; |
| 182 | if (f && path.isAbsolute(f) && existsSync(f)) reveal(f); |
| 183 | } |
| 184 | // The CLI's own sign-in and settings, at their usual paths so it signs itself in: private copies, never |
| 185 | // written back, so nothing the agent writes there (another account's credentials) reaches the terminal. |
| 186 | if (a.mode === "agent" && a.cli) { |
| 187 | for (const f of CLI_FILES[a.cli]) { |
| 188 | const copy = path.join(o.tools, "home", f.replace(/\//g, "_")); |
| 189 | if (!existsSync(copy)) continue; |
| 190 | args.push("--dir", path.dirname(path.join(o.home, f)), "--bind", copy, path.join(o.home, f)); |
| 191 | } |
| 192 | } |
| 193 | const PATH = [path.join(o.tools, "bin"), ...(o.env.PATH ?? "/usr/local/bin:/usr/bin:/bin").split(":").filter((d) => d && !isHidden(d))].join(":"); |
| 194 | args.push("--chdir", a.dir, "--clearenv", "--setenv", "PATH", PATH, "--setenv", "HOME", o.home, "--setenv", "TMPDIR", "/tmp", "--setenv", "DIFFINERY_TASK_OUT", a.out); |
| 195 | for (const k of KEEP_ENV) if (o.env[k] !== undefined) args.push("--setenv", k, o.env[k]!); |
| 196 | if (a.mode === "agent") { |
| 197 | // Claude Code skips its permission prompts when told it's in a sandbox (it runs as root here). |
| 198 | args.push("--setenv", "IS_SANDBOX", "1", "--setenv", "DISABLE_AUTOUPDATER", "1", "--setenv", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1"); |
| 199 | const proxy = `http://127.0.0.1:${RELAY_PORT}`; |
| 200 | for (const k of ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"]) args.push("--setenv", k, proxy); |
| 201 | } |
| 202 | return args; |
| 203 | } |
| 204 | |
| 205 | const CLIENT_HELLO_TIMEOUT_MS = 10_000; |
| 206 | /** A ClientHello spread over more than this is refused. */ |
| 207 | const MAX_CLIENT_HELLO_BYTES = 64 * 1024; |
| 208 | |
| 209 | /** |
| 210 | * The server name a TLS client's first bytes ask for (SNI): the name, lower case; `null` when they aren't a |
| 211 | * ClientHello, name no single host name, or carry Encrypted Client Hello (whose real name is hidden); or |
| 212 | * `undefined` while more bytes are needed. Handles a ClientHello split over several records. |
| 213 | */ |
| 214 | export function clientHelloServerName(data: Uint8Array): string | null | undefined { |
| 215 | const fragments: Uint8Array[] = []; |
| 216 | let have = 0; |
| 217 | let pos = 0; |
| 218 | for (;;) { |
| 219 | if (data.length < pos + 5) return data.length > MAX_CLIENT_HELLO_BYTES ? null : undefined; |
| 220 | if (data[pos] !== 0x16) return null; // not a handshake record |
| 221 | const len = (data[pos + 3]! << 8) | data[pos + 4]!; |
| 222 | if (len === 0 || len > 16384) return null; |
| 223 | if (data.length < pos + 5 + len) return pos + 5 + len > MAX_CLIENT_HELLO_BYTES ? null : undefined; |
| 224 | fragments.push(data.subarray(pos + 5, pos + 5 + len)); |
| 225 | have += len; |
| 226 | pos += 5 + len; |
| 227 | const hs = Buffer.concat(fragments, have); |
| 228 | if (hs[0] !== 0x01) return null; // not a ClientHello |
| 229 | if (hs.length < 4) continue; |
| 230 | const size = (hs[1]! << 16) | (hs[2]! << 8) | hs[3]!; |
| 231 | if (size + 4 > MAX_CLIENT_HELLO_BYTES) return null; |
| 232 | if (hs.length < size + 4) continue; |
| 233 | try { |
| 234 | return serverNameOf(hs.subarray(4, 4 + size)); |
| 235 | } catch { |
| 236 | return null; |
| 237 | } |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | function serverNameOf(b: Buffer): string | null { |
| 242 | const at = (n: number) => { |
| 243 | if (n >= b.length) throw new RangeError("short ClientHello"); |
| 244 | return b[n]!; |
| 245 | }; |
| 246 | const u16 = (n: number) => (at(n) << 8) | at(n + 1); |
| 247 | let p = 2 + 32; // version, random |
| 248 | p += 1 + at(p); // session id |
| 249 | p += 2 + u16(p); // cipher suites |
| 250 | p += 1 + at(p); // compression methods |
| 251 | if (p + 2 > b.length) return null; // no extensions, so no server name |
| 252 | const end = p + 2 + u16(p); |
| 253 | if (end > b.length) return null; |
| 254 | p += 2; |
| 255 | const names: string[] = []; |
| 256 | while (p < end) { |
| 257 | const type = u16(p); |
| 258 | const len = u16(p + 2); |
| 259 | const body = p + 4; |
| 260 | if (body + len > end) return null; |
| 261 | if (type === 0xfe0d) return null; // encrypted_client_hello |
| 262 | if (type === 0x0000) { |
| 263 | const listEnd = body + 2 + u16(body); |
| 264 | if (listEnd > body + len) return null; |
| 265 | for (let q = body + 2; q < listEnd; ) { |
| 266 | const nameType = at(q); |
| 267 | const nameLen = u16(q + 1); |
| 268 | if (q + 3 + nameLen > listEnd) return null; |
| 269 | if (nameType === 0) names.push(b.subarray(q + 3, q + 3 + nameLen).toString("latin1")); |
| 270 | q += 3 + nameLen; |
| 271 | } |
| 272 | } |
| 273 | p = body + len; |
| 274 | } |
| 275 | if (names.length !== 1 || !/^[A-Za-z0-9.-]+$/.test(names[0]!)) return null; |
| 276 | return names[0]!.toLowerCase().replace(/\.$/, ""); |
| 277 | } |
| 278 | |
| 279 | /** The CONNECT proxy outside the jail: tunnels to JAIL_HOSTS by name, plain-HTTP downloads from package mirrors. */ |
| 280 | export function startJailProxy( |
| 281 | listen: string | number, |
| 282 | log: (line: string) => void = (l) => process.stderr.write(`${l}\n`), |
| 283 | connectUpstream: (port: number, host: string, onConnect: () => void) => net.Socket = (port, host, onConnect) => net.connect(port, host, onConnect), |
| 284 | ): Promise<Server> { |
| 285 | const server = createServer((req, res) => { |
| 286 | let url: URL; |
| 287 | try { |
| 288 | url = new URL(req.url ?? ""); |
| 289 | } catch { |
| 290 | res.writeHead(400).end(); |
| 291 | return; |
| 292 | } |
| 293 | const port = Number(url.port || 80); |
| 294 | if (url.protocol !== "http:" || !jailAllows(url.hostname, port) || (req.method !== "GET" && req.method !== "HEAD")) { |
| 295 | log(`diffinery: refused ${req.method} ${url.protocol}//${url.host} from the task (only the hosts in /docs/agents/terminal#what-a-task-can-reach)`); |
| 296 | res.writeHead(403, { "content-type": "text/plain" }).end(`${url.host} isn't reachable from a terminal task.\n`); |
| 297 | return; |
| 298 | } |
| 299 | const headers = { ...req.headers }; |
| 300 | delete headers["proxy-connection"]; |
| 301 | delete headers["proxy-authorization"]; |
| 302 | const up = httpRequest({ host: url.hostname, port, path: `${url.pathname}${url.search}`, method: req.method, headers }, (r) => { |
| 303 | res.writeHead(r.statusCode ?? 502, r.headers); |
| 304 | r.pipe(res); |
| 305 | }); |
| 306 | up.on("error", () => res.writeHead(502).end()); |
| 307 | up.end(); |
| 308 | }); |
| 309 | server.on("connect", (req, socket, head) => { |
| 310 | const m = /^\[?([A-Za-z0-9.:-]+?)\]?:(\d+)$/.exec(req.url ?? ""); |
| 311 | const host = m?.[1] ?? ""; |
| 312 | const port = Number(m?.[2] ?? 0); |
| 313 | socket.on("error", () => socket.destroy()); |
| 314 | if (!m || port !== 443 || !jailAllows(host, port) || net.isIP(host)) { |
| 315 | log(`diffinery: refused a connection to ${req.url} from the task (only the hosts in /docs/agents/terminal#what-a-task-can-reach)`); |
| 316 | socket.end("HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\n\r\nnot reachable from a terminal task\n"); |
| 317 | return; |
| 318 | } |
| 319 | // The tunnel opens only once the client's ClientHello asks for this same host: CONNECT to an allowed |
| 320 | // host behind a CDN, then TLS naming another site on that CDN, would otherwise reach that site. |
| 321 | socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); |
| 322 | const want = host.toLowerCase().replace(/\.$/, ""); |
| 323 | let buf = Buffer.from(head); |
| 324 | const refuse = (why: string) => { |
| 325 | log(`diffinery: refused a connection to ${req.url} from the task: ${why}`); |
| 326 | socket.destroy(); |
| 327 | }; |
| 328 | const timer = setTimeout(() => refuse("no TLS ClientHello"), CLIENT_HELLO_TIMEOUT_MS); |
| 329 | const check = () => { |
| 330 | const name = clientHelloServerName(buf); |
| 331 | if (name === undefined) return; |
| 332 | socket.off("data", onData); |
| 333 | socket.pause(); |
| 334 | clearTimeout(timer); |
| 335 | if (name !== want) return refuse(name === null ? "its TLS ClientHello names no single server, or hides it (Encrypted Client Hello)" : `its TLS ClientHello asks for ${name}`); |
| 336 | const up = connectUpstream(port, host, () => { |
| 337 | up.write(buf); |
| 338 | up.pipe(socket); |
| 339 | socket.pipe(up); |
| 340 | socket.resume(); |
| 341 | }); |
| 342 | up.on("error", () => socket.destroy()); |
| 343 | }; |
| 344 | const onData = (d: Buffer) => { |
| 345 | buf = Buffer.concat([buf, d]); |
| 346 | check(); |
| 347 | }; |
| 348 | socket.on("data", onData); |
| 349 | socket.on("close", () => clearTimeout(timer)); |
| 350 | if (buf.length) check(); |
| 351 | }); |
| 352 | return new Promise((resolve, reject) => { |
| 353 | server.once("error", reject); |
| 354 | server.listen(listen, () => resolve(server)); |
| 355 | }); |
| 356 | } |
| 357 | |
| 358 | /** `agent.mjs --jail ...`: runs the command in the jail and exits with its code (JAIL_FAILED if it couldn't). */ |
| 359 | export async function runJail(argv: string[]): Promise<number> { |
| 360 | const fail = (m: string) => { |
| 361 | process.stderr.write(`diffinery: the task's jail couldn't start: ${m}\n`); |
| 362 | return JAIL_FAILED; |
| 363 | }; |
| 364 | let a: JailArgs; |
| 365 | try { |
| 366 | a = parseJailArgs(argv); |
| 367 | } catch (e) { |
| 368 | return fail((e as Error).message); |
| 369 | } |
| 370 | const bwrap = findBwrap(); |
| 371 | if (!bwrap) return fail("bubblewrap (bwrap) isn't installed in this sandbox"); |
| 372 | for (const p of [a.dir, a.out]) if (!existsSync(p)) return fail(`${p} doesn't exist`); |
| 373 | // bwrap's own setup errors exit 1 like any command would: try the namespaces first, so a jail that can't |
| 374 | // exist is never mistaken for an agent that failed. |
| 375 | const probe = spawnSync(bwrap, ["--unshare-all", "--die-with-parent", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--", "true"], { encoding: "utf8" }); |
| 376 | if (probe.status !== 0) return fail(`bubblewrap can't create its namespaces here: ${(probe.stderr || probe.error?.message || `exit ${probe.status}`).trim()}`); |
| 377 | const home = os.homedir(); |
| 378 | const tools = mkdtempSync(path.join(a.root, ".jail-")); |
| 379 | let server: Server | null = null; |
| 380 | try { |
| 381 | // The CLI, found as the terminal finds it, linked into the jail's PATH. |
| 382 | const cliPath = which(a.cmd[0]!, process.env.PATH ?? ""); |
| 383 | const real = cliPath ? realpathSync(cliPath) : null; |
| 384 | const bin = path.join(tools, "bin"); |
| 385 | mkdirSync(bin); |
| 386 | if (real) symlinkSync(real, path.join(bin, path.basename(a.cmd[0]!))); |
| 387 | mkdirSync(path.join(tools, "home")); |
| 388 | if (a.mode === "agent" && a.cli) for (const f of CLI_FILES[a.cli]) if (existsSync(path.join(home, f))) copyFileSync(path.join(home, f), path.join(tools, "home", f.replace(/\//g, "_"))); |
| 389 | let cmd = a.cmd; |
| 390 | if (a.mode === "agent") { |
| 391 | const sock = path.join(tools, "proxy.sock"); |
| 392 | server = await startJailProxy(sock); |
| 393 | writeFileSync(path.join(tools, "relay.mjs"), RELAY); |
| 394 | cmd = [process.execPath, path.join(tools, "relay.mjs"), sock, String(RELAY_PORT), ...a.cmd]; |
| 395 | } |
| 396 | const args = [...bwrapArgs(a, { home, tools, env: process.env, cliPath: real, nodePath: process.execPath }), "--", ...cmd]; |
| 397 | const code = await new Promise<number>((resolve) => { |
| 398 | const child = spawn(bwrap, args, { stdio: "inherit" }); |
| 399 | const stop = (sig: NodeJS.Signals) => child.kill(sig); |
| 400 | process.on("SIGTERM", stop); |
| 401 | process.on("SIGINT", stop); |
| 402 | child.on("error", (e) => resolve(fail(e.message))); |
| 403 | child.on("exit", (c, sig) => resolve(c ?? 128 + (os.constants.signals[sig as NodeJS.Signals] ?? 1))); |
| 404 | }); |
| 405 | return code; |
| 406 | } finally { |
| 407 | server?.close(); |
| 408 | rmSync(tools, { recursive: true, force: true }); |
| 409 | } |
| 410 | } |