From 090f9f690b36039fe76062c130d864ba206d41c2 Mon Sep 17 00:00:00 2001 From: Marco D'Alia Date: Fri, 26 Jun 2026 12:13:22 +0100 Subject: [PATCH 1/2] fix(codex): make agent home dirs vscode-owned so Codex can create state_*.sqlite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We stopped seeding Codex's state_*.sqlite index (commit 2eaf2b428), so Codex now creates it at startup instead of receiving it pre-uploaded. The create failed with a permission error because the directory wasn't owned by vscode (the user the agent runs as). Two distinct ownership defects: 1. The agent home dirs (~/.codex, ~/.claude, ~/.local/share/opencode) were not reliably vscode-owned in cloud templates (E2B's base image ships a `node` user; the root `npm install -g @openai/codex` bake step left ~/.codex as node:node). This breaks even a plain `agentbox codex` start. Fixed with a cheap, idempotent create-time chown (ensureAgentHomeDirsOwned) — no re-bake. 2. The upload primitives only chowned the final landed path, not the parent directory chain they mkdir -p'd as root. Session-teleport lands a rollout at ~/.codex/sessions/YYYY/MM/DD/, leaving that chain root-owned so Codex can't write a new rollout / its sqlite index. Mirror the carry.ts parent-walk fix in both upload primitives (cloud-cp.ts + docker box-cp.ts), gated on the dest being under /home/vscode. Chowns are name/id-derived (vscode / id -un), not hardcoded 1000, since the vscode uid varies per provider (docker/hetzner=1000, vercel=1001, e2b=1002). Claude-Session: https://claude.ai/code/session_0152GmbNW3e7QpXNkQFd3MB2 --- .../sandbox-cloud/src/agent-credentials.ts | 36 +++++++++++++++ packages/sandbox-cloud/src/cloud-cp.ts | 25 +++++++++- packages/sandbox-cloud/src/cloud-provider.ts | 8 ++++ packages/sandbox-cloud/src/index.ts | 1 + packages/sandbox-cloud/test/cloud-cp.test.ts | 46 +++++++++++++++++++ packages/sandbox-docker/src/box-cp.ts | 39 ++++++++++++++++ 6 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 packages/sandbox-cloud/test/cloud-cp.test.ts diff --git a/packages/sandbox-cloud/src/agent-credentials.ts b/packages/sandbox-cloud/src/agent-credentials.ts index 1478fdab..f762a674 100644 --- a/packages/sandbox-cloud/src/agent-credentials.ts +++ b/packages/sandbox-cloud/src/agent-credentials.ts @@ -258,6 +258,42 @@ export async function seedAgentVolumesIfFresh( ); } +/** + * Force the agent static-config home dirs (`~/.codex`, `~/.claude`, + * `~/.local/share/opencode`) to be owned by `vscode` on the live box. + * + * Why this exists: the agent runs as `vscode`, but a cloud base template can + * bake these dirs with the wrong owner (E2B's base image ships a `node` user, + * and the root `npm install -g @openai/codex` step has been observed leaving + * `~/.codex` as `node:node`). Once we stopped seeding Codex's `state_*.sqlite` + * index, Codex *creates* it at startup — which fails with EACCES when the dir + * isn't vscode-writable. A cheap, idempotent chown at create time fixes + * existing prepared templates without forcing a re-bake. + * + * Best-effort: `chown` is rejected on Daytona's S3-backed FUSE volumes, so the + * command tolerates failure. `chown -R` does not dereference symlinks, so the + * baked `~/.codex/auth.json -> ~/.agentbox-creds/codex/auth.json` credential + * symlink (and its peers) are unaffected. + */ +export async function ensureAgentHomeDirsOwned( + backend: CloudBackend, + handle: CloudHandle, + opts: { onLog?: (line: string) => void } = {}, +): Promise { + const log = opts.onLog ?? (() => {}); + const paths = AGENT_SPECS.map((s) => s.staticMountPath).join(' '); + try { + await backend.exec( + handle, + `sudo -n chown -R vscode:vscode ${paths} 2>/dev/null || true`, + ); + } catch (err) { + log( + `agent home-dir ownership normalize failed (continuing): ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + /** * Refresh the host-side credential backups (`~/.agentbox/{claude,codex,opencode}-credentials.json`) * from the live docker shared volumes BEFORE cloud creates seed from them. diff --git a/packages/sandbox-cloud/src/cloud-cp.ts b/packages/sandbox-cloud/src/cloud-cp.ts index 259bdc33..eb28aefd 100644 --- a/packages/sandbox-cloud/src/cloud-cp.ts +++ b/packages/sandbox-cloud/src/cloud-cp.ts @@ -32,6 +32,9 @@ import { bashScript, quoteShellArg } from './shell.js'; const REMOTE_UP_TAR = '/tmp/agentbox-cp-up.tar.gz'; const REMOTE_DOWN_TAR = '/tmp/agentbox-cp-down.tar.gz'; +/** In-box home for the agent user; cloud boxes always run as `vscode`. */ +const BOX_HOME = '/home/vscode'; + export interface CloudCpResult { /** Final landed path on the receiving side. */ finalPath: string; @@ -87,6 +90,23 @@ export async function uploadToCloudBox( finalName !== srcBasename ? `$SUDO cp -f ${quoteShellArg(initialPath)} ${quoteShellArg(finalPath)} && $SUDO rm -f ${quoteShellArg(initialPath)}` : ': # no rename'; + // Parent-chain chown: `mkdir -p` ran as root (via $SUDO), so any new dirs + // between $HOME and the landed path are root-owned. When the dest is under + // the box home, walk back up to $HOME (exclusive) and chown each so the + // agent (vscode) can write siblings — e.g. session-teleport lands a rollout + // under `~/.codex/sessions/YYYY/MM/DD/` and Codex must then create its + // `state_*.sqlite` index in that subtree. System paths (/etc/*) and + // /workspace keep their existing ownership. The whole script runs via + // `bashScript()` (`bash -c ''`), which protects `$(...)`/`while` from + // Vercel's outer `sudo -u vscode -H bash -lc` wrapping. + const underHome = finalPath === BOX_HOME || finalPath.startsWith(BOX_HOME + '/'); + const parentWalk = underHome + ? `parent=$(dirname ${quoteShellArg(finalPath)}); ` + + `while [ "$parent" != ${quoteShellArg(BOX_HOME)} ] && [ "$parent" != "/" ]; do ` + + `$SUDO chown "$(id -un):$(id -gn)" "$parent" || true; ` + + `parent=$(dirname "$parent"); ` + + `done` + : `: # dest outside ${BOX_HOME}; leave parent ownership untouched`; const script = [ `set -euo pipefail`, `if command -v sudo >/dev/null 2>&1; then SUDO='sudo -n'; else SUDO=''; fi`, @@ -97,9 +117,10 @@ export async function uploadToCloudBox( // sandbox's regular disk. Same flags as the credential-seed extract. `$SUDO tar -xzf ${quoteShellArg(REMOTE_UP_TAR)} -C ${quoteShellArg(boxParent)} --no-same-permissions --no-same-owner -m`, renameStep, - // chown only the landed path — anything we mkdir'd through stays at - // its existing ownership. Tolerate failure (chown bad on read-only mounts). + // chown the landed subtree, then the parent chain back up to $HOME. + // Tolerate failure (chown bad on read-only / FUSE mounts). `$SUDO chown -R "$(id -un):$(id -gn)" ${quoteShellArg(finalPath)} || true`, + parentWalk, `rm -f ${quoteShellArg(REMOTE_UP_TAR)}`, ].join('\n'); const r = await backend.exec(handle, bashScript(script)); diff --git a/packages/sandbox-cloud/src/cloud-provider.ts b/packages/sandbox-cloud/src/cloud-provider.ts index 1e27097d..dbe68b2a 100644 --- a/packages/sandbox-cloud/src/cloud-provider.ts +++ b/packages/sandbox-cloud/src/cloud-provider.ts @@ -51,6 +51,7 @@ import { TERM_FALLBACK_SNIPPET, } from '@agentbox/sandbox-docker'; import { + ensureAgentHomeDirsOwned, ensureAgentVolumesForCloud, extractCloudAgentCredentials, refreshAgentCredentialsBackup, @@ -715,6 +716,13 @@ export function createCloudProvider( }); } + // Normalize ownership of the agent static-config home dirs to vscode. + // Runs unconditionally (not gated on a credentials volume): the agent + // runs as vscode and a base template can bake `~/.codex` etc. with the + // wrong owner. Without this, Codex can't create its `state_*.sqlite` + // index at startup (we stopped seeding it). Cheap + idempotent. + await ensureAgentHomeDirsOwned(backend, handle, { onLog: log }); + // Seed the host's selected OpenCode model into the box's (ephemeral) // state dir on every create. Runs unconditionally — Hetzner has no // credentials volume, so it is absent from `agentVolumes.agents` above diff --git a/packages/sandbox-cloud/src/index.ts b/packages/sandbox-cloud/src/index.ts index 7f4b3181..f31920dd 100644 --- a/packages/sandbox-cloud/src/index.ts +++ b/packages/sandbox-cloud/src/index.ts @@ -16,6 +16,7 @@ export { } from './workspace-seed.js'; export { agentSpecsForCloud, + ensureAgentHomeDirsOwned, ensureAgentVolumesForCloud, extractCloudAgentCredentials, seedAgentVolumesIfFresh, diff --git a/packages/sandbox-cloud/test/cloud-cp.test.ts b/packages/sandbox-cloud/test/cloud-cp.test.ts new file mode 100644 index 00000000..dde367b7 --- /dev/null +++ b/packages/sandbox-cloud/test/cloud-cp.test.ts @@ -0,0 +1,46 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { uploadToCloudBox } from '../src/cloud-cp.js'; +import { makeMockCloudBackend } from '../src/mock-backend.js'; + +let workspace: string; + +beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), 'cloud-cp-')); +}); + +/** The bash one-liner handed to backend.exec lives at args[1] of the exec call. */ +async function uploadAndGetCmd(boxDst: string): Promise { + const src = join(workspace, 'rollout.jsonl'); + await writeFile(src, '{}'); + const backend = makeMockCloudBackend(); + const handle = await backend.provision({ name: 'b', image: 'i' }); + await uploadToCloudBox(backend, handle, src, boxDst); + const execCall = backend.calls.find((c) => c.method === 'exec'); + return String(execCall!.args[1]); +} + +describe('uploadToCloudBox parent-chain chown', () => { + it('walks parent dirs up to $HOME when the dest is under /home/vscode', async () => { + const cmd = await uploadAndGetCmd('/home/vscode/.codex/sessions/2026/06/26/'); + // The landed subtree is chowned recursively, then the parent chain. + expect(cmd).toContain( + `chown -R "$(id -un):$(id -gn)" /home/vscode/.codex/sessions/2026/06/26/rollout.jsonl`, + ); + expect(cmd).toContain('while [ "$parent" != /home/vscode ]'); + expect(cmd).toContain('parent=$(dirname /home/vscode/.codex/sessions/2026/06/26/rollout.jsonl)'); + }); + + it('omits the parent walk for system paths (/etc/*)', async () => { + const cmd = await uploadAndGetCmd('/etc/agentbox/'); + expect(cmd).not.toContain('while [ "$parent"'); + expect(cmd).toContain('leave parent ownership untouched'); + }); + + it('omits the parent walk for /workspace paths', async () => { + const cmd = await uploadAndGetCmd('/workspace/sub/'); + expect(cmd).not.toContain('while [ "$parent"'); + }); +}); diff --git a/packages/sandbox-docker/src/box-cp.ts b/packages/sandbox-docker/src/box-cp.ts index 154dadc5..d634cb9f 100644 --- a/packages/sandbox-docker/src/box-cp.ts +++ b/packages/sandbox-docker/src/box-cp.ts @@ -15,6 +15,9 @@ import { basename, dirname, posix, resolve } from 'node:path'; import { execa } from 'execa'; import type { BoxRecord } from '@agentbox/core'; +/** In-box home for the agent user; boxes always run as `vscode` (uid 1000). */ +const BOX_HOME = '/home/vscode'; + function posixDirname(p: string): string { return posix.dirname(p) || '/'; } @@ -166,6 +169,42 @@ export async function uploadToBox( warn: `chown ${finalPath} to vscode (uid 1000) failed; ownership inside the box may be root.`, }; } + + // Parent-chain chown: `mkdir -p` ran as root, so any new dirs between $HOME + // and the landed path are root-owned. When the dest is under the box home, + // walk back up to $HOME (exclusive) and chown each so the agent (vscode) can + // write siblings — e.g. session-teleport lands a rollout under + // `~/.codex/sessions/YYYY/MM/DD/` and Codex must then create its + // `state_*.sqlite` index in that subtree. Non-fatal (matches the warn-only + // policy of the chown above). + if (finalPath === BOX_HOME || finalPath.startsWith(BOX_HOME + '/')) { + const walk = await execa( + 'docker', + [ + 'exec', + '--user', + 'root', + box.container, + 'sh', + '-c', + `parent=$(dirname "$1"); ` + + `while [ "$parent" != "$2" ] && [ "$parent" != "/" ]; do ` + + `chown 1000:1000 "$parent" || true; ` + + `parent=$(dirname "$parent"); ` + + `done`, + 'sh', + finalPath, + BOX_HOME, + ], + { reject: false }, + ); + if (walk.exitCode !== 0) { + return { + finalPath, + warn: `chown of parent dirs under ${BOX_HOME} failed; some may remain root-owned.`, + }; + } + } return { finalPath }; } From ff13b7a55ae03e854c3809a4a46f50a6cbb44c0c Mon Sep 17 00:00:00 2001 From: Marco D'Alia Date: Fri, 26 Jun 2026 12:19:17 +0100 Subject: [PATCH 2/2] fix(codex): don't walk parent chain when upload lands exactly at $HOME Bugbot: when an upload's resolved finalPath was exactly /home/vscode, the `=== BOX_HOME` branch of the gate let the parent walk run with dirname=/home, reassigning /home itself to the agent user. Gate strictly on `startsWith(BOX_HOME + '/')` (a trailing segment), matching carry.ts. Applies to both cloud-cp.ts and docker box-cp.ts; adds a regression test. Claude-Session: https://claude.ai/code/session_0152GmbNW3e7QpXNkQFd3MB2 --- packages/sandbox-cloud/src/cloud-cp.ts | 4 +++- packages/sandbox-cloud/test/cloud-cp.test.ts | 7 +++++++ packages/sandbox-docker/src/box-cp.ts | 4 +++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/sandbox-cloud/src/cloud-cp.ts b/packages/sandbox-cloud/src/cloud-cp.ts index eb28aefd..ddd26e64 100644 --- a/packages/sandbox-cloud/src/cloud-cp.ts +++ b/packages/sandbox-cloud/src/cloud-cp.ts @@ -99,7 +99,9 @@ export async function uploadToCloudBox( // /workspace keep their existing ownership. The whole script runs via // `bashScript()` (`bash -c ''`), which protects `$(...)`/`while` from // Vercel's outer `sudo -u vscode -H bash -lc` wrapping. - const underHome = finalPath === BOX_HOME || finalPath.startsWith(BOX_HOME + '/'); + // Strictly *under* home (trailing segment) — never `=== BOX_HOME`, else + // `dirname` would be `/home` and the walk could reassign `/home` itself. + const underHome = finalPath.startsWith(BOX_HOME + '/'); const parentWalk = underHome ? `parent=$(dirname ${quoteShellArg(finalPath)}); ` + `while [ "$parent" != ${quoteShellArg(BOX_HOME)} ] && [ "$parent" != "/" ]; do ` + diff --git a/packages/sandbox-cloud/test/cloud-cp.test.ts b/packages/sandbox-cloud/test/cloud-cp.test.ts index dde367b7..81857929 100644 --- a/packages/sandbox-cloud/test/cloud-cp.test.ts +++ b/packages/sandbox-cloud/test/cloud-cp.test.ts @@ -43,4 +43,11 @@ describe('uploadToCloudBox parent-chain chown', () => { const cmd = await uploadAndGetCmd('/workspace/sub/'); expect(cmd).not.toContain('while [ "$parent"'); }); + + it('omits the parent walk when the dest lands exactly at $HOME (no /home chown)', async () => { + // boxDst without trailing slash → finalPath === /home/vscode. The walk must + // NOT run, else `dirname` would be `/home` and could reassign it. + const cmd = await uploadAndGetCmd('/home/vscode'); + expect(cmd).not.toContain('while [ "$parent"'); + }); }); diff --git a/packages/sandbox-docker/src/box-cp.ts b/packages/sandbox-docker/src/box-cp.ts index d634cb9f..5103edce 100644 --- a/packages/sandbox-docker/src/box-cp.ts +++ b/packages/sandbox-docker/src/box-cp.ts @@ -177,7 +177,9 @@ export async function uploadToBox( // `~/.codex/sessions/YYYY/MM/DD/` and Codex must then create its // `state_*.sqlite` index in that subtree. Non-fatal (matches the warn-only // policy of the chown above). - if (finalPath === BOX_HOME || finalPath.startsWith(BOX_HOME + '/')) { + // Strictly *under* home (trailing segment) — never `=== BOX_HOME`, else the + // walk's `dirname` would be `/home` and could reassign `/home` itself. + if (finalPath.startsWith(BOX_HOME + '/')) { const walk = await execa( 'docker', [