mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents can run in remote sandboxes (Daytona), where each turn needs
the task's workspace and a runner inside the sandbox
> - With a default environment, every remote turn got a brand-new
sandbox: the whole workspace was uploaded again and a new runner
started, even for a follow-up comment
> - In a paperclip-sized workspace that made a follow-up take 30 s to
first token, against 8–10 s with a reusable warm sandbox
> - This pull request makes an agent that never chose a lifecycle run
warm on a sandbox for the measured harnesses (Codex, OpenCode, Claude),
so a follow-up keeps its sandbox, runner and workspace
> - The default applies only where warm can work: a sandbox provider
that keeps reusable leases, and a managed AI account that supports a
warm session. Everywhere else the run is per-turn, exactly as before
> - The benefit is fast remote follow-ups by default, without
re-uploading a large repository every turn
## Linked Issues or Issue Description
**What happened?**
On Daytona, with a fresh paperclip clone as the project workspace, a
follow-up comment took 30 s to its first token. The run log showed
`environment: fresh/created` on every follow-up. It spent 13.3 s in
`workspace_sync.transfer`, a full upload into the new sandbox, and 8–9 s
in `runner.session.resume`.
**Steps to reproduce**
1. Create a Daytona environment with the defaults: lifecycle inherited,
lease reuse off.
2. Run a Codex task in a large repository, then post a follow-up.
3. The follow-up gets a new sandbox, uploads the whole workspace again
and starts a new runner.
**Expected behavior**
A follow-up on the same task reuses the live sandbox, its runner and its
workspace (plan rule 2: no full re-upload per turn).
## What Changed
- **One rule for the default.** `defaultsRemoteSandboxRunToWarm`
(`native-runtime/native-warm-capability.ts`) decides the remote-sandbox
warm default for both the lease (environment view) and the runner
(native session policy), in one vocabulary (runtime backend plus ACPX
agent). It is applied only when no lifecycle was chosen anywhere:
admitted session, environment `runnerLifecycleMode`, or agent
`lifecycleMode`. It holds only when:
1. the harness is measured (Codex, OpenCode, ACPX Claude);
2. the sandbox provider keeps reusable leases (Daytona, CreateOS and
boxd declare them; E2B, Modal and the built-in `fake` do not); and
3. with a managed AI account, that harness supports a managed warm
session (`supportsManagedNativeWarmSession`: Codex).
- **The harness resolves as the runtime resolves it.**
`resolveWarmProviderSelection` uses
`resolvePaperclipRunnerProviderProfile`, so an omitted `provider` is
Codex and ACPX without an agent is Claude, in both decisions. A config
the runtime would refuse gets no default.
- **Lease side** (`runner-environment-lifecycle.ts`,
`environment-run-orchestrator.ts`): for an eligible run only, the
orchestrator asks the provider before acquisition
(`providerSupportsReusableLeases` in `environment-runtime.ts`: the
acquisition rule, one registry lookup for a plugin provider, no
worker-ready wait). With support, the run-scoped view sets `reuseLease:
true` and forwards the warm idle window to the provider's auto-stop. The
shared environment is never rewritten. Every other run resolves exactly
as on master, without the probe.
- **Runner side** (`native-lifecycle.ts`, `runtime-selection.ts`): the
native policy applies the same rule to the acquired lease
(`reusableLeaseConfigured && effectiveCapabilities.reusableLeases`). If
acquisition downgrades the lease anyway, the run falls back to per-turn;
a default never produces
`runner_warm_environment_requires_reusable_lease`.
- **Idle window.** The remote default uses the window the environment
view forwards to the provider: an inheriting environment's
`runnerIdleTimeoutMs`, then the agent's `idleTimeoutMs`, then 5 minutes.
The sandbox execution target now carries the environment's window
(`runnerIdleTimeoutMs`, an optional field in `adapter-utils`'s execution
target) so the runner and the provider auto-stop agree. An explicit warm
choice and the local default are unchanged.
- Unchanged: explicit agent choices, environments that pin a lifecycle,
plugin-task environments, admitted sessions (`persistedPolicy`), and
local behavior.
- `tests/restart-chaos`: the vCPU budget check counts `--daytona-inherit
--lifecycle default` sandboxes as retained, because they now default to
warm.
- `doc/plans/2026-11-08-large-workspace-speed.md`: the measurements, the
ranked waits, and the gate.
## Verification
- Unit and integration tests, one per rule:
- Rule 1 (provider gate): `native-lifecycle.test.ts` (ephemeral lease →
per-turn), `runner-environment-lifecycle.test.ts` (no reusable leases →
environment unchanged, no warm window),
`environment-runtime-reusable-lease-probe.test.ts` (`fake`, an
undeclared plugin, a declared plugin whose worker does not verify the
reuse methods, and a plugin without a running worker read false),
`runtime-selection.test.ts` ("defaults an agent without a lifecycle on
...": reusable → warm/keep_running; reuse configured on an unsupported
provider → per_turn/destroy_after_turn; ephemeral → per_turn),
`environment-run-orchestrator.test.ts` (probe result decides
`reuseLease` and the forwarded window).
- Rule 2 (explicit configuration wins): explicit per-turn agent, pinned
environment, admitted session, configured `idleTimeoutMs`, managed
Claude per-turn, managed Codex warm; the orchestrator does not probe
when a lifecycle is explicit.
- Rule 3 (omitted provider): `{}` resolves to Codex in the environment
view; `runtime-selection.test.ts` "agrees with the environment view's
lease for ..." feeds the environment view's lease into selection for
Codex, a provider-less Codex agent, OpenCode, ACPX Claude, ACPX without
an agent, and Copilot (per-turn), and checks both decisions agree. A
plugin-task target stays out of the default.
- The two suites that failed on the previous head
(`runtime-selection.test.ts`,
`workspace-recovery-preparation-dispatch.test.ts`) pass locally.
Locally, 6 connector-skill cases in `runtime-selection.test.ts` fail
with `ENOENT .claude/skills/slack/SKILL.md` because this checkout is a
nested worktree; they do not touch this change and pass in CI.
- End-to-end check of this default on Daytona (before the gate was
added; Daytona keeps reusable leases, so the gate does not change this
path): a user-default environment (lifecycle inherited, lease reuse
unset, `--daytona-inherit`), a fresh paperclip clone, built from the
image's source revision with this change (branch measure/remote-warm).
| | Before (per-turn sandbox) | After (this change) |
|---|---|---|
| Follow-up, Codex | 30.4 s | 7.6 s |
| Follow-up, Claude | about 30 s | 11.0 s |
| First follow-up after an idle K1, Codex | 45.9 s | 20.3 s |
| First follow-up after an idle K1, Claude | 38.3 s | 18.3 s |
The run-log reuse records show the follow-ups reusing the workspace,
environment, runner and provider session, with no
`workspace_sync.transfer`. First turns still upload the workspace once
(14.8 s and 26.1 s). Every sandbox was cleaned up.
- Daytona restart chaos wave (K1/K2/K4 with a mid-run comment,
inheriting environment, agents with no lifecycle, runner image built
from this branch): wave `w-20261110T171438-s20048`, seed 20048, on
b7f6243d60 with image `paperclip-daytona-runner@sha256:76a33275…6fe29`
(built from this branch by the `image_only` runner workflow; runner
content id `ed503454…a479` matches every later commit here, which change
only server, adapter-utils types and tests). `--daytona-inherit
--lifecycle default --adapters codex,claude --workloads A1,C1 --triggers
time,comment --faults K1,K2,K4 --tasks 6 --concurrency 3`.
- **6/6 tasks pass** (R1–R6 and T), all 6 crossed a restart; cells:
Codex A1 K2, Codex A1 K4, Claude A1 K4, Claude C1 K1, Claude C1 K4,
Codex C1 K2 at the mid-run comment. **No task needed Retry** (R3 n/a on
every task; one Claude task recovered automatically from
`process_lost`). No open signatures, 0 speed breaches.
- The default applied: the R6 sweep found 6 retained reusable leases
(one per task, Daytona keeps them) and no orphan or leaked sandbox; the
environment was deleted cleanly.
- Timings (Daytona, first turns, n=3 each): TTFT p50 Codex 21.9 s,
Claude 31.0 s. Restart recovery p50/p95: Codex 10.5 s / 29.6 s (n=3),
Claude 8.8 s / 23.9 s (n=2). Warm follow-up TTFT has no clean sample in
this wave (both follow-ups were crossed by a fault), so the follow-up
comparison above (30.4 s → 7.6 s Codex, ~30 s → 11.0 s Claude) remains
the speed evidence; no separate per-turn chaos wave was run.
- An earlier attempt, `w-20261110T170357-s20048`, never started tasks:
the first sandbox from the new image stayed `creating` past the
harness's 300 s Codex qualification bound (Daytona building the new
snapshot). It left one sandbox that the harness reported after deleting
the environment; the rerun on the cached snapshot is the wave above.
- The idle-window fix (9a74116082) landed after the wave; it changes
only runs whose inheriting environment sets `runnerIdleTimeoutMs`, which
this wave's environment does not.
## Previous-release story
No persisted state changes shape. A session admitted per-turn before
this change keeps its saved policy (`persistedPolicy` wins) until a new
session starts. A run in flight at the cutover keeps its admitted
lifecycle for recovery (`admittedLifecycleMode` wins in the environment
view).
## Cost and opt-out
- **Cost:** after a turn, the sandbox and its warm runner stay up for
the warm idle window (the environment's `runnerIdleTimeoutMs`, else the
agent's `idleTimeoutMs`, else 5 minutes). Then the controller suspends
the runner, and the provider auto-stop follows the same window. One idle
window per active task.
- **Opt-out:** set the agent's lifecycle to "Turn by turn"
(`lifecycleMode: per_turn`), or set the environment's
`runnerLifecycleMode` to `per_turn`. Either wins over this default.
## Reuse scope, restart and saved work
- **One lineage per sandbox.** The Daytona plugin keys a reusable lease
by the agent plus the execution workspace (or issue). Another task never
gets a sandbox holding a different task's files, and subtasks still get
fresh workspaces from the project base, so they get their own sandbox.
The workspace is adopted only after its stamp matches the host's.
- **After a restart,** a warm sandbox is reattached or replaced through
the existing warm recovery and owner fencing. A sandbox with a live
owner is never claimed twice. The Daytona chaos wave above checks this.
- **No lost work.** Each turn's workspace copyback
(`workspace_finalize`) runs at the end of the turn, before any idle
suspend, as for per-turn runs.
## Risks
- **Cost:** an idle sandbox stays up for the warm idle window (5
minutes) after a turn, then suspends. Only on providers with reusable
leases.
- **Probe cost:** one plugin-registry lookup before acquisition, only
for runs the default can apply to. No wait for a worker: a provider
whose worker is not running yet reads as unsupported, so that run is
per-turn.
- **Known gap (pre-existing, not widened):** with an explicit agent warm
choice on an inheriting environment that sets `runnerIdleTimeoutMs`, the
provider gets the environment's window while the host uses the agent's,
as on master.
- **Restart behavior on warm remote sandboxes** uses the existing warm
recovery paths. The Daytona chaos wave above checks K1, K2 and K4.
- Sessions admitted per-turn before this change stay per-turn until a
new session starts.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file editing, test execution, Daytona measurements).
## 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
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
449 lines
18 KiB
TypeScript
449 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, environmentRuntime };
|
|
}
|
|
|
|
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 an inheriting environment's idle window for the remote warm default", async () => {
|
|
const { target } = await buildSandboxTarget({
|
|
snapshot: FULL_GRANT,
|
|
supportsSync: false,
|
|
config: { reuseLease: true, runnerIdleTimeoutMs: 900_000 },
|
|
});
|
|
expect(target.runnerLifecyclePolicy).toBeNull();
|
|
expect(target.runnerIdleTimeoutMs).toBe(900_000);
|
|
});
|
|
|
|
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.each(["syncIn", "syncOut"] as const)("carries a %s deadline only on the requested transfer", async method => {
|
|
const { target, environmentRuntime } = await buildSandboxTarget({ snapshot: FULL_GRANT, supportsSync: true });
|
|
const operations = [{ operationId: "archive-deadline", files: [] }];
|
|
await target.runner![method]!(operations, { minimumTimeoutMs: 900_000 });
|
|
await target.runner![method]!(operations);
|
|
const calls = vi.mocked(environmentRuntime[method]).mock.calls;
|
|
expect(calls[0]?.[0]).toMatchObject({ operations, minimumTimeoutMs: 900_000 });
|
|
expect(calls[1]?.[0]).toMatchObject({ operations });
|
|
expect(calls[1]?.[0]).not.toHaveProperty("minimumTimeoutMs");
|
|
});
|
|
|
|
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("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,
|
|
});
|
|
expect(target.effectiveCapabilities).toBeUndefined();
|
|
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();
|
|
});
|
|
});
|