fix(daytona): replace bwrap user-namespace with su privilege drop (#10805)

## Thinking Path

> - Paperclip runs AI agents inside Daytona sandbox environments
> - The Daytona plugin wraps agent commands in bwrap to prevent
accidental writes outside the workspace
> - bwrap previously used `--unshare-user --uid <uid> --gid <gid>` to
run commands as the sandbox user inside the container
> - This creates a uid_map of `<uid> 0 1` — inside-uid maps to
outside-uid 0 (root)
> - Files owned by the sandbox user (outside-uid 1001) appear as
overflow uid 65534 (nobody) from inside the namespace
> - So all writes to the workspace fail with Permission denied, and the
agent cannot run
> - This PR replaces the user-namespace approach with `su -s /bin/sh
<user>`, which gives the correct uid mapping
> - The benefit is that bwrap works correctly on Daytona: writes succeed
and the advisory isolation is preserved

## Linked Issues or Issue Description

No existing issue. Describing the bug inline per the bug report
template.

**What happened?**

Running a Codex agent in a Daytona sandbox failed immediately. The agent
could not create directories inside the workspace:

```
mkdir /home/daytona/paperclip-workspace/.paperclip-runtime/codex/paperclip-bridge/queue ... failed with exit code 1:
bwrap: Can't find source path /home/daytona/paperclip-workspace: Permission denied
```

Root cause: bwrap used `--unshare-user --uid 1001 --gid 1001` (via
sudo/root). This writes uid_map `1001 0 1` — inside-uid 1001 maps to
outside-uid 0. Files owned by outside-uid 1001 (the workspace) appear as
overflow uid 65534 (nobody) from inside the namespace. `--bind-try`
suppresses ENOENT but not EACCES, so the bind exits 0 and the wrapper
proceeds — but every write inside then fails with Permission denied.

The bwrap capability probe (`sudo -n bwrap --unshare-user --uid 0 --gid
0 --ro-bind / / -- true`) did not test the workspace bind or the su
invocation, so it incorrectly reported bwrap as available.

**Expected behavior**

The agent should start and run normally inside the Daytona sandbox.
Directory creation and file writes in the workspace should succeed.

**Steps to reproduce**

1. Configure Paperclip with a Daytona sandbox provider
2. Start a Codex agent task targeting a Daytona sandbox
3. Observe the `adapter_failed` error: `mkdir ... failed with exit code
1: bwrap: Can't find source path /home/daytona/paperclip-workspace:
Permission denied`

**Paperclip version or commit**

master (reproducible on current HEAD before this fix)

**Deployment mode**

Self-hosted server

**Agent adapter(s) involved**

- [x] Codex

**Relevant logs or output**

```
mkdir /home/daytona/paperclip-workspace/.paperclip-runtime/codex/paperclip-bridge/queue \
  /home/daytona/paperclip-workspace/.paperclip-runtime/codex/paperclip-bridge/queue/requests \
  /home/daytona/paperclip-workspace/.paperclip-runtime/codex/paperclip-bridge/queue/responses \
  /home/daytona/paperclip-workspace/.paperclip-runtime/codex/paperclip-bridge/queue/logs \
  failed with exit code 1: bwrap: Can't find source path /home/daytona/paperclip-workspace: Permission denied
(adapter_failed)
```

## What Changed

- Replaced `--unshare-user --uid <uid> --gid <gid>` with `su -s /bin/sh
<username>` in `buildBwrapCommand`. bwrap runs as real root (for
bind-mount capability), then `su` drops into the sandbox user. Inside
uid=1001 maps to outside uid=1001, so workspace files are writable.
- Removed `detectSandboxUidGid` (ran `id -u` + `id -g`). Added
`detectSandboxUsername` (runs `id -un`) — the username is what `su`
needs.
- Updated `detectBwrapAvailable` probe to test the actual invocation:
`sudo -n bwrap --ro-bind / / [--bind-try <workspace> <workspace>] -- su
-s /bin/sh '<user>' -c true`. This catches both the su failure mode and
the EACCES-on-workspace-bind case.
- Updated `detectBwrapCapability` to run sequentially (username first,
then probe with that username and remoteCwd).
- Replaced `sandboxUid`/`sandboxGid` in lease metadata with
`sandboxUsername`.
- All three `detectBwrapCapability` call sites now pass `remoteCwd`.
- Updated `BwrapExecPlan` type and `resolveBwrapExecPlan` to use
`username: string` instead of `identity: { uid, gid }`.
- Updated tests TDD-style: rewrote tests to describe the new behavior
first, then implemented to pass them.

## Verification

**SSH verification** (run against a live Daytona sandbox before writing
the fix):

```bash
# Old approach — fails
sudo -n bwrap --unshare-user --uid 1001 --gid 1001 \
  --ro-bind / / --bind-try /home/daytona/paperclip-workspace /home/daytona/paperclip-workspace \
  -- sh -c "mkdir -p /home/daytona/paperclip-workspace/test"
# → mkdir: cannot create directory: Permission denied

# New approach — works
sudo -n bwrap --ro-bind / / \
  --bind-try /home/daytona/paperclip-workspace /home/daytona/paperclip-workspace \
  -- su -s /bin/sh daytona -c "mkdir -p /home/daytona/paperclip-workspace/test && echo ok"
# → ok (files owned by uid 1001)
```

**Unit tests:**

```
cd packages/plugins/sandbox-providers/daytona && npx vitest run
# 114 passed, 2 pre-existing failures (macOS tar compatibility in file-sync.ts, unrelated)
```

## Risks

- `su` must be available in the Daytona sandbox image. It is a standard
POSIX tool present in every image tested. If absent,
`detectBwrapAvailable` returns false and execution falls back to the
unwrapped path (same behavior as before).
- The advisory bwrap wrapper was never a security boundary — it is
best-effort. The behavioral change (root → sandbox user inside the
container) is strictly better: files created by the agent now have the
correct ownership.
- `sandboxUid` and `sandboxGid` are removed from lease metadata. Any
external code reading those fields will get `undefined`. They were only
used internally by `resolveBwrapExecPlan`, which now reads
`sandboxUsername`.

## Model Used

Claude Sonnet 4.6 (`claude-sonnet-4-6`) via Claude Code CLI — tool use
enabled, extended context. The model diagnosed the bug via SSH
inspection, designed the fix, and implemented it TDD-style (tests first,
then implementation).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicky Leach 2026-08-03 21:17:17 -07:00 committed by GitHub
parent aa6b6bcb41
commit bd7a13eb9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 155 additions and 186 deletions

View File

@ -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 `<sandbox-user>` 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 `<sandbox-user>` and
`<workspace>` with real values:
```bash
sysctl user.max_user_namespaces
sudo -n bwrap --ro-bind / / --bind-try <workspace> <workspace> -- su -s /bin/sh <sandbox-user> -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

View File

@ -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" },
});
});
});

View File

@ -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<boolean> {
// 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<string | null> {
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<boolean> {
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 <uid> --gid <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 <dir> <dir>` per writable directory, in the caller's order.
// 5. `--ro-bind <stdinPath> <stdinPath>` when a stdin path is supplied.
// 6. `--new-session`.
// 7. `-- sh -c '<escaped inner script>'`.
// `--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 <dir> <dir>` per writable directory, in the caller's order.
// 4. `--ro-bind <stdinPath> <stdinPath>` when a stdin path is supplied.
// 5. `--new-session`.
// 6. `-- su -s /bin/sh '<username>' -c '<escaped inner script>'` when a
// username is supplied, or `-- sh -c '<escaped inner script>'` otherwise.
//
// `sudo -n bwrap` runs as real root (for bind-mount capability). `su` then
// drops into the sandbox user, so inside uid=<user> maps to outside uid=<user>
// 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<string, unknown> | 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<string>([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,