diff --git a/doc/plugins/SANDBOX_PROVIDER_CAPABILITIES.md b/doc/plugins/SANDBOX_PROVIDER_CAPABILITIES.md index c3a0071c80f..c8316567992 100644 --- a/doc/plugins/SANDBOX_PROVIDER_CAPABILITIES.md +++ b/doc/plugins/SANDBOX_PROVIDER_CAPABILITIES.md @@ -50,6 +50,7 @@ environmentDrivers: [ nativeSyncOut: true, persistentProcessSessions: false, independentControlCommands: false, + incrementalSessionOutput: false, }, }, ] @@ -61,8 +62,9 @@ states: - **Omitted** — the host defers to verified worker discovery. The capability is effective when the worker advertises the required methods and no narrowing removes it. Omission is the correct default for a provider that follows the - standard method contract. `reusableLeases` is the one exception: an omitted - `reusableLeases` key never grants reusable leases (see the next section). + standard method contract. `reusableLeases` and `incrementalSessionOutput` are + the two exceptions: an omitted key never grants either capability. Both are + opt-in (see the next two sections). - **`false`** — the host narrows the capability to off. The capability is never effective, even when the worker advertises the required methods. - **`true`** — the host still requires the verified prerequisites. A `true` @@ -99,6 +101,25 @@ into `sandboxCapabilities.reusableLeases`. A manifest with legacy `true` and nested `false` therefore resolves to `false`. Prefer the nested `sandboxCapabilities.reusableLeases` in a new manifest. +## Incremental session output needs an explicit opt-in + +Incremental session output is the second exception to the omission rule. The host +selects the session-output streaming path only when the declaration sets +`incrementalSessionOutput` to `true`. An omitted key resolves the capability to +`false`, so the host keeps the output-file poll path. + +The reason is that this key is a behavioral guarantee, not a worker-method +property. A generic one-shot provider can keep persistent process sessions and run +independent control commands, yet it never emits incremental stdout and stderr +from a live session. The two broad capabilities do not imply incremental output, +so the host requires the provider to declare the behavior. A provider that streams +incremental session output declares `sandboxCapabilities.incrementalSessionOutput: +true`; every other provider omits the key and keeps the poll path. + +The opt-in never removes the prerequisites. The worker must still verify +`environmentExecute`, and per-run narrowing still applies. A config-resolution +failure narrows the capability to off (see [Failure behavior](#failure-behavior)). + ## The capabilities and their worker-method prerequisites | Capability | Required worker methods | Meaning | @@ -108,6 +129,7 @@ Prefer the nested `sandboxCapabilities.reusableLeases` in a new manifest. | `nativeSyncOut` | `environmentSyncOut` | The host transfers files out of the sandbox through the native outbound hook. | | `persistentProcessSessions` | `environmentExecute` | The provider keeps a persistent process session open across commands. | | `independentControlCommands` | `environmentExecute` | The provider runs a one-shot control command beside a long-lived command. | +| `incrementalSessionOutput` | `environmentExecute` | The provider streams incremental stdout and stderr from a live session. Opt-in: an omitted key resolves `false`. | Reusable leases need all three lifecycle methods. The host resumes a lease with `environmentResumeLease`, ends it with `environmentReleaseLease`, and tears down a @@ -131,18 +153,16 @@ this run cannot use. unable to run native file sync, disables native sync. The host narrows `nativeSyncIn` and `nativeSyncOut` to off and keeps the base64-over-exec fallback. -- **`useSessions` provider config.** A session-based provider follows its - `useSessions` config value for `persistentProcessSessions`. Sessions default to - off. A config that omits the key adds no narrowing. ## Failure behavior The host fails closed on two failure states. It never grants a capability from an unknown state. -- **Config-resolution failure.** When the host cannot resolve the provider - config, it cannot read `useSessions`. It narrows `persistentProcessSessions` to - off instead of allowing it through an empty config. +- **Config-resolution failure.** A provider whose config the host cannot resolve + is untrusted. The host narrows `persistentProcessSessions` and + `incrementalSessionOutput` to off instead of allowing either through an empty + config. - **Exact-plugin identity failure.** A retained lease pins the exact plugin that acquired it. When that plugin is absent, or when it no longer declares this provider key with the `sandbox_provider` kind, the host cannot establish the diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index cc31a0c4121..99f1e8a02e2 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -1747,12 +1747,17 @@ async function buildRuntime(input: { Boolean(executionTarget.runner) && Boolean(agentCommandShell); // Stream the agent output through the persistent session log stream instead of - // the host output-file poll. Default OFF; an operator opts a sandbox - // environment in through the environment config. + // the host output-file poll. The decision comes from the effective capability + // snapshot alone: the provider must declare and verify incremental session + // output. `incrementalSessionOutput` is an opt-in capability, so a generic + // one-shot provider that keeps persistent process sessions and runs independent + // control commands, yet never emits incremental session output, keeps the poll + // path. The snapshot resolves this key false when it is absent, undeclared, or + // capability resolution failed, so this fails closed to the poll path. const streamAgentSessionOutput = executionTarget?.kind === "remote" && executionTarget.transport === "sandbox" && - executionTarget.streamAgentSessionOutput === true; + executionTarget.effectiveCapabilities?.incrementalSessionOutput === true; // The ACP `session/new` cwd and every cwd-keyed session-state site // (fingerprint, compat, persist, ensureSession, error) bind to THIS single // value so a warm/resumable session created with the in-sandbox cwd is reused diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 9d53709f399..ce575787baf 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -102,6 +102,7 @@ export interface EffectiveSandboxCapabilities { readonly nativeSyncOut: boolean; readonly persistentProcessSessions: boolean; readonly independentControlCommands: boolean; + readonly incrementalSessionOutput: boolean; } export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWorkspaceMetadata { @@ -127,13 +128,6 @@ export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWor * set to `false` to explicitly opt out back to batch-at-end delivery. */ streamRunLogs?: boolean | null; - /** - * Stream the interactive ACP agent output through the persistent session log - * stream instead of the host-side output-file poll. The process session - * bridge runs the agent as one long-lived session command and reads its - * output frames from the stream. Default OFF: the bridge keeps the poll path. - */ - streamAgentSessionOutput?: boolean | null; } export type AdapterExecutionTarget = @@ -247,6 +241,7 @@ function parseEffectiveSandboxCapabilities(value: unknown): EffectiveSandboxCapa nativeSyncOut: parsed.nativeSyncOut === true, persistentProcessSessions: parsed.persistentProcessSessions === true, independentControlCommands: parsed.independentControlCommands === true, + incrementalSessionOutput: parsed.incrementalSessionOutput === true, }; } diff --git a/packages/plugins/sandbox-providers/daytona/src/manifest.ts b/packages/plugins/sandbox-providers/daytona/src/manifest.ts index 33ddc1cb2b2..e4b70895d3c 100644 --- a/packages/plugins/sandbox-providers/daytona/src/manifest.ts +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -24,6 +24,13 @@ const manifest: PaperclipPluginManifestV1 = { description: "Provisions Daytona sandboxes with configurable image or snapshot selection, startup timeouts, and lease reuse.", supportsReusableLeases: true, + // Daytona keeps a persistent session and tails its callback log form, so it + // emits incremental session output while the command runs. Declare the + // opt-in capability so the host selects the session-output streaming path. + // A generic one-shot provider that omits this key keeps the poll path. + sandboxCapabilities: { + incrementalSessionOutput: true, + }, supportsInteractiveSetup: true, interactiveSetupConnectionTypes: ["ssh"], supportsTemplateCapture: true, @@ -126,12 +133,6 @@ const manifest: PaperclipPluginManifestV1 = { "Whether to stop and later resume the sandbox across runs instead of deleting it on release.", default: false, }, - useLogStream: { - type: "boolean", - description: - "When true, a session command streams stdout and stderr from the Daytona callback log form and reads the exit code one time after the stream ends. When false, the command polls the exit code and reads the logs one time. Defaults to false.", - default: false, - }, }, }, }, diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index d3e3f3561a6..75bbc8482ec 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -128,6 +128,9 @@ describe("Daytona sandbox provider plugin", () => { supportsTemplateCapture: true, templateRefKind: "snapshot", supportsTemplateDelete: true, + // Daytona streams incremental session output, so it declares the opt-in + // capability that selects the session-output streaming path. + sandboxCapabilities: { incrementalSessionOutput: true }, }); }); @@ -170,8 +173,6 @@ describe("Daytona sandbox provider plugin", () => { autoDeleteInterval: -1, reuseLease: true, archiveOnRelease: false, - useSessions: false, - useLogStream: false, }, }); }); @@ -1125,7 +1126,7 @@ describe("Daytona sandbox provider plugin", () => { driverKey: "daytona" as const, companyId: "company-1", environmentId: "env-1", - config: { timeoutMs: 300000, reuseLease: false, useSessions: true }, + config: { timeoutMs: 300000, reuseLease: false }, lease: { providerLeaseId: "sandbox-123", metadata: {} }, command: "printf", args: ["hello"], @@ -1179,25 +1180,12 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", providerLeaseId: "sandbox-123", - config: { timeoutMs: 300000, reuseLease: false, useSessions: true }, + config: { timeoutMs: 300000, reuseLease: false }, }); expect(sandbox.process.deleteSession).toHaveBeenCalledTimes(1); expect(sandbox.process.deleteSession).toHaveBeenCalledWith(sessionId); }); - it("opens no session when the session model is off", async () => { - process.env.DAYTONA_API_KEY = "host-key"; - const sandbox = createMockSandbox(); - mockGet.mockResolvedValue(sandbox); - - await plugin.definition.onEnvironmentExecute?.( - sessionExecParams({ config: { timeoutMs: 300000, reuseLease: false } }), - ); - - expect(sandbox.process.createSession).not.toHaveBeenCalled(); - expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1); - }); - it("runs a bypassSession command one-shot and leaves the session closed", async () => { process.env.DAYTONA_API_KEY = "host-key"; const sandbox = createMockSandbox(); @@ -1246,7 +1234,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", providerLeaseId: "sandbox-123", - config: { timeoutMs: 300000, reuseLease: false, useSessions: true }, + config: { timeoutMs: 300000, reuseLease: false }, }); expect(sandbox.process.deleteSession).toHaveBeenCalledWith(sessionId); @@ -1267,7 +1255,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", providerLeaseId: "sandbox-123", - config: { timeoutMs: 300000, reuseLease: false, useSessions: true }, + config: { timeoutMs: 300000, reuseLease: false }, }), ).rejects.toThrow(/delete failed/); @@ -1287,7 +1275,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", providerLeaseId: "sandbox-123", - config: { timeoutMs: 300000, reuseLease: false, useSessions: true }, + config: { timeoutMs: 300000, reuseLease: false }, }); expect(sandbox.process.deleteSession).toHaveBeenCalledWith(sessionId); @@ -1308,7 +1296,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", providerLeaseId: "sandbox-123", - config: { timeoutMs: 300000, reuseLease: false, useSessions: true }, + config: { timeoutMs: 300000, reuseLease: false }, }); expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(sessionId)); @@ -1325,7 +1313,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", providerLeaseId: "sandbox-123", - config: { timeoutMs: 300000, reuseLease: true, useSessions: true }, + config: { timeoutMs: 300000, reuseLease: true }, }; await plugin.definition.onEnvironmentExecute?.(sessionExecParams()); @@ -1352,7 +1340,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", providerLeaseId: "sandbox-123", - config: { timeoutMs: 300000, reuseLease: false, useSessions: true }, + config: { timeoutMs: 300000, reuseLease: false }, leaseMetadata: { remoteCwd: "/home/daytona/paperclip-workspace", workspaceSentinel: { @@ -1383,7 +1371,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", providerLeaseId: "sandbox-123", - config: { timeoutMs: 300000, reuseLease: false, useSessions: true }, + config: { timeoutMs: 300000, reuseLease: false }, leaseMetadata: { remoteCwd: "/home/daytona/paperclip-workspace", workspaceSentinel: { @@ -1403,7 +1391,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", providerLeaseId: "sandbox-123", - config: { timeoutMs: 300000, reuseLease: false, useSessions: true }, + config: { timeoutMs: 300000, reuseLease: false }, }); expect(sandbox.process.deleteSession).toHaveBeenCalledTimes(1); expect(sandbox.process.deleteSession).toHaveBeenCalledWith(sessionId); @@ -1427,7 +1415,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", providerLeaseId: "sandbox-123", - config: { timeoutMs: 300000, reuseLease: false, useSessions: true }, + config: { timeoutMs: 300000, reuseLease: false }, }); const teardown = spans.find((span) => span.name === "session.close"); expect(teardown).toBeDefined(); @@ -1462,7 +1450,19 @@ describe("Daytona sandbox provider plugin", () => { process.env.DAYTONA_API_KEY = "host-key"; const sandbox = createMockSandbox(); sandbox.process.getSessionCommand.mockResolvedValue({ id: "cmd-1", command: "", exitCode: 7 }); - sandbox.process.getSessionCommandLogs.mockResolvedValue({ stdout: "out-here", stderr: "err-here" }); + // A session command tries the log stream first: it delivers stdout and + // stderr from the callback log form. + sandbox.process.getSessionCommandLogs.mockImplementation( + async ( + _sid: string, + _cmdId: string, + onStdout?: (chunk: string) => void, + onStderr?: (chunk: string) => void, + ) => { + onStdout?.("out-here"); + onStderr?.("err-here"); + }, + ); mockGet.mockResolvedValue(sandbox); const result = await plugin.definition.onEnvironmentExecute?.(sessionExecParams()); @@ -1483,7 +1483,7 @@ describe("Daytona sandbox provider plugin", () => { // The session command runs plain: no bwrap wrapper and no su privilege drop. expect(req.command).not.toContain("sudo -n bwrap"); expect(req.command).not.toContain("su -s /bin/sh"); - // True separated streams come from the logs endpoint. + // True separated streams come from the callback log stream. expect(result).toMatchObject({ exitCode: 7, timedOut: false, stdout: "out-here", stderr: "err-here" }); expect(typeof (result!.metadata as Record)?.durationMs).toBe("number"); }); @@ -1522,10 +1522,20 @@ describe("Daytona sandbox provider plugin", () => { expect(firstSid).toBe(secondSid); }); - it("returns a session timeout when the command never reports an exit code", async () => { + it("returns a session timeout on the poll fallback when the command never reports an exit code", async () => { process.env.DAYTONA_API_KEY = "host-key"; const sandbox = createMockSandbox(); - // The command stays running: the exit code never arrives. + // The log stream fails, so the dispatch falls back to the poll path. The + // command stays running there: the exit code never arrives, so the poll + // deadline fires. + sandbox.process.getSessionCommandLogs.mockImplementation( + async (_sid: string, _cmdId: string, onStdout?: (chunk: string) => void) => { + if (onStdout) { + throw new Error("socket error"); + } + return { stdout: "", stderr: "" }; + }, + ); sandbox.process.getSessionCommand.mockResolvedValue({ id: "cmd-1", command: "", exitCode: undefined }); mockGet.mockResolvedValue(sandbox); @@ -1537,14 +1547,12 @@ describe("Daytona sandbox provider plugin", () => { expect(result!.stderr).toMatch(/timed out/); }); - describe("log stream (useLogStream)", () => { - // A session exec params helper with the log-stream flag on. It streams - // stdout and stderr from the callback log form instead of the 50-ms poll. + describe("log stream (default)", () => { + // A session command tries the log stream first. It streams stdout and + // stderr from the callback log form instead of the 50-ms poll, and falls + // back to the poll only when the stream fails. const streamExecParams = (overrides: Record = {}) => - sessionExecParams({ - config: { timeoutMs: 300000, reuseLease: false, useSessions: true, useLogStream: true }, - ...overrides, - }); + sessionExecParams(overrides); it("streams ordered stdout and stderr from the callback log form (test_log_stream_delivers_ordered_chunks)", async () => { process.env.DAYTONA_API_KEY = "host-key"; @@ -1738,6 +1746,7 @@ describe("Daytona sandbox provider plugin", () => { timeoutMs: 300000, reuseLease: false, }, + bypassSession: true, lease: { providerLeaseId: "sandbox-123", metadata: {} }, command: "printf", args: ["hello"], @@ -1791,6 +1800,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", config: { timeoutMs: 300000, reuseLease: false }, + bypassSession: true, lease: { providerLeaseId: "sandbox-123", metadata: {} }, command: "printf", args: ["hello"], @@ -1819,6 +1829,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", config: { timeoutMs: 300000, reuseLease: false }, + bypassSession: true, lease: { providerLeaseId: "sandbox-123", metadata: {} }, command: "printf", args: ["hello"], @@ -1852,6 +1863,7 @@ describe("Daytona sandbox provider plugin", () => { timeoutMs: 300000, reuseLease: false, }, + bypassSession: true, lease: { providerLeaseId: "sandbox-123", metadata: {} }, command: "cat", args: [], @@ -1888,6 +1900,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", config: { timeoutMs: 300000, reuseLease: false }, + bypassSession: true, lease: { providerLeaseId: "sandbox-123", metadata: { remoteCwd: "/home/daytona/paperclip-workspace" } }, command: "printf", args: ["hello"], @@ -1914,6 +1927,7 @@ describe("Daytona sandbox provider plugin", () => { timeoutMs: 300000, reuseLease: false, }, + bypassSession: true, lease: { providerLeaseId: "sandbox-123", metadata: {} }, command: "printf", args: ["hello"], @@ -1946,6 +1960,7 @@ describe("Daytona sandbox provider plugin", () => { timeoutMs: 300000, reuseLease: false, }, + bypassSession: true, lease: { providerLeaseId: "sandbox-123", metadata: {} }, command: "sleep", args: ["60"], @@ -1978,6 +1993,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", config: { timeoutMs: 300000, reuseLease: false }, + bypassSession: true, lease: { providerLeaseId: "sandbox-123", metadata: {} }, command: "git", args: ["status"], @@ -2003,6 +2019,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", config: { timeoutMs: 300000, reuseLease: false }, + bypassSession: true, lease: { providerLeaseId: "sandbox-123", metadata: {} }, command: "git", args: ["push", "origin", "HEAD"], @@ -2036,6 +2053,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", config: { timeoutMs: 300000, reuseLease: false }, + bypassSession: true, lease: { providerLeaseId: "sandbox-123", metadata: {} }, command: "base64", args: ["-d"], @@ -2070,6 +2088,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: "company-1", environmentId: "env-1", config: { timeoutMs: 300000, reuseLease: false }, + bypassSession: true, lease: { providerLeaseId: "sandbox-123", metadata: {} }, command: "node", args: ["--version"], @@ -2101,6 +2120,7 @@ describe("Daytona sandbox provider plugin", () => { companyId: overrides.companyId ?? "company-1", environmentId: overrides.environmentId ?? "env-1", config: { timeoutMs: 300000, reuseLease: false }, + bypassSession: true, lease: { providerLeaseId, metadata: {} }, command: "printf", args: ["hi"], diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index b344e4c862d..82a15a73f0c 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -142,8 +142,6 @@ interface DaytonaDriverConfig { autoDeleteInterval: number | null; reuseLease: boolean; archiveOnRelease: boolean; - useSessions: boolean; - useLogStream: boolean; } type WorkspaceSentinelResult = { @@ -263,17 +261,6 @@ function parseDriverConfig(raw: Record): DaytonaDriverConfig { autoDeleteInterval: parseOptionalInteger(raw.autoDeleteInterval) ?? DEFAULT_AUTO_DELETE_INTERVAL_MINUTES, reuseLease: raw.reuseLease === true, archiveOnRelease: raw.archiveOnRelease === true, - // Session model opt-in. Default OFF. When off, the provider keeps the - // one-shot command path. When on, the exec hook opens one persistent - // Daytona session per lease and dispatches every command into it. The flag - // stays default off until a live leak soak passes. - useSessions: raw.useSessions === true, - // Log-stream opt-in. Default OFF. When off, the session dispatch polls the - // exit code every 50 ms and then reads the logs one time. When on, the - // dispatch streams stdout and stderr from the callback log form and reads - // the exit code one time after the stream ends. The flag stays default off - // until a live soak passes. - useLogStream: raw.useLogStream === true, }; } @@ -1753,38 +1740,37 @@ async function executeInSession( ); const commandId = dispatched.cmdId; - // Log-stream path (opt-in). Stream stdout and stderr from the callback log - // form, then read the exit code one time. On a stream failure, fall through - // to the poll path below, because the command still runs to its exit on the - // server. - if (config.useLogStream) { - // Emit each genuinely new output chunk to the host during the active - // execute call. The host routes it to the runner log sink by the - // host-issued invocation id. This is a no-op when no plugin context is - // set (a direct test call) or when the host has no active execute route. - const streamResult = await runSessionLogStream( - sandbox, - sessionId, - commandId, - (stream, text) => pluginContext?.execution.log(stream, text), - ); - if (streamResult.ok) { - const exitCode = await readSessionExitCode(sandbox, sessionId, commandId); - const durationMs = timingNow() - execStart; - return { - exitCode, - timedOut: false, - stdout: streamResult.stdout, - stderr: streamResult.stderr, - metadata: { durationMs }, - }; - } + // Log-stream path. A session command always tries the stream first: it + // streams stdout and stderr from the callback log form, then reads the exit + // code one time. On a stream failure, fall through to the poll path below, + // because the command still runs to its exit on the server. + // + // Emit each genuinely new output chunk to the host during the active execute + // call. The host routes it to the runner log sink by the host-issued + // invocation id. This is a no-op when no plugin context is set (a direct + // test call) or when the host has no active execute route. + const streamResult = await runSessionLogStream( + sandbox, + sessionId, + commandId, + (stream, text) => pluginContext?.execution.log(stream, text), + ); + if (streamResult.ok) { + const exitCode = await readSessionExitCode(sandbox, sessionId, commandId); + const durationMs = timingNow() - execStart; + return { + exitCode, + timedOut: false, + stdout: streamResult.stdout, + stderr: streamResult.stderr, + metadata: { durationMs }, + }; } // Poll for the exit code; the SDK has no wait method. The poll deadline uses // the wall clock, separate from the injected timing clock that measures the - // reported `durationMs`. The poll path is the default when the log stream is - // off, and the fallback when the log stream fails. + // reported `durationMs`. The poll path is the fallback when the log stream + // fails. const deadlineMs = Date.now() + effectiveTimeoutMs; let exitCode: number | null = null; while (true) { @@ -2505,20 +2491,19 @@ const plugin = definePlugin({ providerLeaseId, config, }; - // Dispatch the command. When the session model is on, open the persistent - // session on a cache miss and run the command in it. The provider never - // falls back to a one-shot command to open a session; a cache miss creates - // one. When the session model is off, run the command on the one-shot path. + // Dispatch the command. A normal command runs in the persistent session: + // the provider opens the one session on a cache miss and runs every command + // in it. The provider never falls back to a one-shot command to open a + // session; a cache miss creates one. // - // A `bypassSession` command runs one-shot even when the session model is - // on, and it does NOT open the session. The host sets this flag on a - // pre-run command (the workspace provision command) that runs before the - // run opens its trace root. Opening the session there would emit a - // `session.open` span with no run parent, and the span backend would drop - // it. With the bypass the session opens on the first in-run command, whose - // open span parents to the run trace. + // A `bypassSession` command runs one-shot and does NOT open the session. + // The host sets this flag on a pre-run command (the workspace provision + // command) that runs before the run opens its trace root. Opening the + // session there would emit a `session.open` span with no run parent, and + // the span backend would drop it. With the bypass the session opens on the + // first in-run command, whose open span parents to the run trace. let result: PluginEnvironmentExecuteResult; - if (config.useSessions && !params.bypassSession) { + if (!params.bypassSession) { const sessionId = await getOrCreateSession(sandbox, scope); result = await executeInSession(sandbox, sessionId, params, config); } else { diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index aa08f82ab75..2a2867ffc1c 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -30,11 +30,6 @@ export interface FakeSandboxEnvironmentConfig { reuseLease: boolean; /** Stream agent CLI stdout/stderr during sandbox runs (bridge log-tail loop). */ streamRunLogs?: boolean; - /** - * Stream the interactive ACP agent output through the persistent session log - * stream instead of the host output-file poll. Default OFF. - */ - streamAgentSessionOutput?: boolean; /** * Archive the sandbox on lease release instead of deleting it, so operators * can inspect it from the provider dashboard. Injected by test/probe paths; @@ -49,11 +44,6 @@ export interface PluginSandboxEnvironmentConfig { timeoutMs?: number; /** Stream agent CLI stdout/stderr during sandbox runs (bridge log-tail loop). */ streamRunLogs?: boolean; - /** - * Stream the interactive ACP agent output through the persistent session log - * stream instead of the host output-file poll. Default OFF. - */ - streamAgentSessionOutput?: boolean; /** * Archive the sandbox on lease release instead of deleting it, so operators * can inspect it from the provider dashboard. Injected by test/probe paths; diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 7a8e9081a21..2d14f567d6e 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -153,6 +153,16 @@ export interface SandboxProviderCapabilities { persistentProcessSessions?: boolean; /** Provider can run a control command that does not wait for the main command. */ independentControlCommands?: boolean; + /** + * Provider streams incremental stdout and stderr from a persistent session + * while the command runs. This is an opt-in behavioral guarantee, not a worker + * method property: a generic one-shot provider can keep persistent sessions and + * run independent control commands yet never emit incremental session output. + * An omitted key denies the capability. Only a provider that declares this key + * `true` selects the session-output streaming path; every other provider keeps + * the output-file poll path. + */ + incrementalSessionOutput?: boolean; } export interface PluginEnvironmentDriverDeclaration { diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 95f7b27c8fa..4cd52fe0590 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -285,6 +285,7 @@ describe("sandbox provider capability declaration validators", () => { nativeSyncOut: false, persistentProcessSessions: true, independentControlCommands: false, + incrementalSessionOutput: true, }, }), ); @@ -295,6 +296,7 @@ describe("sandbox provider capability declaration validators", () => { nativeSyncOut: false, persistentProcessSessions: true, independentControlCommands: false, + incrementalSessionOutput: true, }); const rejected = pluginManifestV1Schema.safeParse( diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index f8b5a937dad..7c048a0f2c6 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -165,6 +165,7 @@ export const sandboxProviderCapabilitiesSchema = z.object({ nativeSyncOut: z.boolean().optional(), persistentProcessSessions: z.boolean().optional(), independentControlCommands: z.boolean().optional(), + incrementalSessionOutput: z.boolean().optional(), }).strict(); export type SandboxProviderCapabilitiesInput = z.infer; diff --git a/server/src/__tests__/environment-config.test.ts b/server/src/__tests__/environment-config.test.ts index 76923fe25ba..faa56777305 100644 --- a/server/src/__tests__/environment-config.test.ts +++ b/server/src/__tests__/environment-config.test.ts @@ -121,6 +121,45 @@ describe("environment config helpers", () => { }); }); + it("loads a strict fake sandbox config that still carries the removed streamAgentSessionOutput key and drops it", () => { + // Session-output streaming moved from an operator flag to the capability + // snapshot. The fake sandbox schema is `.strict()`, so an undeclared key + // would fail validation. A saved config that still carries the removed key + // must load, and the removed key must not reach the stored config. + const config = normalizeEnvironmentConfig({ + driver: "sandbox", + config: { + provider: "fake", + image: "ubuntu:24.04", + streamAgentSessionOutput: true, + }, + }); + + expect(config).toEqual({ + provider: "fake", + image: "ubuntu:24.04", + reuseLease: false, + }); + expect(config).not.toHaveProperty("streamAgentSessionOutput"); + }); + + it("loads a plugin sandbox config that still carries the removed streamAgentSessionOutput key and drops it", () => { + // The plugin sandbox schema uses `.catchall`, so an unknown key passes + // through. The removed key must still drop, so no consumer reads a stale + // operator flag. + const config = normalizeEnvironmentConfig({ + driver: "sandbox", + config: { + provider: "fake-plugin", + image: "fake:test", + streamAgentSessionOutput: true, + }, + }); + + expect(config).not.toHaveProperty("streamAgentSessionOutput"); + expect(config).toMatchObject({ provider: "fake-plugin", image: "fake:test" }); + }); + it("parses a persisted sandbox environment into a typed driver config", () => { const parsed = parseEnvironmentDriverConfig({ driver: "sandbox", diff --git a/server/src/__tests__/environment-execution-target-capabilities.test.ts b/server/src/__tests__/environment-execution-target-capabilities.test.ts index deb3ba3c20c..d07e20f224e 100644 --- a/server/src/__tests__/environment-execution-target-capabilities.test.ts +++ b/server/src/__tests__/environment-execution-target-capabilities.test.ts @@ -18,6 +18,7 @@ const SNAPSHOT: EffectiveSandboxCapabilities = { nativeSyncOut: false, persistentProcessSessions: true, independentControlCommands: false, + incrementalSessionOutput: false, }; // A snapshot that grants every capability. A test overrides one flag to prove @@ -28,6 +29,7 @@ const FULL_GRANT: EffectiveSandboxCapabilities = { nativeSyncOut: true, persistentProcessSessions: true, independentControlCommands: true, + incrementalSessionOutput: true, }; // Build a sandbox execution target with a fixed snapshot and a fixed @@ -183,36 +185,48 @@ describe("effective snapshot gates the sync decision", () => { }); }); -describe("effective snapshot gates the session-output-streaming decision", () => { +// Session-output streaming is decided downstream from the carried snapshot +// alone (see `streamAgentSessionOutput` in `acpx-engine/execute.ts`): the bridge +// streams only when the snapshot grants `incrementalSessionOutput`. That key is +// opt-in, so a generic one-shot provider that keeps persistent process sessions +// and runs independent control commands, yet never declares incremental session +// output, keeps the poll path. These tests prove the target carries the exact +// capability that drives that decision. +describe("effective snapshot carries the session-output-streaming capability", () => { beforeEach(() => { mockResolveEnvironmentDriverConfigForRuntime.mockReset(); }); - it("keeps session output streaming on when the snapshot grants both session capabilities", async () => { - const { target } = await buildSandboxTarget({ - snapshot: FULL_GRANT, - supportsSync: false, - config: { streamAgentSessionOutput: true }, - }); - expect(target.streamAgentSessionOutput).toBe(true); + it("carries incremental session output when the snapshot grants it (stream on)", async () => { + const { target } = await buildSandboxTarget({ snapshot: FULL_GRANT, supportsSync: false }); + expect(target.effectiveCapabilities?.incrementalSessionOutput).toBe(true); }); - it("drops session output streaming when the snapshot removes persistent process sessions", async () => { + it("drops incremental session output when the snapshot removes it (poll)", async () => { const { target } = await buildSandboxTarget({ - snapshot: { ...FULL_GRANT, persistentProcessSessions: false }, + snapshot: { ...FULL_GRANT, incrementalSessionOutput: false }, supportsSync: false, - config: { streamAgentSessionOutput: true }, }); - expect(target.streamAgentSessionOutput).toBe(false); + expect(target.effectiveCapabilities?.incrementalSessionOutput).toBe(false); }); - it("drops session output streaming when the snapshot removes independent control commands", async () => { + it("keeps the poll path for a generic one-shot provider with broad caps but no streaming opt-in", async () => { + // The regression case: a generic one-shot provider (for example Modal) + // exposes the two broad session capabilities but never emits incremental + // session output. The streaming gate reads `incrementalSessionOutput`, so + // this provider keeps the poll path. const { target } = await buildSandboxTarget({ - snapshot: { ...FULL_GRANT, independentControlCommands: false }, + snapshot: { + ...FULL_GRANT, + persistentProcessSessions: true, + independentControlCommands: true, + incrementalSessionOutput: false, + }, supportsSync: false, - config: { streamAgentSessionOutput: true }, }); - expect(target.streamAgentSessionOutput).toBe(false); + expect(target.effectiveCapabilities?.persistentProcessSessions).toBe(true); + expect(target.effectiveCapabilities?.independentControlCommands).toBe(true); + expect(target.effectiveCapabilities?.incrementalSessionOutput).toBe(false); }); }); @@ -257,16 +271,16 @@ describe("a rejected capability resolution fails closed for persistent-session b expect(target.effectiveCapabilities).toBeUndefined(); }); - it("drops session output streaming when the resolution rejects", async () => { + it("carries no snapshot for session-output streaming when the resolution rejects", async () => { // A rejected resolution must not read as an open grant that enables - // streaming; keep the host output-file poll path. + // streaming. The target carries no snapshot, so the bridge keeps the host + // output-file poll path. const { target } = await buildSandboxTarget({ snapshot: null, supportsSync: false, - config: { streamAgentSessionOutput: true }, rejectResolution: true, }); - expect(target.streamAgentSessionOutput).toBe(false); + expect(target.effectiveCapabilities).toBeUndefined(); }); it("never forces the persistent session when the resolution rejects", async () => { diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index e7d02a16e1c..9cdac48a9a9 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -1882,12 +1882,16 @@ describe("environment routes", () => { expect(mockSecretService.create).not.toHaveBeenCalled(); }); - it("keeps host-owned stream flags when the provider plugin drops them from its normalized config", async () => { - // The host owns `streamRunLogs` and `streamAgentSessionOutput`. It reads - // them to select the run-log stream and the ACP session output stream. A - // provider plugin normalizes only its own driver fields, so it drops these - // host flags from its normalized config. The host must re-apply them, or the - // saved environment loses the operator opt-in and the streams never start. + it("keeps the host-owned stream flag and drops a removed flag a saved config still carries", async () => { + // The host owns `streamRunLogs`. It reads it to select the run-log stream. A + // provider plugin normalizes only its own driver fields, so it drops the host + // flag from its normalized config. The host must re-apply it, or the saved + // environment loses the operator opt-out and the stream never starts. + // + // `streamAgentSessionOutput` is a removed operator flag. A saved config can + // still carry it, but session-output streaming now follows the capability + // snapshot alone. The removed key must load and then drop, so it never + // reaches the persisted config. const environment = { ...createEnvironment(), id: "env-sandbox-fake-plugin", @@ -1897,11 +1901,9 @@ describe("environment routes", () => { }; mockEnvironmentService.create.mockResolvedValue(environment); mockValidatePluginSandboxProviderConfig.mockImplementation(async ({ provider, config }) => { - // Drop the host flags to reproduce a plugin that allowlists driver fields. - const { streamRunLogs, streamAgentSessionOutput, ...driverConfig } = - config as Record; + // Drop the host flag to reproduce a plugin that allowlists driver fields. + const { streamRunLogs, ...driverConfig } = config as Record; void streamRunLogs; - void streamAgentSessionOutput; return { normalizedConfig: driverConfig, pluginId: `plugin-${provider}`, @@ -1936,7 +1938,8 @@ describe("environment routes", () => { expect(res.status).toBe(201); const persisted = mockEnvironmentService.create.mock.calls[0][0].config as Record; - expect(persisted.streamAgentSessionOutput).toBe(true); + // The removed key never reaches the persisted config. + expect(persisted.streamAgentSessionOutput).toBeUndefined(); expect(persisted.streamRunLogs).toBe(false); }); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 7ada8c774b1..ad0ed77cb29 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -425,6 +425,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await db.delete(agentWakeupRequests); await db.delete(budgetPolicies); for (let attempt = 0; attempt < 5; attempt += 1) { + // A still-alive recovery child process can insert a new wakeup request + // or runtime-state row after the first delete. Re-clear both rows each + // attempt so a late insert cannot hold the agents foreign key. + await db.delete(agentWakeupRequests); await db.delete(agentRuntimeState); try { await db.delete(agents); diff --git a/server/src/__tests__/sandbox-capability-contract.test.ts b/server/src/__tests__/sandbox-capability-contract.test.ts index eb5da33f9a2..16300432a21 100644 --- a/server/src/__tests__/sandbox-capability-contract.test.ts +++ b/server/src/__tests__/sandbox-capability-contract.test.ts @@ -66,7 +66,6 @@ describe("sandbox capability contract normalizer", () => { const narrowing = buildSandboxCapabilityNarrowing({ leasePolicy: "ephemeral", leaseMetadata: { backend: "job" }, - config: {}, }); const effective = resolveEffectiveSandboxCapabilities({ verifiedMethods: ALL_PLUGIN_METHODS, @@ -83,59 +82,44 @@ describe("sandbox capability contract normalizer", () => { const flaggedNarrowing = buildSandboxCapabilityNarrowing({ leasePolicy: "ephemeral", leaseMetadata: { nativeFileSyncUnsupported: true }, - config: {}, }); expect(flaggedNarrowing.nativeSyncIn).toBe(false); expect(flaggedNarrowing.nativeSyncOut).toBe(false); }); - it("test_daytona_persistent_process_sessions_follows_use_sessions_config", () => { + it("test_persistent_process_sessions_follow_the_verified_and_declared_capability", () => { + // Session-output streaming now follows the capability snapshot alone, not a + // config flag. A provider that declares and verifies persistent process + // sessions keeps the capability when no narrowing removes it. const verifiedMethods = ["environmentExecute"]; const declared = { persistentProcessSessions: true }; - const sessionsOff = resolveEffectiveSandboxCapabilities({ - verifiedMethods, - declared, - narrowing: buildSandboxCapabilityNarrowing({ - leasePolicy: "ephemeral", - leaseMetadata: {}, - config: { useSessions: false }, - }), - }); - expect(sessionsOff.persistentProcessSessions).toBe(false); - - const sessionsOn = resolveEffectiveSandboxCapabilities({ - verifiedMethods, - declared, - narrowing: buildSandboxCapabilityNarrowing({ - leasePolicy: "ephemeral", - leaseMetadata: {}, - config: { useSessions: true }, - }), - }); - expect(sessionsOn.persistentProcessSessions).toBe(true); - - // A provider config that omits `useSessions` adds no narrowing here. - const noKey = buildSandboxCapabilityNarrowing({ + const narrowing = buildSandboxCapabilityNarrowing({ leasePolicy: "ephemeral", leaseMetadata: {}, - config: {}, }); - expect(noKey.persistentProcessSessions).toBeUndefined(); + // A normal lease adds no persistent-session narrowing. + expect(narrowing.persistentProcessSessions).toBeUndefined(); + + const effective = resolveEffectiveSandboxCapabilities({ + verifiedMethods, + declared, + narrowing, + }); + expect(effective.persistentProcessSessions).toBe(true); }); it("test_config_resolution_failure_fails_closed_on_persistent_process_sessions", () => { const verifiedMethods = ["environmentExecute"]; const declared = { persistentProcessSessions: true }; - // Config resolution failed, so the runtime cannot read `useSessions`. The - // narrowing must deny persistent process sessions instead of allowing them - // through an empty config. Without the fail-closed guard this narrowing key - // stays undefined and `persistentProcessSessions` resolves to true. + // Config resolution failed, so the provider is untrusted. The narrowing must + // deny persistent process sessions instead of allowing them through. Without + // the fail-closed guard this narrowing key stays undefined and + // `persistentProcessSessions` resolves to true. const narrowing = buildSandboxCapabilityNarrowing({ leasePolicy: "ephemeral", leaseMetadata: {}, - config: {}, configResolutionFailed: true, }); expect(narrowing.persistentProcessSessions).toBe(false); @@ -151,7 +135,6 @@ describe("sandbox capability contract normalizer", () => { const syncNarrowing = buildSandboxCapabilityNarrowing({ leasePolicy: "reuse_by_environment", leaseMetadata: { backend: "job" }, - config: {}, configResolutionFailed: true, }); expect(syncNarrowing.reusableLeases).toBe(true); @@ -241,6 +224,72 @@ describe("sandbox capability contract normalizer", () => { expect(allReuseVerbs.reusableLeases).toBe(true); }); + it("test_generic_one_shot_provider_does_not_get_session_output_streaming", () => { + // The regression: a generic one-shot provider (for example Modal) verifies + // `environmentExecute` and declares the two broad session capabilities, yet + // it never emits incremental session output. Both broad capabilities resolve + // true, but `incrementalSessionOutput` must stay false because the provider + // did not declare the opt-in behavior. The session-output streaming gate + // reads `incrementalSessionOutput`, so this provider keeps the poll path. + const effective = resolveEffectiveSandboxCapabilities({ + verifiedMethods: ["environmentExecute"], + declared: { + persistentProcessSessions: true, + independentControlCommands: true, + }, + }); + + expect(effective.persistentProcessSessions).toBe(true); + expect(effective.independentControlCommands).toBe(true); + // Opt-in denied: the provider did not declare incremental session output. + expect(effective.incrementalSessionOutput).toBe(false); + }); + + it("test_incremental_session_output_is_opt_in_and_needs_a_declaration", () => { + // An absent declaration denies the opt-in capability even when the worker + // verifies the prerequisite verb. This differs from a worker-property + // capability, which defers to the verified baseline. + const undeclared = resolveEffectiveSandboxCapabilities({ + verifiedMethods: ["environmentExecute"], + declared: null, + }); + expect(undeclared.incrementalSessionOutput).toBe(false); + + // A provider that declares the capability and verifies the prerequisite gets + // the streaming path. + const declared = resolveEffectiveSandboxCapabilities({ + verifiedMethods: ["environmentExecute"], + declared: { incrementalSessionOutput: true }, + }); + expect(declared.incrementalSessionOutput).toBe(true); + + // A declaration never grants the capability without the verified verb. + const declaredButUnverified = resolveEffectiveSandboxCapabilities({ + verifiedMethods: [], + declared: { incrementalSessionOutput: true }, + }); + expect(declaredButUnverified.incrementalSessionOutput).toBe(false); + }); + + it("test_config_resolution_failure_fails_closed_on_incremental_session_output", () => { + // Config resolution failed, so the provider is untrusted. The narrowing must + // deny incremental session output even with a positive declaration, so the + // session-output streaming gate fails closed to the poll path. + const narrowing = buildSandboxCapabilityNarrowing({ + leasePolicy: "ephemeral", + leaseMetadata: {}, + configResolutionFailed: true, + }); + expect(narrowing.incrementalSessionOutput).toBe(false); + + const effective = resolveEffectiveSandboxCapabilities({ + verifiedMethods: ["environmentExecute"], + declared: { incrementalSessionOutput: true }, + narrowing, + }); + expect(effective.incrementalSessionOutput).toBe(false); + }); + it("test_unknown_or_unavailable_verification_resolves_false", () => { const declaredAll = { reusableLeases: true, @@ -248,6 +297,7 @@ describe("sandbox capability contract normalizer", () => { nativeSyncOut: true, persistentProcessSessions: true, independentControlCommands: true, + incrementalSessionOutput: true, }; for (const verifiedMethods of [null, undefined, [] as string[]]) { diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index 433839de826..6cb113ed3f4 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -77,7 +77,6 @@ const fakeSandboxEnvironmentConfigSchema = z.object({ .default("ubuntu:24.04"), reuseLease: z.boolean().optional().default(false), streamRunLogs: z.boolean().optional(), - streamAgentSessionOutput: z.boolean().optional(), archiveOnRelease: z.boolean().optional(), }).strict(); @@ -94,7 +93,6 @@ const pluginSandboxEnvironmentConfigSchema = z.object({ timeoutMs: z.coerce.number().int().min(1).max(86_400_000).optional(), reuseLease: z.boolean().optional().default(false), streamRunLogs: z.boolean().optional(), - streamAgentSessionOutput: z.boolean().optional(), archiveOnRelease: z.boolean().optional(), }).catchall(z.unknown()); @@ -123,10 +121,29 @@ function getSandboxProvider(raw: Record) { return typeof raw.provider === "string" && raw.provider.trim().length > 0 ? raw.provider.trim() : "fake"; } +// Operator flags removed when session-output streaming moved to the verified +// capability snapshot. A saved environment config can still carry a removed key. +// The server now decides session-output streaming from the effective capability +// snapshot alone, so no consumer reads these flags. Strip a removed key before +// validation so a strict schema (the fake sandbox) still loads an old config, +// and so the removed flag never reaches the parsed config. +const REMOVED_SANDBOX_CONFIG_KEYS = ["streamAgentSessionOutput"] as const; + +function stripRemovedSandboxConfigKeys(raw: Record): Record { + if (!REMOVED_SANDBOX_CONFIG_KEYS.some((key) => key in raw)) { + return raw; + } + const next = { ...raw }; + for (const key of REMOVED_SANDBOX_CONFIG_KEYS) { + delete next[key]; + } + return next; +} + function parseSandboxEnvironmentConfig( input: Record | null | undefined, ) { - const raw = parseObject(input); + const raw = stripRemovedSandboxConfigKeys(parseObject(input)); const provider = getSandboxProvider(raw); if (provider === "fake") { @@ -373,15 +390,14 @@ export function stripSandboxProviderEnvelope(config: SandboxEnvironmentConfig): return driverConfig; } -// The host owns these sandbox run-behavior flags, not the provider plugin. The -// host reads them to select the run-log stream and the ACP session output -// stream. The host passes the whole config to the plugin, so a plugin that -// allowlists its own driver fields drops these flags from its normalized -// config. Re-apply them from the parsed envelope after the plugin normalizes, -// or a saved environment loses the operator opt-in and the stream never starts. +// The host owns this sandbox run-behavior flag, not the provider plugin. The +// host reads it to select the run-log stream. The host passes the whole config +// to the plugin, so a plugin that allowlists its own driver fields drops the +// flag from its normalized config. Re-apply it from the parsed envelope after +// the plugin normalizes, or a saved environment loses the operator opt-in and +// the stream never starts. const HOST_OWNED_SANDBOX_STREAM_FLAGS = [ "streamRunLogs", - "streamAgentSessionOutput", ] as const; function applyHostOwnedSandboxStreamFlags( diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index d2bb9d83d36..2689aaa2a8b 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -283,16 +283,6 @@ export async function resolveEnvironmentExecutionTarget(input: { !capabilityResolutionFailed && (!effectiveCapabilities || (effectiveCapabilities.nativeSyncIn && effectiveCapabilities.nativeSyncOut)); - // The persistent-session output-streaming path needs the provider to keep a - // persistent process session AND to run independent one-shot control - // commands beside the long-lived agent command. When the snapshot removes - // either capability, drop back to the host output-file poll path. When the - // resolution failed, fail closed and keep the poll path too. - const sessionOutputStreamingAllowed = - !capabilityResolutionFailed && - (!effectiveCapabilities || - (effectiveCapabilities.persistentProcessSessions && - effectiveCapabilities.independentControlCommands)); // A command that opts onto the persistent session needs the provider to keep // persistent process sessions. When the snapshot removes that capability, // never force the session; the command runs one-shot instead. When the @@ -315,13 +305,11 @@ export async function resolveEnvironmentExecutionTarget(input: { // output reaches the UI mid-run; `streamRunLogs: false` is an explicit // opt-out back to batch-at-end delivery. streamRunLogs: parsed.config.streamRunLogs !== false, - // Interactive ACP output streaming through the persistent session log - // stream. Default OFF: the process session bridge keeps the output-file - // poll unless an operator opts a sandbox environment in. The effective - // snapshot gates it too: a provider that cannot keep persistent process - // sessions or run independent control commands keeps the poll path. - streamAgentSessionOutput: - parsed.config.streamAgentSessionOutput === true && sessionOutputStreamingAllowed, + // Interactive ACP output streaming is decided downstream from the effective + // capability snapshot alone: the process session bridge streams only when + // the provider keeps persistent process sessions and runs independent + // control commands. The snapshot is absent when resolution failed, so the + // bridge fails closed to the output-file poll. runner: input.environmentRuntime && input.lease ? { // Provider-backed sandbox RPCs do not surface bounded mid-stream diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 58e721be0a8..9e5ecf93724 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -75,10 +75,24 @@ export const SANDBOX_CAPABILITY_KEYS = [ "nativeSyncOut", "persistentProcessSessions", "independentControlCommands", + "incrementalSessionOutput", ] as const; export type SandboxCapabilityKey = (typeof SANDBOX_CAPABILITY_KEYS)[number]; +/** + * Opt-in capability keys. Most capabilities are a worker property: an absent + * declaration defers to the verified baseline and allows the capability. An + * opt-in capability is a behavioral guarantee that only some providers make, so + * an absent declaration denies it. A generic one-shot provider must not get an + * opt-in capability just because its worker verifies a shared verb such as + * `environmentExecute`. Only a provider that declares the key `true` (and still + * verifies the prerequisites and passes narrowing) gets the capability. + */ +const SANDBOX_CAPABILITY_OPT_IN_KEYS: ReadonlySet = new Set([ + "incrementalSessionOutput", +]); + /** * Verified prerequisite mapping: the worker methods each capability requires. * @@ -103,6 +117,10 @@ export type SandboxCapabilityKey = (typeof SANDBOX_CAPABILITY_KEYS)[number]; * before it acquires a fresh lease. * - `persistentProcessSessions` and `independentControlCommands` require * `environmentExecute`; both run commands through it. + * - `incrementalSessionOutput` requires `environmentExecute` too, because the + * provider tails the session log through it. The verified verb is necessary + * but not sufficient: this key is opt-in, so the declaration is the real gate + * (see {@link SANDBOX_CAPABILITY_OPT_IN_KEYS}). */ const SANDBOX_CAPABILITY_PREREQUISITE_METHODS: Record = { // Reusable leases require ALL reuse verbs. Each verb is its own required @@ -115,6 +133,7 @@ const SANDBOX_CAPABILITY_PREREQUISITE_METHODS: Record { const verified = capabilityIsVerified(key, verifiedMethods); - // An absent declaration defers to the verified baseline (true = no extra - // restriction). A present declaration can only narrow. - const declaredAllows = declared[key] ?? true; + // An absent declaration defers to the verified baseline for a worker-property + // capability (true = no extra restriction), but denies an opt-in capability + // (false). A present declaration can only narrow. + const declaredDefault = SANDBOX_CAPABILITY_OPT_IN_KEYS.has(key) ? false : true; + const declaredAllows = declared[key] ?? declaredDefault; // An absent narrowing applies no restriction. const narrowingAllows = narrowing[key] ?? true; return verified && declaredAllows && narrowingAllows; @@ -195,6 +216,7 @@ export function resolveEffectiveSandboxCapabilities(input: { nativeSyncOut: resolve("nativeSyncOut"), persistentProcessSessions: resolve("persistentProcessSessions"), independentControlCommands: resolve("independentControlCommands"), + incrementalSessionOutput: resolve("incrementalSessionOutput"), }; } @@ -207,22 +229,19 @@ export function resolveEffectiveSandboxCapabilities(input: { * never reuses). * - a Kubernetes Job lease disables native sync (mirrors the native sync guard, * which falls back for a `job` backend or a `nativeFileSyncUnsupported` lease). - * - a session-based provider follows its `useSessions` config for persistent - * process sessions (default off); a config without the key adds no narrowing. * - `configResolutionFailed` marks that the runtime could not resolve the - * provider config. The runtime cannot read `useSessions`, so it fails closed - * and narrows `persistentProcessSessions` to false. An empty config alone does - * not fail closed; only a resolution error does. + * provider config. A provider whose config cannot be resolved is untrusted, so + * the runtime fails closed and narrows `persistentProcessSessions` and + * `incrementalSessionOutput` to false. An empty config alone does not fail + * closed; only a resolution error does. */ export function buildSandboxCapabilityNarrowing(input: { leasePolicy?: EnvironmentLease["leasePolicy"] | null; leaseMetadata?: Record | null; - config?: Record | null; configResolutionFailed?: boolean; }): Partial> { const narrowing: Partial> = {}; const metadata = input.leaseMetadata ?? {}; - const config = input.config ?? {}; narrowing.reusableLeases = input.leasePolicy === "reuse_by_environment"; @@ -232,11 +251,12 @@ export function buildSandboxCapabilityNarrowing(input: { } if (input.configResolutionFailed === true) { - // The runtime could not read `useSessions`, so it fails closed and denies - // persistent process sessions. + // The runtime could not resolve the provider config, so it fails closed and + // denies persistent process sessions and incremental session output. The + // session-output streaming gate reads `incrementalSessionOutput`, so it must + // narrow with the persistent-session gate to keep the fail-closed behavior. narrowing.persistentProcessSessions = false; - } else if ("useSessions" in config) { - narrowing.persistentProcessSessions = config.useSessions === true; + narrowing.incrementalSessionOutput = false; } return narrowing; @@ -1674,7 +1694,6 @@ function createSandboxEnvironmentDriver( let declared: SandboxProviderCapabilities | null = null; let verifiedMethods: readonly string[] = []; - let config: Record = {}; let configResolutionFailed = false; if (metadata.sandboxProviderPlugin) { @@ -1712,15 +1731,16 @@ function createSandboxEnvironmentDriver( verifiedMethods = pluginWorkerManager?.getWorker(pluginId)?.supportedMethods ?? []; declared = resolveDeclaredSandboxCapabilities(resolvedDriver.driver); try { - config = (await resolvePluginSandboxRuntimeConfig({ + // Resolve the provider config to confirm it is readable. A provider + // whose config cannot be resolved is untrusted, so a resolution error + // fails closed on persistent process sessions below. The resolved + // value itself is no longer read for the narrowing decision. + await resolvePluginSandboxRuntimeConfig({ environment: input.environment, lease: input.lease, provider: providerKey, - })) as unknown as Record; + }); } catch { - // The runtime could not resolve the provider config. It cannot read - // `useSessions`, so it fails closed on persistent process sessions - // instead of leaving the config empty and allowing them. configResolutionFailed = true; } } else { @@ -1734,7 +1754,6 @@ function createSandboxEnvironmentDriver( const narrowing = buildSandboxCapabilityNarrowing({ leasePolicy: input.lease.leasePolicy, leaseMetadata: metadata, - config, configResolutionFailed, });