mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## 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>
450 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|