Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/sandbox-cloud/src/agent-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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.
Expand Down
27 changes: 25 additions & 2 deletions packages/sandbox-cloud/src/cloud-cp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -87,6 +90,25 @@ 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 '<body>'`), which protects `$(...)`/`while` from
// Vercel's outer `sudo -u vscode -H bash -lc` wrapping.
// 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 ` +
`$SUDO chown "$(id -un):$(id -gn)" "$parent" || true; ` +
`parent=$(dirname "$parent"); ` +
`done`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parent walk chowns /home

Medium Severity

When an upload’s resolved finalPath is exactly /home/vscode, the new parent-chain chown treats that as under home, sets parent to /home, and the loop condition only excludes /home/vscode, so /home itself can be reassigned to the agent user. The existing carry path avoids this by requiring destinations under BOX_HOME/ with a trailing segment, not equality with BOX_HOME.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 090f9f6. Configure here.

: `: # 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`,
Expand All @@ -97,9 +119,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));
Expand Down
8 changes: 8 additions & 0 deletions packages/sandbox-cloud/src/cloud-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
TERM_FALLBACK_SNIPPET,
} from '@agentbox/sandbox-docker';
import {
ensureAgentHomeDirsOwned,
ensureAgentVolumesForCloud,
extractCloudAgentCredentials,
refreshAgentCredentialsBackup,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/sandbox-cloud/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
} from './workspace-seed.js';
export {
agentSpecsForCloud,
ensureAgentHomeDirsOwned,
ensureAgentVolumesForCloud,
extractCloudAgentCredentials,
seedAgentVolumesIfFresh,
Expand Down
53 changes: 53 additions & 0 deletions packages/sandbox-cloud/test/cloud-cp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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<string> {
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"');
});

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"');
});
});
41 changes: 41 additions & 0 deletions packages/sandbox-docker/src/box-cp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) || '/';
}
Expand Down Expand Up @@ -166,6 +169,44 @@ 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).
// 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',
[
'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 };
}

Expand Down
Loading