feat(sandbox): add a duplex transport for Daytona behind a default-off kill switch (#11750)

## Thinking Path

> - Paperclip is an open source app that manages AI agents for work
> - Paperclip runs agents in local and remote sandbox environments
> - A sandbox needs a bounded channel for commands and asynchronous
input
> - Daytona needs a real pseudo-terminal transport for this channel
> - The sandbox gateway also needs a mode that handles channel loss
safely
> - This pull request adds the Daytona transport and gateway mode behind
a default-off kill switch
> - The benefit is a tested foundation for later transport selection

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting (multiple of the above): sandbox providers, plugin SDK,
server settings, and shared types.

**Problem or motivation**

The merged sandbox protocol has no runtime transport for Daytona. The
generated sandbox gateway also has no duplex mode. A later
transport-selection change needs both parts and a safe per-run gate.

**Proposed solution**

Add a Daytona `duplexCommandStream` transport over a raw
pseudo-terminal. Add a generated gateway mode named `duplex_v1`. Add the
`enableSandboxDuplexBridge` setting with a default value of `false`.
Keep transport selection disabled until a later pull request.

**Alternatives considered**

Keep the protocol unused until the transport-selection change. This
would delay provider tests and leave the gateway path without direct
coverage.

**Roadmap alignment**

This change supports the completed Roadmap item for cloud and sandbox
agents. It extends the merged sandbox channel foundation in pull request
#11738.

**Additional context**

The Daytona provider remains an untrusted boundary. Deployments must use
least-privilege provider credentials and provider-side quota controls.
Operators must name an owner for duplex telemetry retention before
rollout.

## What Changed

- Add the Daytona `duplexCommandStream` capability over a raw
pseudo-terminal.
- Add a launch wrapper that disables echo and newline translation for
NDJSON frames.
- Close channels on lease release, destroy, resume of a stopped worker,
and worker shutdown.
- Declare the capability in the Daytona manifest and set
`PLUGIN_VERSION` to `0.1.5`.
- Add the worker-to-host notification sink at `ctx.duplexChannel.data`
and `ctx.duplexChannel.exit`.
- Add the generated sandbox gateway mode
`PAPERCLIP_API_BRIDGE_MODE=duplex_v1`.
- Add channel-loss results of `409 outcome_indeterminate` and `503
bridge_unavailable`.
- Add the per-run setting `enableSandboxDuplexBridge`, with a default
value of `false`.
- Add unit tests, generated-source codec tests, lifecycle tests, and a
credential-gated live Daytona test.

## Verification

- Daytona suite: 185 tests pass.
- Adapter utilities: 754 tests pass and 4 tests skip.
- Plugin SDK: 62 tests pass.
- Shared package: 28 tests pass.
- Server duplex tests pass.
- Shared, plugin SDK, server, and Daytona TypeScript checks pass.
- The live Daytona test passes 3 cases when `DAYTONA_API_KEY` is set.
- The live Daytona test skips 3 cases without `DAYTONA_API_KEY`.
- CI must run the full workspace typecheck, test, and build gates after
PR creation.

## Risks

- The Daytona control plane and pseudo-terminal remain untrusted
boundaries.
- The duplex gateway changes behavior only when the mode and per-run
setting enable it.
- A lost channel fails requests without replay, so callers must handle
indeterminate outcomes.
- The transport-selection change must require both `duplexCommandStream
=== true` and `enableSandboxDuplexBridge === true`.
- The provider credential and quota limits need operator control before
rollout.

## Model Used

OpenAI Codex, GPT-5, tool use and code execution.

## 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: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-08-19 17:14:47 -07:00
1 parent d5416fde9a
commit faab2620ad
25 files changed
+2437 -133

No files matched your search

@@ -0,0 +1,69 @@
import { beforeAll, describe, expect, it } from "vitest";
import { createDb } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
environmentRuntimeService,
resolveSandboxDuplexBridgeInput,
} from "../services/environment-runtime.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres duplex bridge input tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describe("resolveSandboxDuplexBridgeInput", () => {
it("carries an enabled kill switch into the bridge input", () => {
expect(resolveSandboxDuplexBridgeInput({ enableSandboxDuplexBridge: true })).toEqual({
enableDuplexBridge: true,
});
});
it("keeps the file bridge when the kill switch is off", () => {
expect(resolveSandboxDuplexBridgeInput({ enableSandboxDuplexBridge: false })).toEqual({
enableDuplexBridge: false,
});
});
});
describeEmbeddedPostgres("environmentRuntimeService.readSandboxDuplexBridgeInput", () => {
let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>;
let runtime!: ReturnType<typeof environmentRuntimeService>;
let settings!: ReturnType<typeof instanceSettingsService>;
beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("environment-runtime-duplex-bridge");
stopDb = started.stop;
db = createDb(started.connectionString);
runtime = environmentRuntimeService(db);
settings = instanceSettingsService(db);
return async () => {
await stopDb?.();
};
});
it("defaults the per-run bridge input to the file bridge", async () => {
expect(await runtime.readSandboxDuplexBridgeInput()).toEqual({ enableDuplexBridge: false });
});
it("reads the enabled kill switch from instance settings for a run", async () => {
await settings.updateExperimental({ enableSandboxDuplexBridge: true });
expect(await runtime.readSandboxDuplexBridgeInput()).toEqual({ enableDuplexBridge: true });
});
it("returns to the file bridge when the kill switch is turned back off", async () => {
await settings.updateExperimental({ enableSandboxDuplexBridge: true });
await settings.updateExperimental({ enableSandboxDuplexBridge: false });
expect(await runtime.readSandboxDuplexBridgeInput()).toEqual({ enableDuplexBridge: false });
});
});
@@ -52,6 +52,7 @@ describe("instance settings service", () => {
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: false,
enableOwnerInstanceAdmin: false,
enableSandboxDuplexBridge: false,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
+42 -2
View File
@@ -7,6 +7,7 @@ import type {
EnvironmentLease,
EnvironmentLeaseStatus,
ExecutionWorkspace,
InstanceExperimentalSettings,
IssueExecutionWorkspaceSettings,
PluginEnvironmentConfig,
SandboxEnvironmentConfig,
@@ -32,6 +33,7 @@ import {
type StartupSpanContext,
} from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
import { environmentService } from "./environments.js";
import { instanceSettingsService } from "./instance-settings.js";
import {
collectEnvironmentSecretRefs,
parseEnvironmentDriverConfig,
@@ -321,6 +323,29 @@ export function buildEnvironmentLeaseContext(input: {
};
}
/**
* The per-run duplex bridge input. The host resolves it for each new run before
* it selects the sandbox callback bridge transport. When `enableDuplexBridge` is
* false the host keeps the file bridge with no manifest change and no redeploy.
* The transport selection reads this input in a later phase; this phase only
* delivers the setting and the per-run read.
*/
export interface ResolvedSandboxDuplexBridgeInput {
/** True only when the instance opts in to the sandbox duplex command stream. */
enableDuplexBridge: boolean;
}
/**
* Map the experimental instance setting `enableSandboxDuplexBridge` into the
* per-run duplex bridge input. The setting is the kill switch. It defaults off,
* so an absent or false setting keeps the file bridge.
*/
export function resolveSandboxDuplexBridgeInput(
experimental: Pick<InstanceExperimentalSettings, "enableSandboxDuplexBridge">,
): ResolvedSandboxDuplexBridgeInput {
return { enableDuplexBridge: experimental.enableSandboxDuplexBridge === true };
}
function stripSecretRefValuesFromPluginLeaseMetadata(input: {
metadata: Record<string, unknown> | null | undefined;
schema: Record<string, unknown> | null | undefined;
@@ -498,8 +523,12 @@ export interface EnvironmentDriverSyncInput extends EnvironmentDriverLeaseInput
}
export interface EnvironmentDriverOpenDuplexChannelInput extends EnvironmentDriverLeaseInput {
/** The command line the sandbox runs as the duplex channel child process. */
command: string;
/**
* The command argument vector the sandbox runs as the duplex channel child
* process. Element 0 is the program. The worker runs the vector with no
* shell, so a shell metacharacter in an element cannot inject a command.
*/
command: readonly string[];
}
export interface EnvironmentRuntimeDriver {
@@ -3138,6 +3167,17 @@ export function environmentRuntimeService(
return {
getDriver,
/**
* Read the sandbox duplex bridge kill switch for a new run. The host calls it
* per run before it selects the callback bridge transport. It reads the
* experimental instance setting `enableSandboxDuplexBridge` and maps it into
* the resolved bridge input. The default-off setting keeps the file bridge.
*/
async readSandboxDuplexBridgeInput(): Promise<ResolvedSandboxDuplexBridgeInput> {
const experimental = await instanceSettingsService(db).getExperimental();
return resolveSandboxDuplexBridgeInput(experimental);
},
async acquireRunLease(input: {
companyId: string;
environment: Environment;
+2
View File
@@ -245,6 +245,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? true,
enableWorkspaceDirtyQuarantineRepair: parsed.data.enableWorkspaceDirtyQuarantineRepair ?? true,
enableOwnerInstanceAdmin: parsed.data.enableOwnerInstanceAdmin ?? false,
enableSandboxDuplexBridge: parsed.data.enableSandboxDuplexBridge ?? false,
enableWorktreeRunExecution: parsed.data.enableWorktreeRunExecution ?? false,
worktreeRunExecutionActivatedAt: parsed.data.worktreeRunExecutionActivatedAt ?? null,
worktreeRunExecutionActivationInstanceId:
@@ -282,6 +283,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableOwnerInstanceAdmin: false,
enableSandboxDuplexBridge: false,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
+6 -1
View File
@@ -490,7 +490,12 @@ export interface DuplexChannelOpenInput {
companyId: string;
environmentId: string;
providerLeaseId: string;
command: string;
/**
* The command argument vector the worker runs on the channel. Element 0 is the
* program. The worker runs the vector with no shell, so a shell metacharacter
* in an element cannot inject a command.
*/
command: readonly string[];
}
/**