diff --git a/docs/agent37.md b/docs/agent37.md new file mode 100644 index 0000000..2058eba --- /dev/null +++ b/docs/agent37.md @@ -0,0 +1,140 @@ +# Agent37 adapter + +Everything below was measured on a live Agent37 instance on 2026-08-25, on the +provider's default system template. Where a fact is shared with another +provider it says so, because two of the things previously logged as "Agent37 +defects" are not properties of Agent37 at all. + +## The template, and the one setting that matters + +```text +user node (uid 1000, gid 1000) +HOME /home/node +cwd / (the exec plane's default when no cwd is given) +os Debian GNU/Linux 12 (bookworm) +kernel 4.19.0-gvisor +shape 2 vCPU · 4096 MB · 10 GB +shell sh (dash) — NOT bash +``` + +**Construct `Agent37Runtime` with `defaultHomeDir: "/home/node"`.** This is the +single most consequential line in an Agent37 integration. + +`/root` **exists** — as `drwx------ root root` — and the template user cannot +enter it. So a launch that sets `workdir: '/root'` does not fail once, it fails +*every command on the box*, identically: + +```console +$ id; echo PWD=$PWD → exit 1 +sh: 1: cd: can't cd to /root +``` + +Ten unrelated probes were run that way and all ten returned exit 1. Re-pointed +at `/home/node`, all ten passed. Note the correction to an earlier note: `/root` +is **unreachable, not missing**, and the distinction matters because "missing" +sends you looking at the image while "unreachable" sends you to the one line of +caller configuration that actually causes it. + +Since a bare `cd … || exit 1` is indistinguishable from the command's own exit +1, the adapter no longer emits one. A failed `cd` now raises +`Agent37WorkdirUnusableError`, which names the directory and the instance. See +`AGENT37_WORKDIR_UNUSABLE_EXIT_CODE`. + +## The exec plane is dash + +`agent37`'s exec runs POSIX `sh`, not bash. A bashism does not error usefully — +`${PIPESTATUS[0]}` comes back as `Bad substitution`, so a step that looks like +it succeeded quietly did nothing. Anything this package generates for an +Agent37 box is POSIX; anything a caller generates should be too. + +## There is no root, and that is not an Agent37 property + +`sudo` is inert (`effective uid is not 0 … nosuid`) and `mkdir /opt/` +returns `Permission denied`. + +**The same `mkdir /opt` is denied on Daytona**, measured in the same run, so it +should stop being recorded as an Agent37 defect. Nothing needs root: npm's +global prefix is already `/home/node/.npm-global` and already on PATH, and +`/home/node`, `/home/node/.local/bin` and `/tmp` are all writable. + +## What the image does not ship + +| | Agent37 | Daytona | +| --- | --- | --- | +| `node` / `npm` | v24.19.0 / 11.17.0 | present | +| `git`, `curl`, `ssh`, `python3` | present | present | +| `gh` | **absent** — `gh --version` → **exit 127** | **absent** — **exit 127** | +| `relayfile-mount` | **absent from PATH** | `/usr/local/bin/relayfile-mount` | +| `agent-relay` | absent (installs from npm in ~46 s) | present in the image | + +Egress is open: `registry.npmjs.org` and the Agent Relay control plane both +answered `200`. + +Both gaps close in userspace with no root — see `src/bootstrap.ts`: + +- `buildRelayfileMountLinkShell` symlinks the daemon that `agent-relay` already + vendors as `@relayfile/mount--`. No download. +- `buildGhInstallShell` drops a release tarball into `~/.local/bin`; measured at + about three seconds, moving `gh --version` from exit 127 to exit 0. + +`gh auth status` then returns **exit 1** ("not logged into any GitHub hosts"). +Keep the two apart when reporting: a present binary with no credential is a +different failure from a missing binary, and only the second is exit 127. + +## Relayfile mount: `mount | grep` is not a test + +`relayfile-mount` is a **userspace sync daemon**, not a kernel or FUSE mount. +On a completely healthy Agent37 box: + +```console +$ mount | grep -i relayfile → exit 1, no output +``` + +The identical empty result comes back on Daytona, where the mount is in daily +production use. Two lanes drew a false conclusion from this check. + +Test it by moving bytes instead. Measured end to end on Agent37: a file written +inside the instance was read on a laptop (`exit 0`, byte-identical), a file +written on the laptop was read inside the instance (`exit 0`), and a third +machine on the same scope saw both. The daemon's own +`/.relay/state.json` is the honest instrument — `status`, the `files` +map, and `outbox` (`pending` / `failed` / `acked`). + +The gVisor kernel is the reason this shape is right: a FUSE mount is not +available, and a userspace mirror is unaffected. + +## Placing an agent + +A targeted spawn must name its working directory. `worker_cwd` is node-relative +and a `--node` spawn sets none, so without `--cwd` the agent lands at the +broker's project root rather than its workspace — and a tree at a path the +agent was never placed in is indistinguishable, from inside, from a missing +tree. With `--cwd` passed, `readlink /proc//cwd` confirmed the requested +directory for both the broker's PTY process and the agent process. + +Cross-node attach works with no ssh, but needs a **real PTY**: `script` fails on +non-tty stdin with `tcgetattr/ioctl: Operation not supported on socket`. +Allocate one (Python's `pty.fork()` will do) and read the bytes — 18,323 bytes +of live screen came back over a `--mode view` attach. + +Two harness gaps to expect on any fresh box, neither provider-specific: + +- A clean `~/.claude.json` records a valid API key's tail under + `customApiKeyResponses.rejected`, so the agent boots to an OAuth screen while + holding a working credential. `buildClaudeConfigSeedShell` approves the tail + and completes onboarding, and repairs an already-poisoned config. +- `relay node up` may resolve a different workspace than the one + `relay cloud enroll` bound the node to, which makes an in-box roster read + return a single entry — the node's own name. That is a platform issue, not a + provider one; it reproduces on Daytona. + +## Teardown + +Delete is synchronous enough to verify immediately: `destroy` returned in +7,242 ms and `GET /v1/instances` was empty 317 ms later, across three separate +runs with no leaked instance. + +**Daytona is not**: `destroy` returned in 131 ms there and an immediate +`getById` still resolved the sandbox, which was gone from the provider's list +moments later. A read-back straight after delete is not a valid "verified gone" +check on that provider. diff --git a/package.json b/package.json index 986afe9..feaf14c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "files": [ "dist", + "docs/agent37.md", "docs/freestyle.md", "README.md", "LICENSE", diff --git a/src/agent37/index.ts b/src/agent37/index.ts index 8645d4f..601be68 100644 --- a/src/agent37/index.ts +++ b/src/agent37/index.ts @@ -1,5 +1,7 @@ export { AGENT37_COMMAND_CAP_MS, + AGENT37_WORKDIR_UNUSABLE_EXIT_CODE, + AGENT37_WORKDIR_UNUSABLE_MARKER, Agent37CommandTimeoutUnsupportedError, Agent37CreateTimeoutUnsupportedError, Agent37EnvValidationError, @@ -7,6 +9,7 @@ export { Agent37MalformedResponseError, Agent37Runtime, Agent37UnknownExitCodeError, + Agent37WorkdirUnusableError, } from "./runtime.js"; export type { Agent37BundleFile, diff --git a/src/agent37/runtime.test.ts b/src/agent37/runtime.test.ts index 00c5124..1863610 100644 --- a/src/agent37/runtime.test.ts +++ b/src/agent37/runtime.test.ts @@ -132,6 +132,7 @@ function execCommand(request: RecordedRequest): string { return parsed.command as string; } + /** Strip comments so a source scan reads code, not the prose explaining it. */ function withoutComments(source: string): string { return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, ""); @@ -905,7 +906,13 @@ describe("Agent37Runtime.runScript", () => { env: { TOKEN_NAME: "it's fine" }, }); - const request = h.requests[0] as RecordedRequest; + // Two exec calls when cwd is set: pre-execution cwd probe, then the + // composed script itself. The probe body carries only the cwd — the + // sandbox has no material with which to spoof the workdir signal. + assert.equal(h.requests.length, 2); + assert.equal(execCommand(h.requests[0] as RecordedRequest), "cd '/work/repo'\n"); + + const request = h.requests[1] as RecordedRequest; assert.equal(request.url, `${TEST_BASE_URL}/v1/instances/ab12cd34ef/exec`); const parsed = JSON.parse(bodyText(request)) as Record; assert.deepEqual( @@ -913,20 +920,125 @@ describe("Agent37Runtime.runScript", () => { ["command"], "exec takes exactly one field; anything else is rejected by the API", ); + // No in-band marker: reclassification is decided by the probe, not by + // scanning the composed script's output. The cd guard is a plain + // `|| exit 191` so a race between the probe and the exec does not let + // the user command run in the shell's inherited directory. assert.equal( parsed.command, - "cd '/work/repo' || exit 1\nexport TOKEN_NAME='it'\\''s fine'\nnpm test\n", + "cd '/work/repo' || exit 191\nexport TOKEN_NAME='it'\\''s fine'\nnpm test\n", ); assert.deepEqual(result, { output: "ok\n", stdout: "ok\n", exitCode: 0 }); }); - it("falls back to the handle's workdir and omits cd when there is none", async () => { + it("falls back to the handle's workdir and omits both the probe and the cd when there is none", async () => { const h = harness(() => ({ json: { exit_code: 0, stdout: "", stderr: "" } })); const runtime = makeRuntime(h); + // Handle carries a workdir: probe first, then composed script. await runtime.runScript({ ...RUNNING_HANDLE, workdir: "/from/handle" }, { command: "ls" }); - assert.match(execCommand(h.requests[0] as RecordedRequest), /^cd '\/from\/handle' \|\| exit 1\n/); + assert.equal(execCommand(h.requests[0] as RecordedRequest), "cd '/from/handle'\n"); + assert.match( + execCommand(h.requests[1] as RecordedRequest), + /^cd '\/from\/handle' \|\| exit 191\nls\n$/, + ); + // No workdir anywhere: no probe, no cd guard. Bare command only. await runtime.runScript(RUNNING_HANDLE, { command: "ls" }); - assert.equal(execCommand(h.requests[1] as RecordedRequest), "ls\n"); + assert.equal(execCommand(h.requests[2] as RecordedRequest), "ls\n"); + }); + + it("names an unusable workdir before running any user command", async () => { + // The regression this guards: ten unrelated probes against `workdir: + // '/root'` all came back exit 1, and the lane concluded /root did not + // exist. It exists — root-owned, mode 0700, unreachable by the template's + // `node` user — and every exit 1 was the `cd`. The pre-execution probe + // catches this before any user command runs. + const h = harness(() => ({ + json: { + exit_code: 1, + stdout: "", + stderr: "sh: 1: cd: can't cd to /root\n", + }, + })); + const runtime = makeRuntime(h); + await assert.rejects( + runtime.runScript(RUNNING_HANDLE, { command: "id", cwd: "/root" }), + (error: unknown) => { + assert.ok(error instanceof pkg.Agent37WorkdirUnusableError); + assert.equal(error.instanceId, "ab12cd34ef"); + assert.equal(error.workdir, "/root"); + assert.match(error.output, /can't cd to \/root/); + return true; + }, + ); + // Only the probe ran — the user command never got a chance to execute. + assert.equal(h.requests.length, 1); + assert.equal(execCommand(h.requests[0] as RecordedRequest), "cd '/root'\n"); + }); + + it("does not reclassify when a hostile command tries to fake a workdir failure from inside the sandbox", async () => { + // The probe is the only signal a user command inside the sandbox cannot + // reach — it runs no user script and its exit code comes straight from + // the shell. Whatever a command prints or which code it exits with, if + // the probe said the cwd is usable, the result is an ordinary command + // exit, never `Agent37WorkdirUnusableError`. + const h = harness((_request, index) => { + if (index === 0) { + // Probe: cd succeeds; the workdir is fine. + return { json: { exit_code: 0, stdout: "", stderr: "" } }; + } + // Main script: hostile command exits 191 and prints the deprecated + // marker prefix in an attempt to look like a workdir fault. + return { + json: { + exit_code: pkg.AGENT37_WORKDIR_UNUSABLE_EXIT_CODE, + stdout: "", + stderr: `${pkg.AGENT37_WORKDIR_UNUSABLE_MARKER}\nhostile output\n`, + }, + }; + }); + const result = await makeRuntime(h).runScript(RUNNING_HANDLE, { + command: `printf '%s\\n' '${pkg.AGENT37_WORKDIR_UNUSABLE_MARKER}' >&2; exit 191`, + cwd: "/work", + }); + assert.equal(result.exitCode, pkg.AGENT37_WORKDIR_UNUSABLE_EXIT_CODE); + assert.match(result.output, /hostile output/); + }); + + it("does not treat an unknown probe outcome as a workdir failure", async () => { + // A response that omits `exit_code` is unknown, not a `cd` failure. If + // the probe returns nothing conclusive, run the user command anyway and + // let its own outcome speak — spoofing the workdir classifier through a + // malformed probe response must not be possible. + const h = harness((_request, index) => { + if (index === 0) { + // Probe: no exit_code — unknown outcome. + return { json: { stdout: "", stderr: "" } }; + } + return { json: { exit_code: 0, stdout: "ran", stderr: "" } }; + }); + const result = await makeRuntime(h).runScript(RUNNING_HANDLE, { + command: "true", + cwd: "/work", + }); + assert.equal(result.exitCode, 0); + assert.equal(result.output, "ran"); + }); + + it("passes the caller's requestTimeoutMs to the pre-execution probe", async () => { + // The probe must be bounded by the same request budget as the main exec + // — otherwise `runScript` could outlive the caller's wait limit whenever + // a cwd is set. + const h = harness(() => ({ json: { exit_code: 0, stdout: "", stderr: "" } })); + await makeRuntime(h).runScript(RUNNING_HANDLE, { + command: "true", + cwd: "/work", + requestTimeoutMs: 1234, + }); + assert.equal(h.requests.length, 2); + // AbortController presence on the probe request is the observable proof + // that the timeout was propagated to `execRaw`. + assert.equal((h.requests[0] as RecordedRequest).hasSignal, true, "probe must carry an abort signal"); + assert.equal((h.requests[1] as RecordedRequest).hasSignal, true, "main exec must carry an abort signal"); }); it("reports a nonzero exit as a result, not an error, and combines the streams", async () => { diff --git a/src/agent37/runtime.ts b/src/agent37/runtime.ts index ef08e5d..8502b00 100644 --- a/src/agent37/runtime.ts +++ b/src/agent37/runtime.ts @@ -300,6 +300,72 @@ export class Agent37UnknownExitCodeError extends Error { } } +/** + * Exit status the composed script leaves behind when its own `cd` guard + * trips — i.e. the cwd went away between the pre-execution probe and the + * script's own `cd`, and the user command was not permitted to run in the + * shell's inherited directory instead. + * + * Reclassification of a run to {@link Agent37WorkdirUnusableError} is + * decided by the out-of-band probe in {@link Agent37Runtime.runScript}, not + * by scanning the result for this code — the code exists so the composed + * script fails visibly instead of silently misdirecting the user command, + * not as a signal a user command could not also produce. The probe is what + * a user command inside the sandbox cannot reach. + * + * The original bug that shaped this design: a lane pointed an Agent37 + * instance at `workdir: '/root'`, watched ten unrelated probes all return + * exit 1, and concluded from it that `/root` did not exist. It does exist + * — as `drwx------ root root`, unreachable by the template's `node` user — + * and every one of those exit 1s was the `cd`. The pre-execution probe + * catches that class of fault before any user command runs. + */ +export const AGENT37_WORKDIR_UNUSABLE_EXIT_CODE = 191; + +/** + * @deprecated The workdir-unusable classifier no longer relies on an in-band + * marker string — reclassification is decided by an out-of-band probe. This + * constant is preserved only so callers that pattern-match legacy shell + * output for debugging continue to compile. It is not emitted by + * {@link composeScript}. + */ +export const AGENT37_WORKDIR_UNUSABLE_MARKER = "__agent37_workdir_unusable__"; + +/** + * Raised when the instance could not enter the requested working directory. + * + * This is a *configuration* fault, not a command failure: nothing the caller + * asked to run ever ran. Reporting it as `exitCode: 191` would leave the + * caller to infer that from stderr, which is exactly the inference that got + * made wrongly before. So it is named instead. + * + * The most common cause is a `workdir` that belongs to another user. Agent37's + * template runs as `node` (uid 1000) with `HOME=/home/node`; `/root` exists but + * is mode 0700 and owned by root, so pointing a launch at it fails every + * command on the box. See `docs/agent37.md`. + */ +export class Agent37WorkdirUnusableError extends Error { + /** Instance the command was sent to. */ + readonly instanceId: string; + /** The directory the script could not enter. */ + readonly workdir: string; + /** Whatever the shell said, kept so the underlying reason is not lost. */ + readonly output: string; + + constructor(instanceId: string, workdir: string, output: string) { + super( + `Agent37 could not enter working directory "${workdir}" on instance ` + + `"${instanceId}", so the command never ran. Check that the directory ` + + `exists and is readable by the template's user — the Agent37 template ` + + `runs as "node" with HOME=/home/node, and /root is root-owned mode 0700.`, + ); + this.name = "Agent37WorkdirUnusableError"; + this.instanceId = instanceId; + this.workdir = workdir; + this.output = output; + } +} + /** * Raised when a caller asks for a command lifetime Agent37 cannot enforce. * @@ -771,6 +837,52 @@ export class Agent37Runtime implements SandboxRuntime, WorkflowRuntime { assertValidEnv(options.env); } const cwd = options.cwd ?? handle.workdir; + // Prove the cwd is usable BEFORE running any user code. This is the only + // signal a user command cannot spoof: the probe runs no user script, so + // whatever is inside the sandbox has nothing to interpose on the probe's + // exit code and no way to mutate the cwd's accessibility before the + // probe sees it. A later `cd` failure inside the user's own exec is + // therefore always the command's own result, never proof that the + // caller's workdir was unusable. + // + // The probe is skipped when no cwd was requested — there is nothing to + // verify. + // + // When both the probe and the main exec run, they share ONE request + // budget: `requestTimeoutMs` from the caller is a total wait limit for + // `runScript`, not per-exec. Passing the full timeout to each call in + // sequence would let a two-hop runScript wait up to `2 × requestTimeoutMs`, + // silently exceeding the caller's contract. Instead, capture a monotonic + // start, and pass the remaining budget to each downstream `execRaw`. + // Node's `Date.now()` is a millisecond wall clock; a small skew between + // successive reads is fine because the budget is coarse to begin with. + const startAt = options.requestTimeoutMs !== undefined ? Date.now() : undefined; + const remainingBudgetMs = (): number | undefined => { + if (options.requestTimeoutMs === undefined || startAt === undefined) return undefined; + const remaining = options.requestTimeoutMs - (Date.now() - startAt); + // The client treats `<= 0` as "no timeout at all", which would silently + // uncancel an already-expired request. Floor at 1 so an over-budget + // call still aborts immediately rather than running to completion. + return remaining <= 0 ? 1 : remaining; + }; + if (cwd) { + const probe = await this.execRaw( + handle.id, + `cd ${shellQuote(cwd)}\n`, + remainingBudgetMs(), + ); + // Only a KNOWN nonzero exit is proof of a workdir fault. An unknown + // outcome (no exit_code in the response) is exactly that — unknown — + // and treating it as a `cd` failure would let a malformed response + // spoof the reclassification. + if (probe.exit_code !== null && probe.exit_code !== 0) { + throw new Agent37WorkdirUnusableError( + handle.id, + cwd, + combineOutput(probe.stdout, probe.stderr), + ); + } + } const script = composeScript(options.command, { ...(cwd ? { cwd } : {}), ...(options.env ? { env: options.env } : {}), @@ -779,7 +891,7 @@ export class Agent37Runtime implements SandboxRuntime, WorkflowRuntime { // lifetime, and it got here only because the provider's own cap already // satisfies it — turning it into an HTTP abort would abandon the response // while the command ran on. - const result = await this.execRaw(handle.id, script, options.requestTimeoutMs); + const result = await this.execRaw(handle.id, script, remainingBudgetMs()); return { output: combineOutput(result.stdout, result.stderr), ...(result.stdout ? { stdout: result.stdout } : {}), @@ -1208,7 +1320,17 @@ export function composeScript( ): string { const lines: string[] = []; if (options.cwd) { - lines.push(`cd ${shellQuote(options.cwd)} || exit 1`); + // Fail-fast guard: if the pre-execution probe passed but the cwd went + // away between the two exec calls, do NOT let the user command run in + // whatever directory the shell inherits — that is exactly the class of + // silent misdirection the workdir-unusable classifier exists to prevent. + // The sentinel exit code stays consistent for debuggability, but the + // caller no longer relies on any in-band marker: reclassification is + // decided by the out-of-band probe in {@link Agent37Runtime.runScript}. + // POSIX `sh`, not bash: Agent37's exec plane runs dash. + lines.push( + `cd ${shellQuote(options.cwd)} || exit ${AGENT37_WORKDIR_UNUSABLE_EXIT_CODE}`, + ); } for (const [key, value] of Object.entries(options.env ?? {})) { lines.push(`export ${key}=${shellQuote(value)}`); diff --git a/src/bootstrap.test.ts b/src/bootstrap.test.ts new file mode 100644 index 0000000..efa29f9 --- /dev/null +++ b/src/bootstrap.test.ts @@ -0,0 +1,609 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { promisify } from "node:util"; + +import { + GH_RELEASE_BASE_URL, + buildClaudeConfigSeedShell, + buildGhInstallShell, + buildRelayfileMountLinkShell, +} from "./index.js"; + +const run = promisify(execFile); + +/** + * These snippets are shipped to run inside a sandbox, so most of them are + * *executed* here rather than string-matched. A builder whose output only ever + * gets compared to an expected string will keep passing after it stops working + * — which is the failure mode this package has already paid for once, when a + * bad `cd` looked exactly like a bad command. + * + * Everything runs under `sh` (not bash) for the same reason the builders target + * POSIX: Agent37's exec plane is dash. + */ +async function sh( + script: string, + options: { env?: Record; cwd?: string } = {}, +): Promise<{ stdout: string; stderr: string; code: number }> { + try { + const { stdout, stderr } = await run("/bin/sh", ["-c", script], { + env: { PATH: process.env.PATH ?? "", ...options.env }, + ...(options.cwd ? { cwd: options.cwd } : {}), + maxBuffer: 8 * 1024 * 1024, + }); + return { stdout, stderr, code: 0 }; + } catch (error) { + const failure = error as { stdout?: string; stderr?: string; code?: number }; + return { stdout: failure.stdout ?? "", stderr: failure.stderr ?? "", code: failure.code ?? 1 }; + } +} + +async function scratch(): Promise { + return mkdtemp(join(tmpdir(), "sandbox-bootstrap-")); +} + +const PLATFORM_PACKAGE = `@relayfile/mount-${process.platform}-${process.arch}`; + +/** Lay down a fake global npm root containing an `agent-relay` install. */ +async function fakeGlobalRoot( + root: string, + options: { hoisted?: boolean } = {}, +): Promise { + const globalRoot = join(root, "lib", "node_modules"); + // Hoisted: npm lifted the platform package to the top level, beside + // `agent-relay`, instead of nesting it. Both layouts happen in the wild. + const packageHost = options.hoisted + ? globalRoot + : join(globalRoot, "agent-relay"); + const binDir = join(packageHost, "node_modules", PLATFORM_PACKAGE, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(join(globalRoot, "agent-relay"), { recursive: true }); + const binary = join(binDir, "relayfile-mount"); + await writeFile(binary, "#!/bin/sh\nprintf 'fake-relayfile-mount\\n'\n"); + await chmod(binary, 0o755); + return globalRoot; +} + +describe("buildRelayfileMountLinkShell", () => { + it("links the vendored binary onto PATH without downloading anything", async () => { + const root = await scratch(); + try { + const globalRoot = await fakeGlobalRoot(root); + const binDir = join(root, "bin"); + const script = buildRelayfileMountLinkShell({ binDir, searchRoots: [globalRoot] }); + + assert.ok( + !/curl|wget|npm install/.test(script), + "the whole point is that the binary is already on the box", + ); + + const result = await sh(script, { env: { PATH: `${binDir}:${process.env.PATH ?? ""}` } }); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout.trim(), new RegExp(`${PLATFORM_PACKAGE}/bin/relayfile-mount$`)); + + // The link is only useful if invoking the bare name works. + const invoked = await sh("relayfile-mount", { + env: { PATH: `${binDir}:${process.env.PATH ?? ""}` }, + }); + assert.equal(invoked.stdout.trim(), "fake-relayfile-mount"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("finds the binary when npm hoisted it above the agent-relay package", async () => { + const root = await scratch(); + try { + const globalRoot = await fakeGlobalRoot(root, { hoisted: true }); + const binDir = join(root, "bin"); + const result = await sh( + buildRelayfileMountLinkShell({ binDir, searchRoots: [globalRoot] }), + { env: { PATH: `${binDir}:${process.env.PATH ?? ""}` } }, + ); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout.trim(), /bin\/relayfile-mount$/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("fails loudly when no vendored binary exists, rather than leaving a broken box", async () => { + const root = await scratch(); + try { + const empty = join(root, "empty"); + await mkdir(empty, { recursive: true }); + const binDir = join(root, "bin"); + const result = await sh( + buildRelayfileMountLinkShell({ binDir, searchRoots: [empty] }), + { env: { PATH: `${binDir}:/usr/bin:/bin` } }, + ); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /relayfile-mount not found/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("refuses a relative bin directory and a link name with a path separator", () => { + assert.throws(() => buildRelayfileMountLinkShell({ binDir: "bin" }), /absolute path/); + assert.throws( + () => buildRelayfileMountLinkShell({ binDir: "/tmp/bin", linkName: "a/b" }), + /bare filename/, + ); + // `.` and `..` slip past the `/` check but `ln -sf src ..` follows the + // directory and drops the link one level above the bin dir. Reject both. + assert.throws( + () => buildRelayfileMountLinkShell({ binDir: "/tmp/bin", linkName: "." }), + /"\." or "\.\."/, + ); + assert.throws( + () => buildRelayfileMountLinkShell({ binDir: "/tmp/bin", linkName: ".." }), + /"\." or "\.\."/, + ); + }); + + it("does not consult npm's global root when explicit searchRoots are supplied", () => { + // A stale global `agent-relay` earlier in the resolver's argv would win + // the search over an explicitly named root, contradicting the option's + // documented ordering. + const withRoots = buildRelayfileMountLinkShell({ + binDir: "/home/node/.local/bin", + searchRoots: ["/opt/vendored"], + }); + assert.ok(!/npm root -g/.test(withRoots), "must not fall back to npm root when searchRoots given"); + + const withoutRoots = buildRelayfileMountLinkShell({ binDir: "/home/node/.local/bin" }); + assert.match(withoutRoots, /npm root -g/, "must fall back to npm root when searchRoots omitted"); + }); + + it("fails loud when a different binary shadows the linked one on PATH", async () => { + const root = await scratch(); + try { + const globalRoot = await fakeGlobalRoot(root); + const binDir = join(root, "bin"); + // A prior `relayfile-mount` sits earlier on PATH; the linked binary is + // present at the requested location but PATH order is wrong. The + // snippet must refuse rather than pretend everything is fine. + const shadow = join(root, "shadow"); + await mkdir(shadow, { recursive: true }); + await writeFile(join(shadow, "relayfile-mount"), "#!/bin/sh\nprintf 'shadow\\n'\n"); + await chmod(join(shadow, "relayfile-mount"), 0o755); + const result = await sh( + buildRelayfileMountLinkShell({ binDir, searchRoots: [globalRoot] }), + { env: { PATH: `${shadow}:${binDir}:${process.env.PATH ?? ""}` } }, + ); + assert.notEqual(result.code, 0, "must not report success when PATH resolves to another binary"); + assert.match(result.stderr, /prepend .* to PATH/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("accepts a binDir with a trailing slash without flagging the install as shadowed", async () => { + // `command -v` normalizes the resolved PATH entry, so a `binDir` of + // `/foo/` would compare against `/foo//name` if we naïvely concatenated + // and the post-link verification would fail a good install. + const root = await scratch(); + try { + const globalRoot = await fakeGlobalRoot(root); + const binDir = join(root, "bin"); + const result = await sh( + buildRelayfileMountLinkShell({ binDir: `${binDir}/`, searchRoots: [globalRoot] }), + { env: { PATH: `${binDir}:${process.env.PATH ?? ""}` } }, + ); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout.trim(), /bin\/relayfile-mount$/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +describe("buildGhInstallShell", () => { + /** A stand-in for a GitHub release tarball, served over `file://`. */ + async function fakeRelease( + root: string, + version: string, + ): Promise<{ baseUrl: string; sha256: string }> { + const arch = process.arch === "arm64" ? "arm64" : "amd64"; + const name = `gh_${version}_linux_${arch}`; + const stage = join(root, "stage", name, "bin"); + await mkdir(stage, { recursive: true }); + await writeFile(join(stage, "gh"), `#!/bin/sh\nprintf 'gh version ${version}\\n'\n`); + await chmod(join(stage, "gh"), 0o755); + const releases = join(root, "releases", `v${version}`); + await mkdir(releases, { recursive: true }); + const tarball = join(releases, `${name}.tar.gz`); + await run("tar", ["-czf", tarball, "-C", join(root, "stage"), name]); + const { stdout } = await run("/bin/sh", [ + "-c", + `if command -v sha256sum >/dev/null 2>&1; then sha256sum '${tarball}'; else shasum -a 256 '${tarball}'; fi`, + ]); + return { baseUrl: `file://${join(root, "releases")}`, sha256: stdout.trim().split(/\s+/)[0]! }; + } + + it("installs gh into a user directory and leaves it on PATH", async () => { + const root = await scratch(); + try { + const { baseUrl } = await fakeRelease(root, "9.9.9"); + const binDir = join(root, "bin"); + const workDir = join(root, "work"); + const script = buildGhInstallShell({ version: "9.9.9", binDir, workDir, releaseBaseUrl: baseUrl }); + // No root anywhere in the generated shell: that is the whole constraint. + // Assert on the shell we built, not on `gh --version` output — the + // stdout scan cannot detect `sudo` and would keep passing after the + // constraint stopped holding. + assert.ok(!/\bsudo\b/.test(script), "no sudo in generated shell"); + const result = await sh(script, { env: { PATH: `${binDir}:${process.env.PATH ?? ""}` } }); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /gh version 9\.9\.9/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("aborts before extracting when the checksum does not match", async () => { + const root = await scratch(); + try { + const { baseUrl } = await fakeRelease(root, "9.9.9"); + const binDir = join(root, "bin"); + const result = await sh( + buildGhInstallShell({ + version: "9.9.9", + binDir, + workDir: join(root, "work"), + releaseBaseUrl: baseUrl, + sha256: "0".repeat(64), + }), + { env: { PATH: `${binDir}:${process.env.PATH ?? ""}` } }, + ); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /failed SHA-256 verification/); + const listed = await sh(`ls ${binDir} 2>/dev/null || true`); + assert.equal(listed.stdout.trim(), "", "nothing may be installed from an unverified tarball"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("accepts the matching checksum", async () => { + const root = await scratch(); + try { + const { baseUrl, sha256 } = await fakeRelease(root, "9.9.9"); + const binDir = join(root, "bin"); + const result = await sh( + buildGhInstallShell({ + version: "9.9.9", + binDir, + workDir: join(root, "work"), + releaseBaseUrl: baseUrl, + sha256, + }), + { env: { PATH: `${binDir}:${process.env.PATH ?? ""}` } }, + ); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /gh version 9\.9\.9/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("defaults to the vendor's own release host and pins no version", () => { + assert.equal(GH_RELEASE_BASE_URL, "https://github.com/cli/cli/releases/download"); + assert.match( + buildGhInstallShell({ version: "2.82.1", binDir: "/home/node/.local/bin" }), + /https:\/\/github\.com\/cli\/cli\/releases\/download/, + ); + assert.throws(() => buildGhInstallShell({ version: "", binDir: "/x" }), /required/); + assert.throws(() => buildGhInstallShell({ version: "v2.82.1", binDir: "/x" }), /leading "v"/); + assert.throws( + () => buildGhInstallShell({ version: "2.82.1", binDir: "/x", sha256: "nope" }), + /64 lowercase hex/, + ); + }); + + it("fails loud when the installed binary cannot run, instead of masking through head", async () => { + // The regression this guards: `gh --version | head -1` used to hide a + // nonzero `gh` exit because dash has no pipefail and `head` still exits + // 0. A build that cannot run on this image now reports failure. + const root = await scratch(); + try { + const arch = process.arch === "arm64" ? "arm64" : "amd64"; + const version = "9.9.9"; + const name = `gh_${version}_linux_${arch}`; + const stage = join(root, "stage", name, "bin"); + await mkdir(stage, { recursive: true }); + // A `gh` that always exits nonzero — stand-in for a binary that cannot + // run on the image (missing loader, wrong arch, corrupted). + await writeFile(join(stage, "gh"), "#!/bin/sh\nexit 1\n"); + await chmod(join(stage, "gh"), 0o755); + const releases = join(root, "releases", `v${version}`); + await mkdir(releases, { recursive: true }); + await run("tar", ["-czf", join(releases, `${name}.tar.gz`), "-C", join(root, "stage"), name]); + const binDir = join(root, "bin"); + const result = await sh( + buildGhInstallShell({ + version, + binDir, + workDir: join(root, "work"), + releaseBaseUrl: `file://${join(root, "releases")}`, + }), + { env: { PATH: `${binDir}:${process.env.PATH ?? ""}` } }, + ); + assert.notEqual(result.code, 0, "must not mask a broken gh install"); + assert.match(result.stderr, /gh --version failed/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("fails loud when a different gh is earlier on PATH", async () => { + const root = await scratch(); + try { + const { baseUrl } = await fakeRelease(root, "9.9.9"); + const binDir = join(root, "bin"); + const shadow = join(root, "shadow"); + await mkdir(shadow, { recursive: true }); + await writeFile(join(shadow, "gh"), "#!/bin/sh\nprintf 'shadow gh\\n'\n"); + await chmod(join(shadow, "gh"), 0o755); + const result = await sh( + buildGhInstallShell({ + version: "9.9.9", + binDir, + workDir: join(root, "work"), + releaseBaseUrl: baseUrl, + }), + { env: { PATH: `${shadow}:${binDir}:${process.env.PATH ?? ""}` } }, + ); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /prepend .* to PATH/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +describe("buildClaudeConfigSeedShell", () => { + async function seed( + configPath: string, + env: Record, + options: Parameters[0] = { configPath }, + ) { + return sh(buildClaudeConfigSeedShell({ ...options, configPath }), { env }); + } + + it("creates a config that completes onboarding and approves the live key", async () => { + const root = await scratch(); + try { + const configPath = join(root, "home", ".claude.json"); + const result = await seed(configPath, { ANTHROPIC_API_KEY: `sk-ant-${"k".repeat(40)}` }); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /apiKeyApproved=1/); + + const cfg = JSON.parse(await readFile(configPath, "utf8")) as Record; + assert.equal(cfg.hasCompletedOnboarding, true); + const responses = cfg.customApiKeyResponses as { approved: string[]; rejected: string[] }; + assert.deepEqual(responses.approved, ["k".repeat(20)]); + assert.deepEqual(responses.rejected, []); + // The credential itself must never reach disk here. + assert.ok(!JSON.stringify(cfg).includes("sk-ant-")); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("never renders the key into the built command", () => { + const script = buildClaudeConfigSeedShell({ configPath: "/home/node/.claude.json" }); + assert.ok(!script.includes("sk-ant")); + assert.match(script, /\$\{ANTHROPIC_API_KEY:-\}/); + }); + + it("repairs a box whose config already rejected the key", async () => { + const root = await scratch(); + try { + const configPath = join(root, ".claude.json"); + const tail = "z".repeat(20); + await writeFile( + configPath, + JSON.stringify({ + machineID: "keep-me", + customApiKeyResponses: { approved: [], rejected: [tail, "other-tail"] }, + }), + ); + const result = await seed(configPath, { ANTHROPIC_API_KEY: `sk-ant-abc${tail}` }); + assert.equal(result.code, 0, result.stderr); + + const cfg = JSON.parse(await readFile(configPath, "utf8")) as Record; + assert.equal(cfg.machineID, "keep-me", "existing state must survive the merge"); + const responses = cfg.customApiKeyResponses as { approved: string[]; rejected: string[] }; + assert.deepEqual(responses.approved, [tail]); + assert.deepEqual( + responses.rejected, + ["other-tail"], + "only this key moves; another key's rejection is not ours to undo", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("still completes onboarding when no key is present, and says so", async () => { + const root = await scratch(); + try { + const configPath = join(root, ".claude.json"); + const result = await seed(configPath, {}); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /apiKeyApproved=0/); + const cfg = JSON.parse(await readFile(configPath, "utf8")) as Record; + assert.equal(cfg.hasCompletedOnboarding, true); + assert.equal(cfg.customApiKeyResponses, undefined); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("writes the config 0600 and records the onboarding version when given one", async () => { + const root = await scratch(); + try { + const configPath = join(root, ".claude.json"); + const result = await sh( + buildClaudeConfigSeedShell({ configPath, onboardingVersion: "2.1.245" }), + { env: { ANTHROPIC_API_KEY: "sk-ant-" + "q".repeat(30) } }, + ); + assert.equal(result.code, 0, result.stderr); + const cfg = JSON.parse(await readFile(configPath, "utf8")) as Record; + assert.equal(cfg.lastOnboardingVersion, "2.1.245"); + const mode = await sh(`ls -l ${configPath} | cut -c1-10`); + assert.equal(mode.stdout.trim(), "-rw-------"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("refuses to clobber a config file that is not a JSON object", async () => { + const root = await scratch(); + try { + const configPath = join(root, ".claude.json"); + await writeFile(configPath, "[1,2,3]"); + const result = await seed(configPath, {}); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /not a JSON object/); + assert.equal(await readFile(configPath, "utf8"), "[1,2,3]"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects a bare JSON null, false, 0, or empty string as the config", async () => { + // These used to be coerced to `{}` by `JSON.parse(...)||{}`, so the shape + // validation never saw them and the file was silently clobbered. + for (const payload of ["null", "false", "0", '""']) { + const root = await scratch(); + try { + const configPath = join(root, ".claude.json"); + await writeFile(configPath, payload); + const result = await seed(configPath, {}); + assert.notEqual(result.code, 0, `expected failure on payload ${payload}`); + assert.match(result.stderr, /not a JSON object/); + assert.equal(await readFile(configPath, "utf8"), payload, "invalid config must survive"); + } finally { + await rm(root, { recursive: true, force: true }); + } + } + }); + + it("re-enforces mode 0600 when rewriting an existing permissive config", async () => { + // The regression this guards: `fs.writeFileSync({ mode: 0o600 })` only + // applies the mode when the file is being created. A world-readable + // config kept its permissions after this snippet ran, leaving the API + // key tail behind mode 0644. + const root = await scratch(); + try { + const configPath = join(root, ".claude.json"); + await writeFile(configPath, "{}", { mode: 0o644 }); + await chmod(configPath, 0o644); + const before = await sh(`ls -l ${configPath} | cut -c1-10`); + assert.equal(before.stdout.trim(), "-rw-r--r--"); + const result = await seed(configPath, { ANTHROPIC_API_KEY: "sk-ant-" + "q".repeat(30) }); + assert.equal(result.code, 0, result.stderr); + const after = await sh(`ls -l ${configPath} | cut -c1-10`); + assert.equal(after.stdout.trim(), "-rw-------", "rewrite must tighten mode to 0600"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects a relative path and an env var that is not an identifier", () => { + assert.throws(() => buildClaudeConfigSeedShell({ configPath: ".claude.json" }), /absolute/); + assert.throws( + () => buildClaudeConfigSeedShell({ configPath: "/x/.claude.json", apiKeyEnvVar: "a-b" }), + /shell identifier/, + ); + }); + + it("survives a home directory whose name contains shell metacharacters", async () => { + const root = await scratch(); + try { + const home = join(root, "it's $(rm -rf); weird"); + await mkdir(home, { recursive: true }); + const configPath = join(home, ".claude.json"); + const result = await seed(configPath, {}); + assert.equal(result.code, 0, result.stderr); + const cfg = JSON.parse(await readFile(configPath, "utf8")) as Record; + assert.equal(cfg.hasCompletedOnboarding, true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +describe("bootstrap snippets are POSIX sh, not bash", () => { + it("uses no bashism that dash would reject", () => { + // Measured: Agent37's exec plane is dash, where `${PIPESTATUS[0]}` is a + // "Bad substitution" — a step that looks fine and silently does nothing. + const scripts = [ + buildRelayfileMountLinkShell({ binDir: "/home/node/.local/bin" }), + buildGhInstallShell({ version: "2.82.1", binDir: "/home/node/.local/bin" }), + buildClaudeConfigSeedShell({ configPath: "/home/node/.claude.json" }), + ]; + for (const script of scripts) { + assert.ok(!script.includes("PIPESTATUS"), script); + assert.ok(!/\[\[/.test(script), script); + assert.ok(!/\blocal\s/.test(script), script); + assert.ok(!/\bfunction\s+\w+\s*\(/.test(script), script); + } + }); + + it("parses under sh -n", async () => { + const root = await scratch(); + try { + const scripts: Record = { + link: buildRelayfileMountLinkShell({ binDir: "/home/node/.local/bin" }), + gh: buildGhInstallShell({ + version: "2.82.1", + binDir: "/home/node/.local/bin", + sha256: "a".repeat(64), + }), + claude: buildClaudeConfigSeedShell({ + configPath: "/home/node/.claude.json", + onboardingVersion: "2.1.245", + }), + }; + for (const [name, script] of Object.entries(scripts)) { + const file = join(root, `${name}.sh`); + await writeFile(file, script); + const result = await sh(`sh -n ${file}`); + assert.equal(result.code, 0, `${name}: ${result.stderr}`); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +describe("the symlink target survives being a symlink itself", () => { + it("re-links over an existing stale link", async () => { + const root = await scratch(); + try { + const globalRoot = await fakeGlobalRoot(root); + const binDir = join(root, "bin"); + await mkdir(binDir, { recursive: true }); + await symlink("/nonexistent/relayfile-mount", join(binDir, "relayfile-mount")); + const result = await sh( + buildRelayfileMountLinkShell({ binDir, searchRoots: [globalRoot] }), + { env: { PATH: `${binDir}:${process.env.PATH ?? ""}` } }, + ); + assert.equal(result.code, 0, result.stderr); + const invoked = await sh("relayfile-mount", { + env: { PATH: `${binDir}:${process.env.PATH ?? ""}` }, + }); + assert.equal(invoked.stdout.trim(), "fake-relayfile-mount"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 0000000..61e78aa --- /dev/null +++ b/src/bootstrap.ts @@ -0,0 +1,440 @@ +/** + * Shell snippets that close the gaps between a bare sandbox image and a box an + * agent can actually work on. + * + * These exist because a measured Agent37 run found three things missing that + * every provider is otherwise expected to have supplied: `relayfile-mount` was + * not on PATH, `gh` was not installed at all (`exit 127`), and a clean + * `~/.claude.json` auto-*rejected* a perfectly valid API key so the agent came + * up on a login screen. None of the three needs root, and none of the three is + * a provider fault — the same `gh` gap is `exit 127` on Daytona too, and the + * Claude config trap is image-independent. + * + * Two constraints shape every snippet here, and both are measured rather than + * assumed: + * + * - **POSIX `sh`, not bash.** Agent37's exec plane runs `dash`. A bashism such + * as `${PIPESTATUS[0]}` fails there with `Bad substitution`, silently + * turning a working step into a broken one. Nothing below uses arrays, + * `[[`, or `local`. + * - **No root.** The Agent37 template user is `node` (uid 1000), `sudo` is + * inert (`effective uid is not 0 … nosuid`), and `mkdir /opt` is denied — + * as it is on Daytona, so that is not an Agent37 property. Everything + * installs under `$HOME`, where npm's global prefix already points. + * + * The contract matches `mount-script.ts`: helpers take primitives, do their own + * shell quoting, and return ready-to-run shell. Callers must not re-quote. + */ + +/** Single-quote a value so no byte in it is parsed as shell syntax. */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function requireNonEmpty(value: string | undefined, field: string): string { + const trimmed = value?.trim(); + if (!trimmed) { + throw new Error(`${field} is required and must be a non-empty string`); + } + return trimmed; +} + +/** + * Reject a value that would be interpreted rather than used. + * + * Quoting already stops shell injection; this stops a subtler class of bug + * where a newline or a `..` segment produces a snippet that runs cleanly and + * does the wrong thing — the same reasoning `mount-script.ts` applies to + * remote paths. + */ +function requireAbsolutePath(value: string | undefined, field: string): string { + const path = requireNonEmpty(value, field); + if (!path.startsWith("/")) { + throw new Error(`${field} must be an absolute path; got ${JSON.stringify(path)}`); + } + if (path.includes("\n") || path.includes("\0")) { + throw new Error(`${field} must not contain a newline or NUL`); + } + if (path.split("/").includes("..")) { + throw new Error(`${field} must not contain a ".." segment; got ${JSON.stringify(path)}`); + } + return path; +} + +// --------------------------------------------------------------------------- +// relayfile-mount +// --------------------------------------------------------------------------- + +export type RelayfileMountLinkShellOptions = { + /** + * Directory the symlink is created in — for example `/home/node/.local/bin`. + * It is created if absent. It must already be on PATH; this builder does not + * edit a profile, because which profile a sandbox's exec plane reads is + * image-specific. + */ + binDir: string; + /** + * Name to link as. Defaults to `relayfile-mount`, which is the name every + * caller in this package invokes (see `mount-script.ts`). + */ + linkName?: string; + /** + * Roots to search for the vendored binary, in order. Omitted, the snippet + * asks npm for its global root, which is where a `npm install -g agent-relay` + * puts the package. + */ + searchRoots?: readonly string[]; +}; + +/** + * Shell that puts `relayfile-mount` on PATH from the copy already vendored + * inside the installed `agent-relay` package, and verifies the result. + * + * **No download and no root.** `agent-relay` ships the daemon as an optional + * per-platform dependency (`@relayfile/mount--`), so a box that + * has the CLI already has the binary — it just is not on PATH, which is why + * `command -v relayfile-mount` comes back empty on an Agent37 box while + * Daytona's image has it at `/usr/local/bin/relayfile-mount`. + * + * Resolution walks up from the `agent-relay` package directory looking for + * `node_modules/@relayfile/mount--/bin/relayfile-mount`, rather + * than joining a fixed path, because npm is free to hoist that dependency to a + * higher `node_modules`. The walk is the same shape the sandbox image's own + * entrypoint uses to find the broker binary. + * + * The snippet exits non-zero with a specific message when the binary cannot be + * found, so a bootstrap fails loudly instead of leaving a box whose mount will + * fail later for a reason nobody will connect to this step. + */ +export function buildRelayfileMountLinkShell(opts: RelayfileMountLinkShellOptions): string { + const binDir = requireAbsolutePath(opts.binDir, "binDir"); + const linkName = requireNonEmpty(opts.linkName ?? "relayfile-mount", "linkName"); + // `.` and `..` slip past the `/` check but `ln -sf src ..` follows the + // directory and creates the link one level up. Reject them explicitly. + if (linkName.includes("/") || linkName === "." || linkName === "..") { + throw new Error( + `linkName must be a bare filename other than "." or ".."; got ${JSON.stringify(linkName)}`, + ); + } + for (const root of opts.searchRoots ?? []) { + requireAbsolutePath(root, "searchRoots entry"); + } + + // `process.argv[1]` carries the roots so no path is interpolated into the JS + // source itself, and the resolver stays one readable expression. + const resolver = + "const fs=require('fs'),path=require('path');" + + "const pkg='@relayfile/mount-'+process.platform+'-'+process.arch;" + + "const roots=process.argv.slice(1).filter(Boolean);" + + "let found='';" + + "for(const root of roots){" + + "let dir=path.join(root,'agent-relay');" + + "if(!fs.existsSync(dir))continue;" + + "for(let i=0;i<10&&!found;i++){" + + "const cand=path.join(dir,'node_modules',pkg,'bin','relayfile-mount');" + + "if(fs.existsSync(cand))found=cand;" + + "const up=path.dirname(dir);" + + "if(up===dir)break;" + + "dir=up;}" + + "if(found)break;}" + + "if(!found)process.exit(1);" + + "process.stdout.write(found);"; + + const rootsExpr = (opts.searchRoots ?? []).map((root) => shellQuote(root)).join(" "); + // Only fall back to npm's global root when the caller did not name their own + // — otherwise a stale global `agent-relay` wins the search over the roots + // that were explicitly requested, contradicting the option's documented + // ordering. + const useNpmFallback = !opts.searchRoots || opts.searchRoots.length === 0; + const argsExpr = useNpmFallback + ? `"$(npm root -g 2>/dev/null)"${rootsExpr ? ` ${rootsExpr}` : ""}` + : rootsExpr; + // Strip a trailing slash before joining: `command -v` normalizes the PATH + // entry that resolves the lookup, so a `binDir` of `/foo/` would otherwise + // build an expected `linkPath` of `/foo//name` that never matches the + // normalized `/foo/name` and the post-link verification would fail a + // perfectly good install. + const linkPath = `${binDir.replace(/\/+$/, "")}/${linkName}`; + + return [ + `set -e`, + `mkdir -p ${shellQuote(binDir)}`, + `__rf_src=$(node -e ${shellQuote(resolver)} -- ${argsExpr}) || {`, + ` printf '%s\\n' 'relayfile-mount not found: no @relayfile/mount-- beside the installed agent-relay package. Install agent-relay first.' >&2`, + ` exit 1`, + `}`, + `ln -sf "$__rf_src" ${shellQuote(linkPath)}`, + // `command -v` alone is not enough: another `${linkName}` earlier on PATH + // wins the lookup and the bare invocation silently runs the wrong binary. + // Verify the resolved path is the one this snippet just created; if PATH + // is misordered we fail loud rather than leaving a box that looks fine. + `__rf_which=$(command -v ${shellQuote(linkName)} 2>/dev/null || true)`, + `if [ -z "$__rf_which" ]; then`, + ` printf '%s\\n' ${shellQuote(`${linkName} linked into ${binDir} but not on PATH; add ${binDir} to PATH`)} >&2`, + ` exit 1`, + `fi`, + `if [ "$__rf_which" != ${shellQuote(linkPath)} ]; then`, + ` printf '%s\\n' ${shellQuote(`${linkName} on PATH resolves to a different binary than ${linkPath}; prepend ${binDir} to PATH`)} >&2`, + ` exit 1`, + `fi`, + `printf '%s\\n' "$__rf_src"`, + ].join("\n"); +} + +// --------------------------------------------------------------------------- +// gh +// --------------------------------------------------------------------------- + +/** + * The GitHub CLI's own published release host. + * + * This package ships no *Agent Relay* endpoint defaults — a base URL for our + * own control plane is always a required argument, because no default is + * correct for every consumer. This is a different thing: it is the upstream + * vendor's canonical download host, the one their install instructions name. + * It stays overridable for callers behind a mirror. + */ +export const GH_RELEASE_BASE_URL = "https://github.com/cli/cli/releases/download"; + +export type GhInstallShellOptions = { + /** + * Release to install, without a leading `v` — for example `2.82.1`. + * Required: a pinned default here would rot silently, and a bootstrap that + * quietly changes which `gh` an agent gets is worse than one that asks. + */ + version: string; + /** + * Directory the `gh` binary is placed in — for example + * `/home/node/.local/bin`. Created if absent, and must be on PATH. + */ + binDir: string; + /** Override the download host. Defaults to {@link GH_RELEASE_BASE_URL}. */ + releaseBaseUrl?: string; + /** + * Expected SHA-256 of the downloaded tarball, lowercase hex. + * + * Optional but recommended: without it the snippet installs an unverified + * binary onto an agent's PATH. It is optional rather than required only + * because the digest is per-version-per-arch, so a caller that pins one arch + * can supply it and a caller that does not cannot. When supplied, a mismatch + * aborts before anything is extracted. + */ + sha256?: string; + /** + * Scratch directory for the download. Defaults to `/tmp`, which is writable + * on both providers measured. + */ + workDir?: string; +}; + +/** + * Shell that installs `gh` into a user-writable directory, no root required. + * + * On a measured Agent37 box this took about three seconds and moved + * `gh --version` from `exit 127` (`gh: not found`) to `exit 0`. Note what it + * does *not* fix: `gh auth status` then returns `exit 1` + * ("not logged into any GitHub hosts"). A present binary with no credential is + * a different failure from a missing binary, and only the first is this + * snippet's job. + * + * The architecture is resolved at run time from `uname -m` rather than baked + * in, so one built command serves amd64 and arm64 images. + */ +export function buildGhInstallShell(opts: GhInstallShellOptions): string { + const version = requireNonEmpty(opts.version, "version"); + if (version.startsWith("v")) { + throw new Error( + `version must not carry a leading "v"; got ${JSON.stringify(version)} — pass "${version.slice(1)}"`, + ); + } + if (!/^[0-9][0-9A-Za-z.+-]*$/.test(version)) { + throw new Error(`version is not a plausible release string: ${JSON.stringify(version)}`); + } + const binDir = requireAbsolutePath(opts.binDir, "binDir"); + const workDir = requireAbsolutePath(opts.workDir ?? "/tmp", "workDir"); + const baseUrl = requireNonEmpty( + opts.releaseBaseUrl ?? GH_RELEASE_BASE_URL, + "releaseBaseUrl", + ).replace(/\/+$/, ""); + const sha256 = opts.sha256?.trim().toLowerCase(); + if (sha256 !== undefined && !/^[0-9a-f]{64}$/.test(sha256)) { + throw new Error("sha256 must be 64 lowercase hex characters"); + } + + const lines = [ + `set -e`, + `mkdir -p ${shellQuote(binDir)} ${shellQuote(workDir)}`, + `__gh_ver=${shellQuote(version)}`, + `case "$(uname -m)" in`, + ` x86_64|amd64) __gh_arch=amd64 ;;`, + ` aarch64|arm64) __gh_arch=arm64 ;;`, + ` *) printf '%s\\n' "unsupported architecture for gh: $(uname -m)" >&2; exit 1 ;;`, + `esac`, + `__gh_name="gh_\${__gh_ver}_linux_\${__gh_arch}"`, + `__gh_tgz=${shellQuote(workDir)}/"\${__gh_name}.tar.gz"`, + `curl -fsSL -o "$__gh_tgz" ${shellQuote(baseUrl)}/"v\${__gh_ver}"/"\${__gh_name}.tar.gz"`, + ]; + + if (sha256) { + // `-c` reads " " — the two spaces are the format, not a + // typo. `sha256sum` is coreutils and `shasum` is the perl tool; a minimal + // image may ship either, so try both rather than assuming. Verified before + // extraction, so a bad payload is never unpacked. + lines.push( + `if command -v sha256sum >/dev/null 2>&1; then __gh_sum="sha256sum"`, + `elif command -v shasum >/dev/null 2>&1; then __gh_sum="shasum -a 256"`, + `else printf '%s\\n' 'no sha256sum or shasum available to verify the gh tarball' >&2; rm -f "$__gh_tgz"; exit 1`, + `fi`, + `printf '%s %s\\n' ${shellQuote(sha256)} "$__gh_tgz" | $__gh_sum -c - >/dev/null 2>&1 || {`, + ` printf '%s\\n' 'gh tarball failed SHA-256 verification; refusing to install' >&2`, + ` rm -f "$__gh_tgz"`, + ` exit 1`, + `}`, + ); + } + + // Strip a trailing slash before joining: `command -v` normalizes the PATH + // entry that resolves the lookup, so a `binDir` of `/foo/` would otherwise + // build an expected `ghPath` of `/foo//gh` that never matches the + // normalized `/foo/gh` and the post-install verification would fail a + // perfectly good install. + const ghPath = `${binDir.replace(/\/+$/, "")}/gh`; + lines.push( + `tar -xzf "$__gh_tgz" -C ${shellQuote(workDir)}`, + `cp ${shellQuote(workDir)}/"\${__gh_name}"/bin/gh ${shellQuote(ghPath)}`, + `chmod 0755 ${shellQuote(ghPath)}`, + `rm -rf "$__gh_tgz" ${shellQuote(workDir)}/"\${__gh_name}"`, + // Presence + resolved-path check: an older `gh` earlier on PATH would + // otherwise win the lookup and the caller would keep the pre-existing + // version despite this snippet returning success. + `__gh_which=$(command -v gh 2>/dev/null || true)`, + `if [ -z "$__gh_which" ]; then`, + ` printf '%s\\n' ${shellQuote(`gh installed into ${binDir} but not on PATH; add ${binDir} to PATH`)} >&2`, + ` exit 1`, + `fi`, + `if [ "$__gh_which" != ${shellQuote(ghPath)} ]; then`, + ` printf '%s\\n' ${shellQuote(`gh on PATH resolves to a different binary than ${ghPath}; prepend ${binDir} to PATH`)} >&2`, + ` exit 1`, + `fi`, + // Capture before piping: `gh --version | head -1` masks a nonzero `gh` + // exit because dash has no pipefail and `head` still returns 0. Verify + // status directly, then trim to the first line for the printed sanity + // check. + `__gh_ver_out=$(gh --version) || {`, + ` printf '%s\\n' 'gh --version failed; installed binary is not runnable' >&2`, + ` exit 1`, + `}`, + `printf '%s\\n' "$__gh_ver_out" | head -1`, + ); + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Claude Code first-run config +// --------------------------------------------------------------------------- + +export type ClaudeConfigSeedShellOptions = { + /** + * Absolute path to the config file — for example `/home/node/.claude.json`. + * It is created if absent and **merged** if present, never overwritten: a + * box may already carry machine ids and migration flags that matter. + */ + configPath: string; + /** + * Environment variable the snippet reads the Anthropic API key from at run + * time. Defaults to `ANTHROPIC_API_KEY`. + * + * The key is read *inside* the sandbox rather than interpolated into the + * command, so no credential is ever rendered into the built string, an argv + * list, or a log — the same ingress rule `mount-script.ts` applies to the + * relayfile token. + */ + apiKeyEnvVar?: string; + /** + * Value recorded as the onboarding version. Optional; omitted, only the + * boolean flag is written. + */ + onboardingVersion?: string; +}; + +/** + * Shell that marks Claude Code's first-run onboarding complete and pre-approves + * the API key the agent will actually use. + * + * **Why the approval half matters.** A freshly spawned agent came up on the + * OAuth sign-in screen while holding a valid key in its environment — a live + * key, verified against the API from inside the same box. The cause was in + * `~/.claude.json`: the key's identifying tail had been recorded under + * `customApiKeyResponses.rejected`, so the CLI declined to use it and fell + * back to interactive login. On a headless box that is a hang, not a prompt. + * + * The identifying tail is the key's last 20 characters, which is what the CLI + * itself stores — never the key. This snippet moves that tail out of + * `rejected` and into `approved`, so a re-run repairs a box that has already + * been poisoned rather than only helping a pristine one. + * + * `hasCompletedOnboarding` is set in the same write because a clean home needs + * both: an approved key still lands on the theme-and-welcome screen without it. + * + * Nothing here is Agent37-specific. It will bite every fresh box on every + * provider. + */ +export function buildClaudeConfigSeedShell(opts: ClaudeConfigSeedShellOptions): string { + const configPath = requireAbsolutePath(opts.configPath, "configPath"); + const apiKeyEnvVar = requireNonEmpty(opts.apiKeyEnvVar ?? "ANTHROPIC_API_KEY", "apiKeyEnvVar"); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(apiKeyEnvVar)) { + throw new Error( + `apiKeyEnvVar must be a valid shell identifier; got ${JSON.stringify(apiKeyEnvVar)}`, + ); + } + const onboardingVersion = opts.onboardingVersion?.trim(); + if (onboardingVersion !== undefined && onboardingVersion.includes("\n")) { + throw new Error("onboardingVersion must not contain a newline"); + } + + // Read/modify/write in one node process so a concurrent bootstrap step + // cannot interleave between the read and the write. + // + // Two subtleties enforced below: + // - `JSON.parse(...)||{}` silently coerces `null`, `false`, `0`, and `""` + // to `{}`, which would let an invalid config be clobbered instead of + // rejected. Let the object-shape validation catch every non-object. + // - `fs.writeFileSync({ mode: 0o600 })` only applies the mode when the + // file is being created; on the repair path the existing perms survive. + // Follow the write with an explicit `chmodSync(0o600)` so the contract + // holds for both first-run and rewrite. + const script = + "const fs=require('fs'),path=require('path');" + + "const p=process.env.__CLAUDE_CONFIG_PATH;" + + "let cfg={};" + + "try{cfg=JSON.parse(fs.readFileSync(p,'utf8'));}catch(e){" + + "if(e&&e.code!=='ENOENT')throw e;}" + + "if(typeof cfg!=='object'||cfg===null||Array.isArray(cfg))" + + "throw new Error('existing Claude config is not a JSON object: '+p);" + + "cfg.hasCompletedOnboarding=true;" + + "const ver=process.env.__CLAUDE_ONBOARDING_VERSION;" + + "if(ver)cfg.lastOnboardingVersion=ver;" + + "const key=process.env.__CLAUDE_API_KEY||'';" + + "let approved=0;" + + "if(key){" + + "const tail=key.slice(-20);" + + "const r=cfg.customApiKeyResponses;" + + "const prev=(r&&typeof r==='object'&&!Array.isArray(r))?r:{};" + + "const keep=(list)=>Array.isArray(list)?list.filter((v)=>typeof v==='string'&&v!==tail):[];" + + "cfg.customApiKeyResponses={approved:keep(prev.approved).concat([tail])," + + "rejected:keep(prev.rejected)};" + + "approved=1;}" + + "fs.mkdirSync(path.dirname(p),{recursive:true});" + + "fs.writeFileSync(p,JSON.stringify(cfg,null,2),{mode:0o600});" + + "fs.chmodSync(p,0o600);" + + "process.stdout.write('claude-config-seeded onboarding=1 apiKeyApproved='+approved+'\\n');"; + + return [ + `set -e`, + `__CLAUDE_CONFIG_PATH=${shellQuote(configPath)} \\`, + ...(onboardingVersion ? [`__CLAUDE_ONBOARDING_VERSION=${shellQuote(onboardingVersion)} \\`] : []), + `__CLAUDE_API_KEY="\${${apiKeyEnvVar}:-}" \\`, + `node -e ${shellQuote(script)}`, + ].join("\n"); +} diff --git a/src/core/index.ts b/src/core/index.ts index 7e1b6b6..57e4762 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -63,3 +63,6 @@ export type { // --- relayfile-mount shell builders ---------------------------------------- export * from "../mount-script.js"; + +// --- sandbox bootstrap shell builders -------------------------------------- +export * from "../bootstrap.js"; diff --git a/src/index.ts b/src/index.ts index dd4cb2d..081d1f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,6 +76,8 @@ export type { export { AGENT37_COMMAND_CAP_MS, + AGENT37_WORKDIR_UNUSABLE_EXIT_CODE, + AGENT37_WORKDIR_UNUSABLE_MARKER, Agent37CommandTimeoutUnsupportedError, Agent37CreateTimeoutUnsupportedError, Agent37EnvValidationError, @@ -83,6 +85,7 @@ export { Agent37MalformedResponseError, Agent37Runtime, Agent37UnknownExitCodeError, + Agent37WorkdirUnusableError, } from "./agent37/runtime.js"; export type { Agent37BundleFile,