Files
PaperClipAI/server/src/__tests__/environment-execution-target-capabilities.test.ts
T
Devin FoleyandPaperclip 0ba88d2195 fix(opencode-local): preserve deadlines in Daytona process sessions (#15905)
## Thinking Path

> - Paperclip manages work done by AI agents.
> - The OpenCode adapter can run its CLI in a provider sandbox.
> - A long CLI run must remain observable until it exits or the host
stops it.
> - The legacy main command does not request a provider process session.
A launch without an active run step uses one synchronous command
request.
> - That request can lose its response before the resolved execution
budget ends.
> - This PR requests the existing asynchronous session path for the main
Daytona OpenCode command. It also carries the resolved command deadline
through the host and provider contracts. The host still applies the
effective capability gate.
> - The change removes that synchronous request from eligible sandbox
runs.

## Linked Issues or Issue Description

Related reporting fix: #15789. A search of open and closed GitHub issues
and PRs found no duplicate transport fix.

**What happened?**

A hosted Linux legacy OpenCode run retained a gateway-timeout response
about one hour after CLI startup. It had a four-hour resolved budget and
recent agent output. The earlier reporting fix correctly kept the
configured policy separate from the timeout message. The original
response owner and specific timer were not retained, so this PR does not
claim to prove that provider cause.

**Expected behavior**

A Daytona target that supports persistent process sessions should
dispatch the long agent command asynchronously. Paperclip should observe
its output and exit through the existing session path. A single command
HTTP request should not need to stay open for the full agent run.

**Steps to reproduce**

1. Use legacy OpenCode with a Daytona sandbox target and a four-hour
execution budget.
2. Launch outside an active measured step. The current main CLI call
does not request a persistent process session.
3. In the SDK fixture, make the synchronous command request return a
provider timeout before the budget ends. Keep the asynchronous session
healthy for more than one hour.
4. The regression tests require session dispatch, incremental output,
one command dispatch, a real exit result, and session cleanup.
5. Set a shorter command deadline and stall dispatch, a status read or a
log snapshot. The command must return a timeout at its own deadline.
Healthy output must not extend that deadline.

**Paperclip version or commit**

Observed source: `e733bddc45e00efb1819fffa8677f7b0d07802c5`. PR base:
`4b59e2a2ecc67e98b336b8eb4c09959681672d54`.

**Deployment mode**

Hosted Linux server, legacy OpenCode adapter, Daytona sandbox. The host
gate retains the old path when a provider cannot keep process sessions.

## What Changed

- Add an optional sandbox session choice to the shared process helper.
Leave the choice absent for existing callers.
- Request that session only at the main Daytona OpenCode CLI invocation.
Keep probes, preparation and other providers on their existing paths.
- Carry a separate optional command deadline through the shared runner,
host service and plugin SDK. Existing session callers can omit it. Zero
leaves the command deadline disabled.
- Bound dispatch, live output and recovery observations with one command
timer. Clear it on settlement. Fence late stream callbacks. Remove
deadline listeners after each read and clear interrupted polling waits.
Preserve a known exit code if only the final logs time out. A timeout
result does not claim that the process stopped.
- Add adapter and process-helper regression coverage. Test the existing
Daytona session for a virtual run beyond one hour, without command
replay, with release cleanup.
- Document transport selection and the limits of the timeout evidence.

## Verification

- `pnpm -r typecheck` passed on Node 25.6.1 and pnpm 9.15.4 with the
command deadline changes.
- `pnpm build` passed with the updated contracts. The separately
installed Daytona package also passes its own typecheck and build
against the pinned SDK 0.203.0 and TypeScript 5.9.3.
- Five focused process, adapter, host and provider suites passed all 349
tests. They cover a run beyond one hour, five stalled observation
phases, healthy-stream deadlines, zero/absent budgets, late callbacks,
session release, SSH and capability fallback.
- The real environment-runtime forwarding tests passed: 19 selected
tests, including the separate command budget and forced session flag in
the worker RPC.
- An earlier full local `pnpm test:run` began before a clean build and
overlapped the correction. That mixed-version run was stopped and is not
passing evidence. After the build, the two previously uncollected suites
pass (1,659 tests). Two unchanged workspace fixtures fail with this
machine's Git default branch `main`; both pass with a test-process-only
`master` override. One unchanged native recovery fixture fails on both
this branch and an independently installed and built checkout of base
`4b59e2a2ecc67e98b336b8eb4c09959681672d54`, with the same
terminal-result mismatch. It is not introduced by this change. The full
local suite is not claimed green. These limitations do not replace fresh
hosted CI.
- Final head `8e96638cdef8ca6e91f7a42a52c83a075180c137` passes all
hosted checks: 54 passed and 2 expected Storybook skips. Greptile
reviewed this exact head at 18:12 UTC and scored it 5/5. Both actionable
findings are fixed and resolved. GitHub reports no conflicts and a clean
merge state. Earlier-head results are not used as final-head proof.
- The standalone provider test command passes 373 tests, with 14
expected live-probe skips.
- SDK tests use a controlled virtual clock. No live provider experiment,
task replay, manual deployment, or public PR merge was performed.

## Risks

The main legacy OpenCode command uses Daytona's existing session route
when the effective capability gate permits it. A quiet log stream can
use the existing status and snapshot fallback. Those observations add
provider reads. The new optional command budget is separate from each
read's timeout and returns a timeout without the host RPC's extra
30-second allowance. Existing session callers that omit it keep their
previous lifetime.

Callback bridge control commands retain their explicit session bypass.
The capability gate retains the previous path for unsupported targets.
Other providers, local and SSH execution keep their existing path. Retry
policy, Stop acknowledgement and lease ownership rules stay the same.
The SDK exposes no cancellation handle for an abandoned stream; existing
Stop and lease teardown still own command termination. This fix does not
establish the original gateway owner or prove recovery in a live
environment.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools and code
execution. The exact backend identifier and context window size are not
exposed by this session.

## 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 (changed paths; the
independently reproduced local baseline failure is documented above)
- [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>
2026-10-11 12:08:45 -07:00

450 lines
18 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockResolveEnvironmentDriverConfigForRuntime } = vi.hoisted(() => ({
mockResolveEnvironmentDriverConfigForRuntime: vi.fn(),
}));
vi.mock("../services/environment-config.js", () => ({
resolveEnvironmentDriverConfigForRuntime: mockResolveEnvironmentDriverConfigForRuntime,
}));
import type { EffectiveExecutionCapabilities } from "@paperclipai/adapter-utils/execution-target";
import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js";
import type { EnvironmentRuntimeService } from "../services/environment-runtime.js";
const SNAPSHOT: EffectiveExecutionCapabilities = {
reusableLeases: true,
nativeSyncIn: true,
nativeSyncOut: false,
persistentProcessSessions: true,
independentControlCommands: false,
incrementalSessionOutput: false,
// Concurrent sync operations need BOTH sync verbs; this snapshot verified only
// inbound sync, so the opt-in stays off.
concurrentSyncOperations: false,
duplexCommandStream: false,
runnerWebSocketIngress: false,
};
// A snapshot that grants every capability. A test overrides one flag to prove
// that the removed capability alone changes the runtime decision.
const FULL_GRANT: EffectiveExecutionCapabilities = {
reusableLeases: true,
nativeSyncIn: true,
nativeSyncOut: true,
persistentProcessSessions: true,
independentControlCommands: true,
incrementalSessionOutput: true,
concurrentSyncOperations: true,
duplexCommandStream: true,
runnerWebSocketIngress: true,
};
// Build a sandbox execution target with a fixed snapshot and a fixed
// `supportsSync` result. The helper returns the sandbox target so a test reads
// the runner and the streaming flag the snapshot gates.
async function buildSandboxTarget(input: {
snapshot: EffectiveExecutionCapabilities | null;
supportsSync: boolean;
config?: Record<string, unknown>;
// Reject the capability resolution to exercise the fail-closed error path.
rejectResolution?: boolean;
}) {
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
driver: "sandbox",
config: { provider: "daytona", timeoutMs: 30_000, ...(input.config ?? {}) },
});
const execute = vi.fn().mockResolvedValue({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "ok",
stderr: "",
metadata: { durationMs: 600, getDurationMs: 15 },
});
const environmentRuntime = {
execute,
supportsSync: () => input.supportsSync,
syncIn: vi.fn(),
syncOut: vi.fn(),
resolveCapabilities: vi.fn(async () => {
if (input.rejectResolution) {
throw new Error("capability resolution failed");
}
return input.snapshot ? Object.freeze({ ...input.snapshot }) : null;
}),
} as unknown as EnvironmentRuntimeService;
const target = await resolveEnvironmentExecutionTarget({
db: {} as never,
companyId: "company-1",
adapterType: "codex_local",
environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } },
leaseId: "lease-1",
leaseMetadata: { remoteCwd: "/work" },
lease: { id: "lease-1", leasePolicy: "reuse_by_environment" } as never,
environmentRuntime,
});
if (target?.kind !== "remote" || target.transport !== "sandbox") {
throw new Error("expected a sandbox target");
}
return { target, execute };
}
describe("resolveEnvironmentExecutionTarget effective capability snapshot", () => {
beforeEach(() => {
mockResolveEnvironmentDriverConfigForRuntime.mockReset();
});
it("test_execution_target_carries_read_only_effective_snapshot", async () => {
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
driver: "sandbox",
config: { provider: "daytona", reuseLease: true, timeoutMs: 30_000 },
});
const resolveCapabilities = vi.fn(async () => Object.freeze({ ...SNAPSHOT }));
const environmentRuntime = {
supportsSync: () => false,
resolveCapabilities,
} as unknown as EnvironmentRuntimeService;
const target = await resolveEnvironmentExecutionTarget({
db: {} as never,
companyId: "company-1",
adapterType: "codex_local",
environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } },
leaseId: "lease-1",
leaseMetadata: { remoteCwd: "/work" },
lease: { id: "lease-1", leasePolicy: "reuse_by_environment" } as never,
environmentRuntime,
});
expect(target?.kind).toBe("remote");
if (target?.kind !== "remote" || target.transport !== "sandbox") {
throw new Error("expected a sandbox target");
}
expect(resolveCapabilities).toHaveBeenCalledTimes(1);
expect(target.effectiveCapabilities).toEqual(SNAPSHOT);
// The snapshot is read-only: it is frozen, so a write does not change it.
expect(Object.isFrozen(target.effectiveCapabilities)).toBe(true);
const snapshot = target.effectiveCapabilities as EffectiveExecutionCapabilities;
try {
(snapshot as { reusableLeases: boolean }).reusableLeases = false;
} catch {
// A strict-mode assignment throws; a non-strict one is a silent no-op.
}
expect(snapshot.reusableLeases).toBe(true);
});
it("omits the snapshot when no environment runtime resolves it", async () => {
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
driver: "sandbox",
config: { provider: "daytona", reuseLease: false, timeoutMs: 30_000 },
});
const target = await resolveEnvironmentExecutionTarget({
db: {} as never,
companyId: "company-1",
adapterType: "codex_local",
environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } },
leaseId: "lease-1",
leaseMetadata: {},
lease: null,
environmentRuntime: null,
});
expect(target?.kind).toBe("remote");
if (target?.kind !== "remote" || target.transport !== "sandbox") {
throw new Error("expected a sandbox target");
}
expect(target.effectiveCapabilities).toBeUndefined();
});
// The static `hasLeaseCapabilityModel` trait is `true` only for the
// `sandbox` driver in this phase (see `environment-driver-traits.ts`). An
// `ssh` lease must never reach the general resolver: the new
// `resolveCapabilities` method never returns `null` for a registered
// driver, so an ungated call would turn "no snapshot" into "every
// capability denied" for a driver this file does not otherwise gate on.
it("never calls the general capability resolver for an ssh lease", async () => {
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
driver: "ssh",
config: {
host: "example.test",
port: 22,
username: "agent",
remoteWorkspacePath: "/work",
},
});
const resolveCapabilities = vi.fn(async () => ({ ...SNAPSHOT }));
const environmentRuntime = {
resolveCapabilities,
} as unknown as EnvironmentRuntimeService;
const target = await resolveEnvironmentExecutionTarget({
db: {} as never,
companyId: "company-1",
adapterType: "codex_local",
environment: { id: "env-1", driver: "ssh", config: {} },
leaseId: "lease-1",
leaseMetadata: {},
lease: { id: "lease-1", leasePolicy: "reuse_by_environment" } as never,
environmentRuntime,
});
expect(target?.kind).toBe("remote");
expect(resolveCapabilities).not.toHaveBeenCalled();
});
it("carries the environment-owned warm runner lifecycle and reuse requirement", async () => {
const { target } = await buildSandboxTarget({
snapshot: FULL_GRANT,
supportsSync: false,
config: {
reuseLease: true,
runnerLifecycleMode: "warm",
runnerIdleTimeoutMs: 45_000,
},
});
expect(target.runnerLifecyclePolicy).toEqual({
mode: "warm",
idleTimeoutMs: 45_000,
});
expect(target.reusableLeaseConfigured).toBe(true);
});
it("carries host-owned sandbox acquisition provenance without persisting provider ids in metadata", async () => {
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
driver: "sandbox",
config: { provider: "daytona", reuseLease: true, timeoutMs: 30_000 },
});
const target = await resolveEnvironmentExecutionTarget({
db: {} as never,
companyId: "company-1",
// This substrate PR does not advertise remote paperclip_runner support
// until the Rust WSS transport lands. A supported direct adapter exercises
// the same host-owned acquisition contract without widening rollout here.
adapterType: "codex_local",
environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } },
leaseId: "lease-row-1",
leaseMetadata: {
remoteCwd: "/work",
sandboxLeaseAcquisition: { outcome: "resumed" },
},
lease: {
id: "lease-row-1",
providerLeaseId: "daytona-sandbox-1",
leasePolicy: "reuse_by_environment",
metadata: { sandboxLeaseAcquisition: { outcome: "resumed" } },
} as never,
environmentRuntime: {
supportsSync: () => false,
resolveCapabilities: vi.fn(async () => ({ ...FULL_GRANT })),
} as unknown as EnvironmentRuntimeService,
});
if (target?.kind !== "remote" || target.transport !== "sandbox") {
throw new Error("expected a sandbox target");
}
expect(target.sandboxLeaseAcquisition).toEqual({
outcome: "resumed",
providerLeaseId: "daytona-sandbox-1",
});
});
});
describe("effective snapshot gates the sync decision", () => {
beforeEach(() => {
mockResolveEnvironmentDriverConfigForRuntime.mockReset();
});
it("exposes the native sync hooks when the snapshot grants both sync verbs", async () => {
const { target } = await buildSandboxTarget({ snapshot: FULL_GRANT, supportsSync: true });
expect(target.runner?.syncIn).toBeTypeOf("function");
expect(target.runner?.syncOut).toBeTypeOf("function");
});
it("omits the native sync hooks when the snapshot removes a sync verb", async () => {
// The snapshot verified inbound sync but not outbound sync. The runner
// exposes the sync hooks both-or-neither, so it keeps the base64 fallback.
const { target } = await buildSandboxTarget({
snapshot: { ...FULL_GRANT, nativeSyncOut: false },
supportsSync: true,
});
expect(target.runner?.syncIn).toBeUndefined();
expect(target.runner?.syncOut).toBeUndefined();
});
it("still requires supportsSync even when the snapshot grants native sync", async () => {
const { target } = await buildSandboxTarget({ snapshot: FULL_GRANT, supportsSync: false });
expect(target.runner?.syncIn).toBeUndefined();
expect(target.runner?.syncOut).toBeUndefined();
});
});
// The execution target carries the concurrent-sync opt-in on both the effective
// snapshot and the runner. The runner Boolean feeds the sync client, which then
// tells the orchestrator whether it may run sync operations concurrently.
describe("effective snapshot carries the concurrent-sync capability", () => {
beforeEach(() => {
mockResolveEnvironmentDriverConfigForRuntime.mockReset();
});
it("carries the concurrent-sync opt-in on the snapshot and the runner", async () => {
const { target } = await buildSandboxTarget({ snapshot: FULL_GRANT, supportsSync: true });
expect(target.effectiveCapabilities?.concurrentSyncOperations).toBe(true);
expect(target.runner?.allowConcurrentSyncOperations).toBe(true);
});
it("keeps the concurrent-sync opt-in off when the snapshot removes it", async () => {
const { target } = await buildSandboxTarget({
snapshot: { ...FULL_GRANT, concurrentSyncOperations: false },
supportsSync: true,
});
expect(target.effectiveCapabilities?.concurrentSyncOperations).toBe(false);
expect(target.runner?.allowConcurrentSyncOperations).toBe(false);
});
it("keeps the concurrent-sync opt-in off when the resolution rejects", async () => {
// A rejected resolution carries no snapshot; it must not read as an open
// grant. The runner keeps the opt-in off.
const { target } = await buildSandboxTarget({
snapshot: null,
supportsSync: true,
rejectResolution: true,
});
expect(target.effectiveCapabilities).toBeUndefined();
expect(target.runner?.allowConcurrentSyncOperations).toBe(false);
});
});
// 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("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 incremental session output when the snapshot removes it (poll)", async () => {
const { target } = await buildSandboxTarget({
snapshot: { ...FULL_GRANT, incrementalSessionOutput: false },
supportsSync: false,
});
expect(target.effectiveCapabilities?.incrementalSessionOutput).toBe(false);
});
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,
persistentProcessSessions: true,
independentControlCommands: true,
incrementalSessionOutput: false,
},
supportsSync: false,
});
expect(target.effectiveCapabilities?.persistentProcessSessions).toBe(true);
expect(target.effectiveCapabilities?.independentControlCommands).toBe(true);
expect(target.effectiveCapabilities?.incrementalSessionOutput).toBe(false);
});
});
describe("effective snapshot gates the persistent-session execution decision", () => {
beforeEach(() => {
mockResolveEnvironmentDriverConfigForRuntime.mockReset();
});
it("forces the persistent session when the snapshot grants persistent process sessions", async () => {
const { target, execute } = await buildSandboxTarget({ snapshot: FULL_GRANT, supportsSync: false });
await target.runner?.execute({ command: "node", args: ["agent.js"], useSession: true, commandTimeoutMs: 12000 });
const call = execute.mock.calls[0]![0] as { forceSession?: boolean };
expect(call.forceSession).toBe(true);
expect(call).toHaveProperty("commandTimeoutMs", 12000);
});
it("never forces the persistent session when the snapshot removes persistent process sessions", async () => {
// The bridge still asks for the session with `useSession`, but the provider
// cannot keep one, so the seam runs the command one-shot instead.
const { target, execute } = await buildSandboxTarget({
snapshot: { ...FULL_GRANT, persistentProcessSessions: false },
supportsSync: false,
});
await target.runner?.execute({ command: "node", args: ["agent.js"], useSession: true });
const call = execute.mock.calls[0]![0] as { forceSession?: boolean };
expect(call.forceSession).toBe(false);
});
});
describe("a rejected capability resolution fails closed for persistent-session behavior", () => {
beforeEach(() => {
mockResolveEnvironmentDriverConfigForRuntime.mockReset();
});
it("omits the snapshot when the resolution rejects", async () => {
// A rejected resolution carries no snapshot; the target never publishes a
// guessed capability set.
const { target } = await buildSandboxTarget({
snapshot: null,
supportsSync: false,
rejectResolution: true,
});
expect(target.effectiveCapabilities).toBeUndefined();
});
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. The target carries no snapshot, so the bridge keeps the host
// output-file poll path.
const { target } = await buildSandboxTarget({
snapshot: null,
supportsSync: false,
rejectResolution: true,
});
expect(target.effectiveCapabilities).toBeUndefined();
});
it("never forces the persistent session when the resolution rejects", async () => {
// The bridge asks for the session with `useSession`, but a rejected
// resolution fails closed, so the seam runs the command one-shot instead.
const { target, execute } = await buildSandboxTarget({
snapshot: null,
supportsSync: false,
rejectResolution: true,
});
await target.runner?.execute({ command: "node", args: ["agent.js"], useSession: true });
const call = execute.mock.calls[0]![0] as { forceSession?: boolean };
expect(call.forceSession).toBe(false);
});
it("omits the native sync hooks when the resolution rejects", async () => {
// The worker advertises both sync verbs, but a rejected resolution must not
// read as an open grant. Fail closed and keep the base64 fallback, so an
// unverified provider never gets the native sync path.
const { target } = await buildSandboxTarget({
snapshot: null,
supportsSync: true,
rejectResolution: true,
});
expect(target.runner?.syncIn).toBeUndefined();
expect(target.runner?.syncOut).toBeUndefined();
});
});