diff --git a/packages/plugins/sandbox-providers/daytona/README.md b/packages/plugins/sandbox-providers/daytona/README.md index 84cfac9ad9..aee264d183 100644 --- a/packages/plugins/sandbox-providers/daytona/README.md +++ b/packages/plugins/sandbox-providers/daytona/README.md @@ -35,10 +35,10 @@ The driver wraps a sandbox command with an advisory bubblewrap (`bwrap`) wrapper - **The wrapper adds no security.** The ephemeral sandbox stays the only security posture. The wrapper only gives an agent real-time feedback when the agent tries to change a file that the ephemeral sandbox will not keep. - **The read-only root is a feedback signal.** The wrapper binds the root as read-only (`--ro-bind / /`) and re-binds only the writable directories. A write to a path outside the writable set fails at once, so the agent learns the change is not durable. -- **A capability probe records the wrapper capability.** No configuration field turns it on. At lease time the driver probes the sandbox for the end-to-end `bwrap` capability (`sudo -n bwrap` with a user namespace) and reads the sandbox user's uid and gid. It stores `bwrapAvailable`, `sandboxUid`, and `sandboxGid` on the lease metadata. -- **The probe is best-effort.** A missing `bwrap` binary, a missing passwordless `sudo -n` rule, or a missing user namespace records `bwrapAvailable: false` and never fails the lease. +- **A capability probe records the wrapper capability.** No configuration field turns it on. At lease time the driver reads the sandbox username with `id -un`, then probes the end-to-end `bwrap` capability by running `sudo -n bwrap` with a workspace bind and an `su` user switch. It stores `bwrapAvailable` and `sandboxUsername` on the lease metadata. +- **The probe is best-effort.** A missing `bwrap` binary, a missing passwordless `sudo -n` rule, a missing `su` binary, or an inaccessible workspace bind records `bwrapAvailable: false` and never fails the lease. - **The writable set is the workspace plus the read-write sync destinations.** The wrapper binds the workspace directory read-write as the baseline; the workspace is always durable. It adds the read-write sync destinations that a sync-in recorded for the same lease. The set deduplicates the directories. The baseline keeps a safe result even when the collected set is empty. -- **The wrapper runs at execute time when the capability is present.** The driver wraps the command only when the lease reports `bwrapAvailable: true` and a uid/gid pair is known. It binds the workspace and the read-write sync destinations, keeps the root read-only for feedback, and re-binds the stdin file after the fresh `/tmp`. It runs the plain command when the capability or the uid/gid is missing. A wrap without a uid/gid would run as root and give the agent's files root ownership, so the driver keeps the plain command in that case. +- **The wrapper runs at execute time when the capability is present.** The driver wraps the command only when the lease reports `bwrapAvailable: true` and a username is known. It binds the workspace and the read-write sync destinations, keeps the root read-only for feedback, and re-binds the stdin file after the fresh `/tmp`. It runs the plain command when the capability or the username is missing. A wrap without a username would run as root and give the agent's files root ownership, so the driver keeps the plain command in that case. ## Operator enablement (advisory bwrap) @@ -86,46 +86,27 @@ Use the real sandbox user name and the real `bwrap` path: The `` is the account name that `id -un` returns inside the sandbox. Use the account name, not a numeric id, in the sudoers line. The probe -reads the numeric user id and group id with `id -u` and `id -g`. The driver -resolves the sandbox work directory first, then the user home directory. It uses -`/home/daytona` only as a fallback default when both are empty. Confirm the real -home directory for your image or snapshot. Install the `sudo` package in the -image or snapshot if it is absent. +reads the username with `id -un`. The driver resolves the sandbox work directory +first, then the user home directory. It uses `/home/daytona` only as a fallback +default when both are empty. Confirm the real home directory for your image or +snapshot. Install the `sudo` and `util-linux` (for `su`) packages in the image +or snapshot if they are absent. -### 3. Allow user namespaces +### 3. Verify the prerequisites -The `bwrap` wrapper creates a user namespace. The kernel must support user -namespaces for the wrapper to run. Confirm the kernel allows them: +Run this exact command as the sandbox user, replacing `` and +`` with real values: ```bash -sysctl user.max_user_namespaces +sudo -n bwrap --ro-bind / / --bind-try -- su -s /bin/sh -c true ``` -A value greater than zero means the kernel supports user namespaces. This value -is the requirement for the wrapper. +A zero exit code means all prerequisites are met. A non-zero exit code means one +prerequisite is missing. The wrapper then stays off and runs the plain command. -The wrapper runs `bwrap` as root with `sudo -n`. Root creates the user namespace -directly, so the Debian/Ubuntu `kernel.unprivileged_userns_clone` setting does -not apply here. That setting only limits an unprivileged process. A managed -sandbox that denies `sysctl kernel.unprivileged_userns_clone=1` still runs the -wrapper when `user.max_user_namespaces` is greater than zero. - -### 4. Verify the three prerequisites - -Run this exact command as the sandbox user: - -```bash -sudo -n bwrap --unshare-user --uid 0 --gid 0 --ro-bind / / -- true -``` - -A zero exit code means all three prerequisites are met. A non-zero exit code -means one prerequisite is missing. The wrapper then stays off and runs the plain -command. - -The `--uid 0` and `--gid 0` flags map the check to root inside the test -namespace. This command is only a capability check. It does not need to match -the sandbox user id. The live wrapper maps to the real sandbox user id and group -id. +The probe binds the workspace directory and switches to the sandbox user with +`su`. This matches the exact invocation the live wrapper uses, so a passing probe +guarantees that execution commands will also succeed. ## Local development diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index 5cbc090ce2..8b726e1949 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -1233,7 +1233,7 @@ describe("Daytona sandbox provider plugin", () => { }); }); - it("wraps the command when the lease reports bwrap available and uid/gid known", async () => { + it("wraps the command when the lease reports bwrap available and username known", async () => { process.env.DAYTONA_API_KEY = "host-key"; const sandbox = createMockSandbox(); mockGet.mockResolvedValue(sandbox); @@ -1248,8 +1248,7 @@ describe("Daytona sandbox provider plugin", () => { metadata: { remoteCwd: "/home/daytona/paperclip-workspace", bwrapAvailable: true, - sandboxUid: 1000, - sandboxGid: 1000, + sandboxUsername: "daytona", }, }, command: "printf", @@ -1258,14 +1257,14 @@ describe("Daytona sandbox provider plugin", () => { }); const [command] = sandbox.process.executeCommand.mock.calls[0] as [string]; - // The wrapper runs `sudo -n bwrap`, re-enters the sandbox user through the - // user namespace, and binds the workspace directory read-write. + // The wrapper runs `sudo -n bwrap`, drops to the sandbox user via su, and + // binds the workspace directory read-write. expect(command.startsWith("sudo -n bwrap")).toBe(true); - expect(command).toContain("--unshare-user --uid 1000 --gid 1000"); + expect(command).toContain("su -s /bin/sh 'daytona'"); expect(command).toContain( "--bind-try '/home/daytona/paperclip-workspace' '/home/daytona/paperclip-workspace'", ); - // The login-shell script still rides inside the wrapper through `sh -c`. + // The login-shell script still rides inside the wrapper through `su -c`. expect(command).toContain("/etc/profile"); }); @@ -1311,7 +1310,7 @@ describe("Daytona sandbox provider plugin", () => { ...scopeParams, lease: { providerLeaseId: "sandbox-123", - metadata: { remoteCwd, bwrapAvailable: true, sandboxUid: 1000, sandboxGid: 1000 }, + metadata: { remoteCwd, bwrapAvailable: true, sandboxUsername: "daytona" }, }, command: "printf", args: ["hello"], @@ -1369,7 +1368,7 @@ describe("Daytona sandbox provider plugin", () => { ...scopeParams, lease: { providerLeaseId: "sandbox-123", - metadata: { remoteCwd, bwrapAvailable: true, sandboxUid: 1000, sandboxGid: 1000 }, + metadata: { remoteCwd, bwrapAvailable: true, sandboxUsername: "daytona" }, }, command: "printf", args: ["hello"], @@ -1400,8 +1399,7 @@ describe("Daytona sandbox provider plugin", () => { metadata: { remoteCwd: "/home/daytona/paperclip-workspace", bwrapAvailable: true, - sandboxUid: 1000, - sandboxGid: 1000, + sandboxUsername: "daytona", }, }, command: "cat", @@ -1418,7 +1416,7 @@ describe("Daytona sandbox provider plugin", () => { expect(command).toContain(`--ro-bind '${stdinPath}' '${stdinPath}'`); }); - it("runs the plain command when bwrap is unavailable or uid/gid is unknown", async () => { + it("runs the plain command when bwrap is unavailable or username is unknown", async () => { process.env.DAYTONA_API_KEY = "host-key"; const sandbox = createMockSandbox(); mockGet.mockResolvedValue(sandbox); @@ -1434,8 +1432,7 @@ describe("Daytona sandbox provider plugin", () => { metadata: { remoteCwd: "/home/daytona/paperclip-workspace", bwrapAvailable: false, - sandboxUid: 1000, - sandboxGid: 1000, + sandboxUsername: "daytona", }, }, command: "printf", @@ -1444,8 +1441,9 @@ describe("Daytona sandbox provider plugin", () => { }); expect((sandbox.process.executeCommand.mock.calls[0] as [string])[0]).not.toContain("bwrap"); - // Case 2: bwrap is available but the uid/gid is unknown. A wrap without a - // uid/gid would run as root, so the seam keeps the plain command. + // Case 2: bwrap is available but the username is unknown. A wrap without a + // username would run as root inside bwrap and give the agent's files root + // ownership, so the seam keeps the plain command. sandbox.process.executeCommand.mockClear(); await plugin.definition.onEnvironmentExecute?.({ driverKey: "daytona", @@ -1457,8 +1455,7 @@ describe("Daytona sandbox provider plugin", () => { metadata: { remoteCwd: "/home/daytona/paperclip-workspace", bwrapAvailable: true, - sandboxUid: null, - sandboxGid: null, + sandboxUsername: null, }, }, command: "printf", @@ -3749,20 +3746,23 @@ describe("daytona manifest memory config", () => { }); describe("buildBwrapCommand advisory wrapper builder", () => { - it("emits user-namespace, ro-bind root, fresh dev/proc/tmp, writable binds, new-session, and sh -c", () => { + it("uses su to drop to sandbox user without --unshare-user", () => { const command = buildBwrapCommand( "echo hi", ["/home/daytona/paperclip-workspace"], null, - { uid: 1000, gid: 1000 }, + "daytona", ); expect(command).toBe( - "sudo -n bwrap --unshare-user --uid 1000 --gid 1000 " + "sudo -n bwrap " + "--ro-bind / / --dev /dev --proc /proc --tmpfs /tmp " + "--bind-try '/home/daytona/paperclip-workspace' '/home/daytona/paperclip-workspace' " - + "--new-session -- sh -c 'echo hi'", + + "--new-session -- su -s /bin/sh 'daytona' -c 'echo hi'", ); + expect(command).not.toContain("--unshare-user"); + expect(command).not.toContain("--uid"); + expect(command).not.toContain("--gid"); }); it("re-binds the stdin path after tmpfs /tmp", () => { @@ -3770,15 +3770,15 @@ describe("buildBwrapCommand advisory wrapper builder", () => { "run-cmd", ["/work"], "/tmp/stdin.bin", - { uid: 1000, gid: 1000 }, + "daytona", ); expect(command).toBe( - "sudo -n bwrap --unshare-user --uid 1000 --gid 1000 " + "sudo -n bwrap " + "--ro-bind / / --dev /dev --proc /proc --tmpfs /tmp " + "--bind-try '/work' '/work' " + "--ro-bind '/tmp/stdin.bin' '/tmp/stdin.bin' " - + "--new-session -- sh -c 'run-cmd'", + + "--new-session -- su -s /bin/sh 'daytona' -c 'run-cmd'", ); // The stdin re-bind must come after the tmpfs, so the tmpfs does not hide it. expect(command.indexOf("--ro-bind '/tmp/stdin.bin'")).toBeGreaterThan(command.indexOf("--tmpfs /tmp")); @@ -3789,16 +3789,16 @@ describe("buildBwrapCommand advisory wrapper builder", () => { "echo 'hello'", ["/data/o'brien"], null, - { uid: 1000, gid: 1000 }, + "daytona", ); // shellQuote rewrites each embedded single quote as the `'"'"'` token. expect(command).toContain(`'"'"'`); expect(command).toContain(`--bind-try '/data/o'"'"'brien' '/data/o'"'"'brien'`); - expect(command).toContain(`-- sh -c 'echo '"'"'hello'"'"''`); + expect(command).toContain(`-c 'echo '"'"'hello'"'"''`); }); - it("omits the stdin re-bind when no stdin path is given and omits user-namespace flags when no uid/gid is given", () => { + it("falls back to sh -c when no username is given", () => { const command = buildBwrapCommand("plain", ["/w"], null, null); expect(command).toBe( @@ -3808,8 +3808,7 @@ describe("buildBwrapCommand advisory wrapper builder", () => { + "--new-session -- sh -c 'plain'", ); expect(command).not.toContain("--unshare-user"); - expect(command).not.toContain("--uid"); - expect(command).not.toContain("--gid"); + expect(command).not.toContain("su -s"); }); it("binds each writable directory with --bind-try so a stale or deleted path does not abort bwrap", () => { @@ -3822,7 +3821,7 @@ describe("buildBwrapCommand advisory wrapper builder", () => { "echo hi", ["/home/daytona/paperclip-workspace", "/home/daytona/data"], null, - { uid: 1000, gid: 1000 }, + "daytona", ); expect(command).toContain( @@ -3837,13 +3836,11 @@ describe("buildBwrapCommand advisory wrapper builder", () => { describe("advisory bwrap capability probe at lease time", () => { // Route each probed command to a deterministic result so the hook exercises - // the real end-to-end path: shell detect, bwrap capability, and uid/gid read. + // the real end-to-end path: shell detect, bwrap capability, and username read. function bwrapExecMock(opts: { bwrapExit?: number; - uid?: string; - gid?: string; - uidExit?: number; - gidExit?: number; + username?: string; + usernameExit?: number; sentinelToken?: string; } = {}) { return async (command: string) => { @@ -3857,13 +3854,9 @@ describe("advisory bwrap capability probe at lease time", () => { if (command.startsWith("sudo -n bwrap")) { return { exitCode: opts.bwrapExit ?? 0, result: "", artifacts: { stdout: "" } }; } - if (command === "id -u") { - const uid = opts.uid ?? "1000"; - return { exitCode: opts.uidExit ?? 0, result: uid, artifacts: { stdout: uid } }; - } - if (command === "id -g") { - const gid = opts.gid ?? "1000"; - return { exitCode: opts.gidExit ?? 0, result: gid, artifacts: { stdout: gid } }; + if (command === "id -un") { + const username = opts.username ?? "daytona"; + return { exitCode: opts.usernameExit ?? 0, result: username, artifacts: { stdout: username } }; } return { exitCode: 0, result: "", artifacts: { stdout: "" } }; }; @@ -3880,10 +3873,10 @@ describe("advisory bwrap capability probe at lease time", () => { config: { image: "node:20", timeoutMs: 300000, reuseLease: true }, }; - it("records bwrap available and reads uid/gid when the probe exits zero", async () => { + it("records bwrap available and reads username when the probe exits zero", async () => { process.env.DAYTONA_API_KEY = "host-key"; const sandbox = createMockSandbox(); - sandbox.process.executeCommand.mockImplementation(bwrapExecMock({ bwrapExit: 0, uid: "1000", gid: "1001" })); + sandbox.process.executeCommand.mockImplementation(bwrapExecMock({ bwrapExit: 0, username: "daytona" })); mockCreate.mockResolvedValue(sandbox); const lease = await plugin.definition.onEnvironmentAcquireLease?.(acquireParams); @@ -3891,8 +3884,7 @@ describe("advisory bwrap capability probe at lease time", () => { expect(lease).toMatchObject({ metadata: { bwrapAvailable: true, - sandboxUid: 1000, - sandboxGid: 1001, + sandboxUsername: "daytona", }, }); }); @@ -3900,14 +3892,14 @@ describe("advisory bwrap capability probe at lease time", () => { it("bounds the probe timeout well under the hook deadline so the hook returns fallback metadata", async () => { process.env.DAYTONA_API_KEY = "host-key"; const sandbox = createMockSandbox(); - sandbox.process.executeCommand.mockImplementation(bwrapExecMock({ bwrapExit: 0, uid: "1000", gid: "1000" })); + sandbox.process.executeCommand.mockImplementation(bwrapExecMock({ bwrapExit: 0, username: "daytona" })); mockCreate.mockResolvedValue(sandbox); // The hook deadline is 300 s; the probe must cap far below it. await plugin.definition.onEnvironmentAcquireLease?.(acquireParams); const probeCalls = sandbox.process.executeCommand.mock.calls.filter( - ([command]: [string]) => command === "id -u" || command === "id -g" || command.startsWith("sudo -n bwrap"), + ([command]: [string]) => command === "id -un" || command.startsWith("sudo -n bwrap"), ); expect(probeCalls.length).toBeGreaterThan(0); for (const call of probeCalls) { @@ -3919,7 +3911,7 @@ describe("advisory bwrap capability probe at lease time", () => { it("records bwrap unavailable when the capability probe exits non-zero", async () => { process.env.DAYTONA_API_KEY = "host-key"; const sandbox = createMockSandbox(); - sandbox.process.executeCommand.mockImplementation(bwrapExecMock({ bwrapExit: 1, uid: "1000", gid: "1000" })); + sandbox.process.executeCommand.mockImplementation(bwrapExecMock({ bwrapExit: 1, username: "daytona" })); mockCreate.mockResolvedValue(sandbox); const lease = await plugin.definition.onEnvironmentAcquireLease?.(acquireParams); @@ -3937,15 +3929,14 @@ describe("advisory bwrap capability probe at lease time", () => { expect(lease?.metadata).toMatchObject({ bwrapAvailable: false, - sandboxUid: null, - sandboxGid: null, + sandboxUsername: null, }); }); it("runs the probe on the environment probe hook", async () => { process.env.DAYTONA_API_KEY = "host-key"; const sandbox = createMockSandbox(); - sandbox.process.executeCommand.mockImplementation(bwrapExecMock({ bwrapExit: 0, uid: "1000", gid: "1000" })); + sandbox.process.executeCommand.mockImplementation(bwrapExecMock({ bwrapExit: 0, username: "daytona" })); mockCreate.mockResolvedValue(sandbox); const result = await plugin.definition.onEnvironmentProbe?.({ @@ -3957,14 +3948,14 @@ describe("advisory bwrap capability probe at lease time", () => { expect(result).toMatchObject({ ok: true, - metadata: { bwrapAvailable: true, sandboxUid: 1000, sandboxGid: 1000 }, + metadata: { bwrapAvailable: true, sandboxUsername: "daytona" }, }); }); it("runs the probe on the resume-lease hook", async () => { process.env.DAYTONA_API_KEY = "host-key"; const sandbox = createMockSandbox({ id: "sandbox-reuse", state: "stopped" }); - sandbox.process.executeCommand.mockImplementation(bwrapExecMock({ bwrapExit: 0, uid: "1000", gid: "1000" })); + sandbox.process.executeCommand.mockImplementation(bwrapExecMock({ bwrapExit: 0, username: "daytona" })); mockGet.mockResolvedValue(sandbox); const lease = await plugin.definition.onEnvironmentResumeLease?.({ @@ -3984,7 +3975,7 @@ describe("advisory bwrap capability probe at lease time", () => { expect(lease).toMatchObject({ providerLeaseId: "sandbox-reuse", - metadata: { bwrapAvailable: true, sandboxUid: 1000, sandboxGid: 1000 }, + metadata: { bwrapAvailable: true, sandboxUsername: "daytona" }, }); }); }); diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index cb2778c74b..6efa06ed58 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -415,18 +415,47 @@ function parseProbeInteger(value: string | undefined | null): number | null { return Number.isInteger(parsed) ? parsed : null; } -// Best-effort probe for the advisory bwrap capability. The probe tests the real -// end-to-end capability, not only the binary. One command exercises the binary, -// the passwordless `sudo -n` rule, and the user namespace together, because the -// advisory wrapper needs all three. A zero exit code means the capability is -// present. A non-zero exit code (a missing binary, a missing `sudo -n` rule, or -// a kernel that blocks the user namespace) or a thrown error means the -// capability is absent. The probe never throws. It records the result, and the -// caller runs the command unwrapped when the capability is absent. -async function detectBwrapAvailable(sandbox: Sandbox, timeoutSeconds: number): Promise { +// Best-effort probe for the sandbox user's username. It runs `id -un` as the +// normal sandbox user (no `sudo`). The username is an image fact, not a code +// fact, so the probe is the only source of truth; the wrapper never assumes a +// hardcoded username. A non-zero exit code, an empty output, or a thrown error +// records no username. The probe never throws. +async function detectSandboxUsername( + sandbox: Sandbox, + timeoutSeconds: number, +): Promise { try { + const result = await sandbox.process.executeCommand("id -un", undefined, undefined, timeoutSeconds); + if (result.exitCode !== 0) return null; + const username = result.result?.trim() ?? ""; + return username.length > 0 ? username : null; + } catch { + return null; + } +} + +// Best-effort probe for the advisory bwrap capability. The probe tests the real +// end-to-end capability using the su-based approach, not the old user-namespace +// approach. One command exercises the binary, the passwordless `sudo -n` rule, +// and the su user-switch together. The probe optionally binds the workspace +// directory (`--bind-try` suppresses ENOENT but not EACCES; on some Daytona +// images the home dir is `drwx------`, so including the workspace bind here +// catches that). A zero exit code means the capability is present. A non-zero +// exit code or a thrown error means the capability is absent. The probe never +// throws. It records the result, and the caller runs the command unwrapped when +// the capability is absent. +async function detectBwrapAvailable( + sandbox: Sandbox, + timeoutSeconds: number, + username: string, + remoteCwd?: string, +): Promise { + try { + const workspaceBind = remoteCwd + ? ` --bind-try ${shellQuote(remoteCwd)} ${shellQuote(remoteCwd)}` + : ""; const result = await sandbox.process.executeCommand( - "sudo -n bwrap --unshare-user --uid 0 --gid 0 --ro-bind / / -- true", + `sudo -n bwrap --ro-bind / /${workspaceBind} -- su -s /bin/sh ${shellQuote(username)} -c true`, undefined, undefined, timeoutSeconds, @@ -437,50 +466,22 @@ async function detectBwrapAvailable(sandbox: Sandbox, timeoutSeconds: number): P } } -// Best-effort probe for the sandbox user's uid and gid. It runs `id -u` and -// `id -g` as the normal sandbox user (no `sudo`). The uid and gid are image -// facts, not code facts, so the probe is the only source of truth; the wrapper -// never assumes a hardcoded pair. A non-zero exit code, a non-integer output, or -// a thrown error records no identity. The probe never throws. -async function detectSandboxUidGid( - sandbox: Sandbox, - timeoutSeconds: number, -): Promise<{ uid: number; gid: number } | null> { - try { - const uidResult = await sandbox.process.executeCommand("id -u", undefined, undefined, timeoutSeconds); - const gidResult = await sandbox.process.executeCommand("id -g", undefined, undefined, timeoutSeconds); - if (uidResult.exitCode !== 0 || gidResult.exitCode !== 0) { - return null; - } - const uid = parseProbeInteger(uidResult.result); - const gid = parseProbeInteger(gidResult.result); - if (uid === null || gid === null) { - return null; - } - return { uid, gid }; - } catch { - return null; - } -} - -// Run both advisory bwrap probes and combine them into the lease-metadata -// fields. The uid/gid probe is the ground-truth read; the wrapper relies on the -// probed pair and never a hardcoded default. A missing identity marks the -// wrapper unavailable, so the caller runs the command unwrapped. Neither probe -// fails the lease. +// Run advisory bwrap probes sequentially (username first, then the bwrap probe +// which needs the username to construct the su command). The username is the +// ground-truth read; the wrapper relies on the probed name and never a +// hardcoded default. A missing username short-circuits and returns unavailable, +// so the caller runs the command unwrapped. Neither probe fails the lease. async function detectBwrapCapability( sandbox: Sandbox, timeoutSeconds: number, -): Promise<{ bwrapAvailable: boolean; sandboxUid: number | null; sandboxGid: number | null }> { - const [capable, identity] = await Promise.all([ - detectBwrapAvailable(sandbox, timeoutSeconds), - detectSandboxUidGid(sandbox, timeoutSeconds), - ]); - return { - bwrapAvailable: capable && identity !== null, - sandboxUid: identity?.uid ?? null, - sandboxGid: identity?.gid ?? null, - }; + remoteCwd?: string, +): Promise<{ bwrapAvailable: boolean; sandboxUsername: string | null }> { + const username = await detectSandboxUsername(sandbox, timeoutSeconds); + if (username === null) { + return { bwrapAvailable: false, sandboxUsername: null }; + } + const bwrapAvailable = await detectBwrapAvailable(sandbox, timeoutSeconds, username, remoteCwd); + return { bwrapAvailable, sandboxUsername: username }; } function workspaceSentinelToken(input: { @@ -591,8 +592,7 @@ function leaseMetadata(input: { sandbox: Sandbox; shellCommand: "bash" | "sh"; bwrapAvailable: boolean; - sandboxUid: number | null; - sandboxGid: number | null; + sandboxUsername: string | null; remoteCwd: string; resumedLease: boolean; workspaceSentinel?: WorkspaceSentinelResult; @@ -603,8 +603,7 @@ function leaseMetadata(input: { // Advisory bwrap capability probed at lease time. `bwrapAvailable` false // runs the command unwrapped; it never fails the lease. bwrapAvailable: input.bwrapAvailable, - sandboxUid: input.sandboxUid, - sandboxGid: input.sandboxGid, + sandboxUsername: input.sandboxUsername, sandboxId: input.sandbox.id, sandboxName: input.sandbox.name, sandboxState: input.sandbox.state ?? null, @@ -646,16 +645,19 @@ function shellQuote(value: string): string { // filesystem operation over the same path wins. So the writable `--bind` flags // and the stdin re-bind must come after the read-only root and the fresh // pseudo-filesystems. The function emits the flags in this fixed order: -// 1. `--unshare-user --uid --gid ` when a uid/gid pair is supplied. -// 2. `--ro-bind / /` (read-only root — the static system allowance base). -// 3. `--dev /dev --proc /proc --tmpfs /tmp` (fresh pseudo-filesystems). -// 4. one `--bind-try ` per writable directory, in the caller's order. -// 5. `--ro-bind ` when a stdin path is supplied. -// 6. `--new-session`. -// 7. `-- sh -c ''`. -// `--uid`/`--gid` require `--unshare-user`, so the function emits the three -// flags only together. `sudo -n bwrap` runs as root; the user namespace -// re-enters the sandbox as the normal sandbox user. +// 1. `--ro-bind / /` (read-only root — the static system allowance base). +// 2. `--dev /dev --proc /proc --tmpfs /tmp` (fresh pseudo-filesystems). +// 3. one `--bind-try ` per writable directory, in the caller's order. +// 4. `--ro-bind ` when a stdin path is supplied. +// 5. `--new-session`. +// 6. `-- su -s /bin/sh '' -c ''` when a +// username is supplied, or `-- sh -c ''` otherwise. +// +// `sudo -n bwrap` runs as real root (for bind-mount capability). `su` then +// drops into the sandbox user, so inside uid= maps to outside uid= +// and workspace files owned by that uid are writable. The old `--unshare-user +// --uid`/`--gid` approach created a uid_map that made workspace files appear as +// overflow uid 65534 (nobody) from inside the namespace, causing EACCES. // // The writable binds use `--bind-try`, not `--bind`. The writable set is an // advisory in-memory collection of sandbox paths. The host cannot check whether @@ -667,21 +669,19 @@ export function buildBwrapCommand( innerScript: string, writableDirs: string[], stdinPath: string | null, - identity: { uid: number; gid: number } | null, + username: string | null, ): string { - const identityFlags = identity - ? ["--unshare-user", "--uid", String(identity.uid), "--gid", String(identity.gid)] - : []; const rootBinds = ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp"]; const writableBinds = writableDirs.flatMap((dir) => ["--bind-try", shellQuote(dir), shellQuote(dir)]); // Re-bind the stdin file after `--tmpfs /tmp`, so the tmpfs does not hide it. const stdinReBind = stdinPath ? ["--ro-bind", shellQuote(stdinPath), shellQuote(stdinPath)] : []; - const tail = ["--new-session", "--", "sh", "-c", shellQuote(innerScript)]; + const tail = username + ? ["--new-session", "--", "su", "-s", "/bin/sh", shellQuote(username), "-c", shellQuote(innerScript)] + : ["--new-session", "--", "sh", "-c", shellQuote(innerScript)]; return [ "sudo", "-n", "bwrap", - ...identityFlags, ...rootBinds, ...writableBinds, ...stdinReBind, @@ -1407,34 +1407,34 @@ function evictSandboxHandle(scope: SandboxScope): void { // login-shell string with `buildBwrapCommand`. When null, it runs the plain // string, which keeps today's behavior. `writableDirs` holds the workspace // directory (baseline, always read-write) plus the collected read-write sync -// destinations. `identity` carries the sandbox uid/gid for the user namespace. +// destinations. `username` is the sandbox user to su into inside bwrap. type BwrapExecPlan = { writableDirs: string[]; - identity: { uid: number; gid: number }; + username: string; }; // Decide whether the advisory bwrap wrapper runs for one exec. The wrapper runs -// only when the lease reports bwrap available, a uid/gid pair is known, and the -// workspace directory is known. A wrap without a uid/gid would run as root and -// give the agent's files root ownership, so this returns null (run the plain -// command) in that case. The writable set is the workspace directory (baseline, -// always read-write) plus the per-scope read-write sync destinations. The -// baseline guarantees a safe result even when the collected store is cold. +// only when the lease reports bwrap available, a username is known, and the +// workspace directory is known. A wrap without a username would run as root +// inside bwrap and give the agent's files root ownership, so this returns null +// (run the plain command) in that case. The writable set is the workspace +// directory (baseline, always read-write) plus the per-scope read-write sync +// destinations. The baseline guarantees a safe result even when the collected +// store is cold. function resolveBwrapExecPlan( metadata: Record | null | undefined, scope: SandboxScope, ): BwrapExecPlan | null { if (metadata?.bwrapAvailable !== true) return null; - const uid = metadata.sandboxUid; - const gid = metadata.sandboxGid; - if (typeof uid !== "number" || typeof gid !== "number") return null; + const username = typeof metadata.sandboxUsername === "string" ? metadata.sandboxUsername.trim() : ""; + if (username.length === 0) return null; const remoteCwd = typeof metadata.remoteCwd === "string" ? metadata.remoteCwd.trim() : ""; if (remoteCwd.length === 0) return null; const writableDirs = new Set([remoteCwd]); for (const dir of sandboxHandleWritableDirs.get(scope)) { writableDirs.add(dir); } - return { writableDirs: [...writableDirs], identity: { uid, gid } }; + return { writableDirs: [...writableDirs], username }; } // One-shot command execution via Daytona's `process.executeCommand`. The @@ -1481,13 +1481,13 @@ async function executeOneShot( }); // Advisory bwrap wrapper (best-effort, automatic, no security boundary). When - // the lease reports bwrap available and a uid/gid is known, wrap the + // the lease reports bwrap available and a username is known, wrap the // login-shell string so a write to a non-persistent path fails and the agent // gets real-time feedback. The writable set binds the workspace and the // read-write sync destinations; the stdin re-bind survives the `--tmpfs /tmp`. // When the plan is null, run the plain string, which keeps today's behavior. const command = bwrap - ? buildBwrapCommand(loginScript, bwrap.writableDirs, stdinPath, bwrap.identity) + ? buildBwrapCommand(loginScript, bwrap.writableDirs, stdinPath, bwrap.username) : loginScript; // Pass cwd undefined: `buildLoginShellScript` already injects the `cd` after @@ -1613,7 +1613,7 @@ const plugin = definePlugin({ try { const remoteCwd = await resolveSandboxWorkingDirectory(sandbox); const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs)); - const bwrapCapability = await detectBwrapCapability(sandbox, toBwrapProbeTimeoutSeconds(config)); + const bwrapCapability = await detectBwrapCapability(sandbox, toBwrapProbeTimeoutSeconds(config), remoteCwd); return { ok: true, summary: `Connected to Daytona sandbox ${sandbox.name}.`, @@ -1621,8 +1621,7 @@ const plugin = definePlugin({ provider: "daytona", shellCommand, bwrapAvailable: bwrapCapability.bwrapAvailable, - sandboxUid: bwrapCapability.sandboxUid, - sandboxGid: bwrapCapability.sandboxGid, + sandboxUsername: bwrapCapability.sandboxUsername, sandboxId: sandbox.id, sandboxName: sandbox.name, target: sandbox.target, @@ -1660,7 +1659,7 @@ const plugin = definePlugin({ try { const remoteCwd = await resolveSandboxWorkingDirectory(sandbox); const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs)); - const bwrapCapability = await detectBwrapCapability(sandbox, toBwrapProbeTimeoutSeconds(config)); + const bwrapCapability = await detectBwrapCapability(sandbox, toBwrapProbeTimeoutSeconds(config), remoteCwd); const workspaceSentinel = await writeWorkspaceSentinel({ sandbox, remoteCwd, @@ -1695,8 +1694,7 @@ const plugin = definePlugin({ sandbox, shellCommand, bwrapAvailable: bwrapCapability.bwrapAvailable, - sandboxUid: bwrapCapability.sandboxUid, - sandboxGid: bwrapCapability.sandboxGid, + sandboxUsername: bwrapCapability.sandboxUsername, remoteCwd, resumedLease: false, workspaceSentinel, @@ -1743,7 +1741,7 @@ const plugin = definePlugin({ return { providerLeaseId: null, metadata: { expired: true, workspaceSentinel } }; } const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs)); - const bwrapCapability = await detectBwrapCapability(sandbox, toBwrapProbeTimeoutSeconds(config)); + const bwrapCapability = await detectBwrapCapability(sandbox, toBwrapProbeTimeoutSeconds(config), remoteCwd); sandboxHandleCache.markFresh(scope); sandboxHandleLeaseAdmissionStates.open(scope); return { @@ -1753,8 +1751,7 @@ const plugin = definePlugin({ sandbox, shellCommand, bwrapAvailable: bwrapCapability.bwrapAvailable, - sandboxUid: bwrapCapability.sandboxUid, - sandboxGid: bwrapCapability.sandboxGid, + sandboxUsername: bwrapCapability.sandboxUsername, remoteCwd, resumedLease: true, workspaceSentinel,