From 70816c18e53f27bbc59d6b059167c942c2e911f2 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Fri, 31 Jul 2026 07:52:28 -0700 Subject: [PATCH] feat(daytona): add advisory bwrap command builder and capability probes (#10541) ## Thinking Path > - Paperclip helps people manage AI agents for work > - The Daytona sandbox provider needs a clear advisory wrapper path and best-effort capability checks > - The wrapper must not change the security model or block a lease when the host lacks bubblewrap support > - The lease metadata must carry the capability result so later steps can make a stable choice > - This pull request adds a pure command builder for the advisory wrapper > - This pull request adds non-throwing probes for bubblewrap and sandbox uid or gid data > - The benefit is a safer advisory path with no behavior change in the execution seam ## Linked Issues or Issue Description ### Problem or motivation The Daytona sandbox provider needs a clear advisory wrapper path and best-effort capability checks. The provider must not fail a lease when the host lacks bubblewrap support. The wrapper must stay advisory only. It must not change the security model. ### Proposed solution Add a pure command builder for the advisory wrapper and non-throwing probes for bubblewrap and sandbox uid or gid data. Store the probe result on the lease metadata so later steps can make a stable choice. Keep the execution seam unchanged. ### Alternatives considered Do nothing and keep the current execution seam unchanged. That path gives no signal when a file change is not durable. This pull request adds the signal without changing runtime behavior. ### Roadmap alignment This work fits the Daytona sandbox provider path and keeps the advisory wrapper outside the execution seam. It does not change the current security model. ### Additional context The wrapper is advisory only. It adds no security. The read-only root is a feedback signal. ## What Changed - Added `buildBwrapCommand` as a pure string builder for the advisory wrapper command. - Added `detectBwrapAvailable` and `detectSandboxUidGid` as best-effort probes that never throw. - Stored `bwrapAvailable`, `sandboxUid`, and `sandboxGid` on the lease metadata in the acquire, resume, and probe hooks. - Added a README section that describes the advisory wrapper model. - Kept the execution seam unchanged. ## Verification - `vitest run` for `packages/plugins/sandbox-providers/daytona/src/plugin.test.ts` - The run passed all 106 tests, including the new builder and probe coverage. - The standalone `tsc` run showed only the known baseline noise that already exists on `master`. ## Risks - Risk is low because the execution seam does not change. - The new wrapper stays advisory and does not alter the sandbox security model. - The probe results only add metadata and do not fail the lease on missing host support. ## Model Used OpenAI Codex, GPT-5, tool use. ## 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 - [x] My branch name describes the change 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: Paperclip --- .../sandbox-providers/daytona/README.md | 9 + .../daytona/src/plugin.test.ts | 220 ++++++++++++++++++ .../sandbox-providers/daytona/src/plugin.ts | 163 +++++++++++++ 3 files changed, 392 insertions(+) diff --git a/packages/plugins/sandbox-providers/daytona/README.md b/packages/plugins/sandbox-providers/daytona/README.md index facdc7526c..1057794b34 100644 --- a/packages/plugins/sandbox-providers/daytona/README.md +++ b/packages/plugins/sandbox-providers/daytona/README.md @@ -29,6 +29,15 @@ Notes: - The driver supports both `snapshot`-based and `image`-based sandbox creation. If both are set, validation rejects the config as ambiguous. - Reusable leases map to Daytona stop/start semantics. Non-reusable leases are deleted on release. +## Advisory bwrap wrapper + +The driver prepares an advisory bubblewrap (`bwrap`) wrapper for a sandbox command. The wrapper is advisory and best-effort. The driver does not wrap the command during execution yet. At lease time the driver probes the sandbox for the wrapper capability and records the result on the lease metadata. The command builder exists as a pure function. A later change wires the builder into command execution. + +- **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. + ## Local development ```bash diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index 868261d5d3..911590bddb 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -33,6 +33,7 @@ import plugin, { setDaytonaHandleFreshnessClockForTest, __resetDaytonaSandboxHandleCacheForTest, __getDaytonaWritableDirsForTest, + buildBwrapCommand, } from "./plugin.js"; import manifest from "./manifest.js"; @@ -3363,3 +3364,222 @@ describe("daytona manifest memory config", () => { expect(memorySchema.required ?? []).not.toContain("memory"); }); }); + +describe("buildBwrapCommand advisory wrapper builder", () => { + it("emits user-namespace, ro-bind root, fresh dev/proc/tmp, writable binds, new-session, and sh -c", () => { + const command = buildBwrapCommand( + "echo hi", + ["/home/daytona/paperclip-workspace"], + null, + { uid: 1000, gid: 1000 }, + ); + + expect(command).toBe( + "sudo -n bwrap --unshare-user --uid 1000 --gid 1000 " + + "--ro-bind / / --dev /dev --proc /proc --tmpfs /tmp " + + "--bind '/home/daytona/paperclip-workspace' '/home/daytona/paperclip-workspace' " + + "--new-session -- sh -c 'echo hi'", + ); + }); + + it("re-binds the stdin path after tmpfs /tmp", () => { + const command = buildBwrapCommand( + "run-cmd", + ["/work"], + "/tmp/stdin.bin", + { uid: 1000, gid: 1000 }, + ); + + expect(command).toBe( + "sudo -n bwrap --unshare-user --uid 1000 --gid 1000 " + + "--ro-bind / / --dev /dev --proc /proc --tmpfs /tmp " + + "--bind '/work' '/work' " + + "--ro-bind '/tmp/stdin.bin' '/tmp/stdin.bin' " + + "--new-session -- sh -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")); + }); + + it("quotes writable paths and inner script with single quotes", () => { + const command = buildBwrapCommand( + "echo 'hello'", + ["/data/o'brien"], + null, + { uid: 1000, gid: 1000 }, + ); + + // shellQuote rewrites each embedded single quote as the `'"'"'` token. + expect(command).toContain(`'"'"'`); + expect(command).toContain(`--bind '/data/o'"'"'brien' '/data/o'"'"'brien'`); + expect(command).toContain(`-- sh -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", () => { + const command = buildBwrapCommand("plain", ["/w"], null, null); + + expect(command).toBe( + "sudo -n bwrap " + + "--ro-bind / / --dev /dev --proc /proc --tmpfs /tmp " + + "--bind '/w' '/w' " + + "--new-session -- sh -c 'plain'", + ); + expect(command).not.toContain("--unshare-user"); + expect(command).not.toContain("--uid"); + expect(command).not.toContain("--gid"); + }); +}); + +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. + function bwrapExecMock(opts: { + bwrapExit?: number; + uid?: string; + gid?: string; + uidExit?: number; + gidExit?: number; + sentinelToken?: string; + } = {}) { + return async (command: string) => { + if (command.startsWith("cat ")) { + const token = opts.sentinelToken ?? "sentinel-token"; + return { exitCode: 0, result: JSON.stringify({ token }), artifacts: { stdout: JSON.stringify({ token }) } }; + } + if (command.includes("command -v bash")) { + return { exitCode: 0, result: "bash", artifacts: { stdout: "bash" } }; + } + 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 } }; + } + return { exitCode: 0, result: "", artifacts: { stdout: "" } }; + }; + } + + const acquireParams = { + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + runId: "run-1", + agentId: "agent-1", + executionWorkspaceId: "workspace-1", + adapterType: "codex_local", + config: { image: "node:20", timeoutMs: 300000, reuseLease: true }, + }; + + it("records bwrap available and reads uid/gid 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" })); + mockCreate.mockResolvedValue(sandbox); + + const lease = await plugin.definition.onEnvironmentAcquireLease?.(acquireParams); + + expect(lease).toMatchObject({ + metadata: { + bwrapAvailable: true, + sandboxUid: 1000, + sandboxGid: 1001, + }, + }); + }); + + 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" })); + 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"), + ); + expect(probeCalls.length).toBeGreaterThan(0); + for (const call of probeCalls) { + const timeoutArg = call[3] as number; + expect(timeoutArg).toBe(10); + } + }); + + 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" })); + mockCreate.mockResolvedValue(sandbox); + + const lease = await plugin.definition.onEnvironmentAcquireLease?.(acquireParams); + + expect(lease?.metadata).toMatchObject({ bwrapAvailable: false }); + }); + + it("records bwrap unavailable and does not throw when the probe throws", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox(); + sandbox.process.executeCommand.mockRejectedValue(new Error("sandbox exec failed")); + mockCreate.mockResolvedValue(sandbox); + + const lease = await plugin.definition.onEnvironmentAcquireLease?.(acquireParams); + + expect(lease?.metadata).toMatchObject({ + bwrapAvailable: false, + sandboxUid: null, + sandboxGid: 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" })); + mockCreate.mockResolvedValue(sandbox); + + const result = await plugin.definition.onEnvironmentProbe?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { snapshot: "base-snapshot", timeoutMs: 300000, reuseLease: false }, + }); + + expect(result).toMatchObject({ + ok: true, + metadata: { bwrapAvailable: true, sandboxUid: 1000, sandboxGid: 1000 }, + }); + }); + + 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" })); + mockGet.mockResolvedValue(sandbox); + + const lease = await plugin.definition.onEnvironmentResumeLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "sandbox-reuse", + config: { timeoutMs: 300000, reuseLease: true }, + leaseMetadata: { + workspaceSentinel: { + path: "/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json", + token: "sentinel-token", + result: "written", + }, + }, + }); + + expect(lease).toMatchObject({ + providerLeaseId: "sandbox-reuse", + metadata: { bwrapAvailable: true, sandboxUid: 1000, sandboxGid: 1000 }, + }); + }); +}); diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index 4efc06bf09..bf56998410 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -146,6 +146,14 @@ const ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES = 60; // RPC ceiling; callers always see an actionable error within this window. const GIT_NETWORK_TIMEOUT_MS = 120_000; +// Fail-fast cap for the advisory bwrap capability probe. The probe is +// best-effort, so it must return fallback metadata inside the lease hook. It +// must never consume the full hook deadline. A stalled probe command would +// otherwise expire the outer lease RPC before the probe records its unavailable +// result. This short cap keeps the probe well under the hook deadline, so the +// probe fails fast, records `bwrapAvailable: false`, and the hook still returns. +const BWRAP_PROBE_TIMEOUT_MS = 10_000; + // Noninteractive git credential defaults injected into every Daytona one-shot // command so that git operations never stall waiting for a terminal prompt. // Callers can override any of these via the env parameter. @@ -293,6 +301,14 @@ function toTimeoutSeconds(timeoutMs: number): number { return Math.max(1, Math.ceil(timeoutMs / 1000)); } +// Bounded timeout for the advisory bwrap capability probe. The probe never uses +// the full hook deadline. It uses the smaller of the short probe cap and the +// hook timeout, so a stalled probe command returns fallback metadata inside the +// hook instead of expiring the outer lease RPC. +function toBwrapProbeTimeoutSeconds(config: DaytonaDriverConfig): number { + return toTimeoutSeconds(Math.min(BWRAP_PROBE_TIMEOUT_MS, config.timeoutMs)); +} + function resolveTimeoutMs(paramsTimeoutMs: number | undefined, config: DaytonaDriverConfig): number { return paramsTimeoutMs != null && Number.isFinite(paramsTimeoutMs) && paramsTimeoutMs > 0 ? Math.trunc(paramsTimeoutMs) @@ -361,6 +377,83 @@ async function detectSandboxShellCommand(sandbox: Sandbox, timeoutSeconds: numbe } } +function parseProbeInteger(value: string | undefined | null): number | null { + const trimmed = value?.trim() ?? ""; + if (!/^\d+$/.test(trimmed)) { + return null; + } + const parsed = Number.parseInt(trimmed, 10); + 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 { + try { + const result = await sandbox.process.executeCommand( + "sudo -n bwrap --unshare-user --uid 0 --gid 0 --ro-bind / / -- true", + undefined, + undefined, + timeoutSeconds, + ); + return result.exitCode === 0; + } catch { + return false; + } +} + +// 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. +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, + }; +} + function workspaceSentinelToken(input: { params: Pick; config: DaytonaDriverConfig; @@ -468,6 +561,9 @@ function leaseMetadata(input: { config: DaytonaDriverConfig; sandbox: Sandbox; shellCommand: "bash" | "sh"; + bwrapAvailable: boolean; + sandboxUid: number | null; + sandboxGid: number | null; remoteCwd: string; resumedLease: boolean; workspaceSentinel?: WorkspaceSentinelResult; @@ -475,6 +571,11 @@ function leaseMetadata(input: { return { provider: "daytona", shellCommand: input.shellCommand, + // 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, sandboxId: input.sandbox.id, sandboxName: input.sandbox.name, sandboxState: input.sandbox.state ?? null, @@ -502,6 +603,56 @@ function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } +// Advisory bubblewrap (`bwrap`) wrapper. +// +// The wrapper gives an agent real-time feedback when the agent tries to change a +// file that the ephemeral sandbox will not keep. It adds NO security. The +// ephemeral sandbox stays the only security posture. The read-only root +// (`--ro-bind / /`) is a feedback signal, not a control: a write to a path +// outside the writable set fails at once, so the agent learns the change is not +// durable. +// +// `buildBwrapCommand` is pure. It builds one command string and runs no process. +// It needs no live sandbox. The flag order is load-bearing, because a later +// 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 ` 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. +export function buildBwrapCommand( + innerScript: string, + writableDirs: string[], + stdinPath: string | null, + identity: { uid: number; gid: number } | 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", 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)]; + return [ + "sudo", + "-n", + "bwrap", + ...identityFlags, + ...rootBinds, + ...writableBinds, + ...stdinReBind, + ...tail, + ].join(" "); +} + function resolveConnectionExpiresInMinutes(value: number | null | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_SSH_ACCESS_MINUTES; return Math.min(24 * 60, Math.max(1, Math.trunc(value))); @@ -1362,12 +1513,16 @@ const plugin = definePlugin({ try { const remoteCwd = await resolveSandboxWorkingDirectory(sandbox); const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs)); + const bwrapCapability = await detectBwrapCapability(sandbox, toBwrapProbeTimeoutSeconds(config)); return { ok: true, summary: `Connected to Daytona sandbox ${sandbox.name}.`, metadata: { provider: "daytona", shellCommand, + bwrapAvailable: bwrapCapability.bwrapAvailable, + sandboxUid: bwrapCapability.sandboxUid, + sandboxGid: bwrapCapability.sandboxGid, sandboxId: sandbox.id, sandboxName: sandbox.name, target: sandbox.target, @@ -1405,6 +1560,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 workspaceSentinel = await writeWorkspaceSentinel({ sandbox, remoteCwd, @@ -1438,6 +1594,9 @@ const plugin = definePlugin({ config, sandbox, shellCommand, + bwrapAvailable: bwrapCapability.bwrapAvailable, + sandboxUid: bwrapCapability.sandboxUid, + sandboxGid: bwrapCapability.sandboxGid, remoteCwd, resumedLease: false, workspaceSentinel, @@ -1484,6 +1643,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)); sandboxHandleCache.markFresh(scope); sandboxHandleLeaseAdmissionStates.open(scope); return { @@ -1492,6 +1652,9 @@ const plugin = definePlugin({ config, sandbox, shellCommand, + bwrapAvailable: bwrapCapability.bwrapAvailable, + sandboxUid: bwrapCapability.sandboxUid, + sandboxGid: bwrapCapability.sandboxGid, remoteCwd, resumedLease: true, workspaceSentinel,