mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +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>
717 lines
35 KiB
TypeScript
717 lines
35 KiB
TypeScript
import { environmentTaskOperationSchema } from "@paperclipai/plugin-sdk";
|
|
import type { Db } from "@paperclipai/db";
|
|
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
|
|
import { adapterSupportsRemoteManagedEnvironments } from "@paperclipai/shared";
|
|
import {
|
|
adapterExecutionTargetToRemoteSpec,
|
|
type AdapterExecutionTarget,
|
|
type SandboxLeaseAcquisition,
|
|
} from "@paperclipai/adapter-utils/execution-target";
|
|
import type { DuplexObservabilityRecorder } from "@paperclipai/adapter-utils/duplex-observability";
|
|
import {
|
|
clampSpanLabel,
|
|
getActiveStepContext,
|
|
normalizeProviderFamily,
|
|
SANDBOX_STARTUP_OUTCOME,
|
|
SANDBOX_STARTUP_SPAN_ATTRS,
|
|
} from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
|
|
import { parseObject } from "../adapters/utils.js";
|
|
import { getStartupTracer } from "../instrumentation.js";
|
|
import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js";
|
|
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
|
|
import { getEnvironmentDriverTraits } from "./environment-driver-traits.js";
|
|
|
|
export const DEFAULT_SANDBOX_REMOTE_CWD = "/tmp";
|
|
|
|
/** The minimal span surface the provider-exec seam calls. A real injected OTel
|
|
* span satisfies it; the no-op tracer's span satisfies it too. */
|
|
type ExecSpan = {
|
|
setAttribute(key: string, value: string | number | boolean): void;
|
|
setStatus(status: { code: number; message?: string }): void;
|
|
end(): void;
|
|
};
|
|
|
|
/**
|
|
* The value of `SpanStatusCode.ERROR` in `@opentelemetry/api`. The server injects
|
|
* a real OTel span, but this module stays OTel-free, so it uses the numeric value
|
|
* directly. A failed exec span sets this status, so a trace UI counts and filters
|
|
* the failure through the native span status, not only the `outcome` attribute.
|
|
*/
|
|
const SPAN_STATUS_CODE_ERROR = 2;
|
|
|
|
/** The minimal tracer surface the provider-exec seam calls. `getStartupTracer`
|
|
* returns a real or no-op implementation that satisfies it. The optional third
|
|
* argument is the explicit parent-context token: the seam passes the active
|
|
* step context, so the exec span parents to its step span. */
|
|
type ExecTracer = {
|
|
startSpan(name: string, options?: unknown, context?: unknown): ExecSpan;
|
|
};
|
|
|
|
/**
|
|
* Set a numeric span attribute only when the value is a finite number. A value
|
|
* that is absent, `NaN`, or `Infinity` yields no attribute — never a misleading
|
|
* `0`. This mirrors the host counter guard below and the `adapter-utils`
|
|
* startup-step guard.
|
|
*/
|
|
function setFiniteNumberAttr(span: ExecSpan, key: string, value: unknown): void {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
span.setAttribute(key, value);
|
|
}
|
|
}
|
|
|
|
/** Read a free-form metadata value as a finite number, or `undefined`. The
|
|
* provider durations ride the exec result's untyped `metadata`, so a provider
|
|
* that omits or mistypes one yields no attribute — never a misleading `0`. */
|
|
function toFiniteNumber(value: unknown): number | undefined {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
}
|
|
|
|
/** Read a free-form metadata value as a boolean, or `undefined`. The provider
|
|
* cache-hit flag rides the exec result's untyped `metadata`, so a provider that
|
|
* omits or mistypes it yields no attribute — never a misleading `false`. */
|
|
function toBoolean(value: unknown): boolean | undefined {
|
|
return typeof value === "boolean" ? value : undefined;
|
|
}
|
|
|
|
function sandboxLeaseAcquisitionFromMetadata(
|
|
value: unknown,
|
|
providerLeaseId: string | null | undefined,
|
|
): SandboxLeaseAcquisition | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const candidate = value as Record<string, unknown>;
|
|
if (
|
|
candidate.outcome !== "created" &&
|
|
candidate.outcome !== "resumed" &&
|
|
candidate.outcome !== "replacement"
|
|
) return null;
|
|
const resolvedProviderLeaseId =
|
|
typeof candidate.providerLeaseId === "string" && candidate.providerLeaseId
|
|
? candidate.providerLeaseId
|
|
: providerLeaseId;
|
|
if (!resolvedProviderLeaseId) return null;
|
|
const reason = candidate.reason;
|
|
if (
|
|
reason !== undefined &&
|
|
reason !== "not_found" &&
|
|
reason !== "expired" &&
|
|
reason !== "identity_mismatch" &&
|
|
reason !== "resume_failed"
|
|
) return null;
|
|
return {
|
|
outcome: candidate.outcome,
|
|
providerLeaseId: resolvedProviderLeaseId,
|
|
...(typeof candidate.previousProviderLeaseId === "string"
|
|
? { previousProviderLeaseId: candidate.previousProviderLeaseId }
|
|
: {}),
|
|
...(reason ? { reason } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Compute the tail of `final` that the provider did NOT already stream.
|
|
*
|
|
* The provider streams output chunks in order. Those chunks form `delivered`.
|
|
* The final result is `final`. In the normal path `final` continues
|
|
* `delivered`, so the tail is `final` past the delivered length.
|
|
*
|
|
* A provider can stream a prefix and then fall back to a poll that returns a
|
|
* different buffer. When `final` does not start with `delivered`, a length
|
|
* slice would drop unrelated leading output or cut a chunk mid-text, so the
|
|
* durable log would hold truncated or corrupt output. In that case this
|
|
* function returns the whole `final` instead. That can repeat the streamed
|
|
* prefix in the log, but the complete final output always reaches the log.
|
|
* Repetition is safer than a silent loss of output.
|
|
*/
|
|
function undeliveredSuffix(delivered: string, final: string): string {
|
|
if (!final) return "";
|
|
if (delivered.length === 0) return final;
|
|
if (final.startsWith(delivered)) return final.slice(delivered.length);
|
|
// Some providers retain only the last 4 MiB while streaming the full output.
|
|
// Replaying that captured tail would count its usage receipts a second time.
|
|
if (delivered.endsWith(final)) return "";
|
|
return final;
|
|
}
|
|
|
|
/**
|
|
* The closed input for one `sandbox.exec` span. The seam builds it from the
|
|
* exec result and the active step context. Every field is already bounded or
|
|
* numeric; the raw command clamps inside the helper below.
|
|
*/
|
|
interface SandboxExecSpanInput {
|
|
/** The low-cardinality provider family (already through `normalizeProviderFamily`). */
|
|
provider: string;
|
|
/** The raw `argv[0]`. The helper clamps it; the raw value never rides the span. */
|
|
command: string;
|
|
/** The numeric process exit code, or `null`. */
|
|
exitCode: number | null;
|
|
/** The host-measured wall time of the execution. */
|
|
wallMs: number;
|
|
/** The provider handle-fetch wait before the execution ran. */
|
|
waitBeforeMs: number | undefined;
|
|
/** The in-sandbox run time of the execution. */
|
|
sandboxMs: number | undefined;
|
|
/** Whether the execution sits on the startup critical path. */
|
|
criticalPath: boolean;
|
|
/** Whether the provider served the sandbox handle from its warm cache. */
|
|
cacheHit: boolean | undefined;
|
|
}
|
|
|
|
/**
|
|
* Assemble every `sandbox.exec` span attribute in one place. This is the single
|
|
* producer-side boundary for the exec span: it sets only the closed
|
|
* `paperclip.sandbox.startup.exec.*` allowlist. The command rides only as a
|
|
* clamped label, so a full command line, an argument, a path, an environment
|
|
* value, or any standard-stream text can never ride the span. A non-finite
|
|
* numeric input yields no attribute (fail open — never `NaN`, never a
|
|
* misleading `0`).
|
|
*/
|
|
function setSandboxExecSpanAttributes(span: ExecSpan, input: SandboxExecSpanInput): void {
|
|
const A = SANDBOX_STARTUP_SPAN_ATTRS;
|
|
span.setAttribute(A.provider, input.provider);
|
|
const command = clampSpanLabel("command", input.command);
|
|
if (command !== undefined) span.setAttribute(A.execCommand, command);
|
|
if (typeof input.exitCode === "number" && Number.isFinite(input.exitCode)) {
|
|
span.setAttribute(A.execExitCode, input.exitCode);
|
|
}
|
|
setFiniteNumberAttr(span, A.execWallMs, input.wallMs);
|
|
setFiniteNumberAttr(span, A.execWaitBeforeMs, input.waitBeforeMs);
|
|
setFiniteNumberAttr(span, A.execSandboxMs, input.sandboxMs);
|
|
// The transport time the host adds around the provider work: wall minus the
|
|
// handle-fetch wait minus the in-sandbox run. Set it only when both parts are
|
|
// present, so a provider that reports no durations yields no derived value.
|
|
if (input.waitBeforeMs !== undefined && input.sandboxMs !== undefined) {
|
|
setFiniteNumberAttr(span, A.execNetworkMs, input.wallMs - input.waitBeforeMs - input.sandboxMs);
|
|
}
|
|
span.setAttribute(A.execCriticalPath, input.criticalPath);
|
|
// The explicit provider cache-hit flag, from `result.metadata.cacheHit`. The
|
|
// plugin decides it at the handle lookup, so the span no longer infers a
|
|
// cache hit from `wait_before_ms == 0`. A provider that omits it yields no
|
|
// attribute.
|
|
if (typeof input.cacheHit === "boolean") {
|
|
span.setAttribute(A.execCacheHit, input.cacheHit);
|
|
}
|
|
const failed = input.exitCode !== 0;
|
|
span.setAttribute(
|
|
A.outcome,
|
|
failed ? SANDBOX_STARTUP_OUTCOME.failed : SANDBOX_STARTUP_OUTCOME.ok,
|
|
);
|
|
// A non-zero exit code is a failed execution, so set the native span status to
|
|
// ERROR too. The success path leaves the status unset, so a successful exec
|
|
// keeps the default OTel status. A `null` exit code counts as failed here.
|
|
if (failed) span.setStatus({ code: SPAN_STATUS_CODE_ERROR });
|
|
}
|
|
|
|
/**
|
|
* Record a failed `sandbox.exec` span when the provider execution throws. There
|
|
* is no exec result, so only the bounded provider family, the clamped command
|
|
* label, the measured wall time, the critical-path flag, and the `failed`
|
|
* outcome ride the span. The raw command never rides the span. A thrown
|
|
* execution now still produces a span, instead of no span at all.
|
|
*/
|
|
function setSandboxExecSpanFailure(
|
|
span: ExecSpan,
|
|
input: { provider: string; command: string; wallMs: number; criticalPath: boolean },
|
|
): void {
|
|
const A = SANDBOX_STARTUP_SPAN_ATTRS;
|
|
span.setAttribute(A.provider, input.provider);
|
|
const command = clampSpanLabel("command", input.command);
|
|
if (command !== undefined) span.setAttribute(A.execCommand, command);
|
|
setFiniteNumberAttr(span, A.execWallMs, input.wallMs);
|
|
span.setAttribute(A.execCriticalPath, input.criticalPath);
|
|
span.setAttribute(A.outcome, SANDBOX_STARTUP_OUTCOME.failed);
|
|
// A thrown execution is a failed execution, so set the native span status to
|
|
// ERROR too, not only the `outcome` attribute.
|
|
span.setStatus({ code: SPAN_STATUS_CODE_ERROR });
|
|
}
|
|
|
|
export async function resolveEnvironmentExecutionTarget(input: {
|
|
db: Db;
|
|
companyId: string;
|
|
adapterType: string;
|
|
environment: {
|
|
id?: string;
|
|
driver: string;
|
|
config: Record<string, unknown> | null;
|
|
};
|
|
leaseId?: string | null;
|
|
leaseMetadata: Record<string, unknown> | null;
|
|
lease?: EnvironmentLease | null;
|
|
environmentRuntime?: EnvironmentRuntimeService | null;
|
|
// The startup tracer for the provider-exec span. Defaults to the endpoint-
|
|
// gated server tracer, which is a no-op when tracing is off. Tests inject a
|
|
// recording tracer.
|
|
tracer?: ExecTracer;
|
|
// The host duplex observability recorder. The seam stamps it onto the sandbox
|
|
// target next to the runner, so the live object stays on the host and never
|
|
// enters the sandbox environment. Absent keeps the safe no-op default in the
|
|
// bridge, so the surface stays inert until the host injects a real recorder.
|
|
duplexObservabilityRecorder?: DuplexObservabilityRecorder | null;
|
|
}): Promise<AdapterExecutionTarget | null> {
|
|
if (input.environment.driver === "local") {
|
|
return {
|
|
kind: "local",
|
|
environmentId: input.environment.id ?? null,
|
|
leaseId: input.leaseId ?? null,
|
|
};
|
|
}
|
|
|
|
if (input.environment.driver === "plugin" && input.adapterType === "paperclip_runner") {
|
|
const lease = input.lease;
|
|
const runtime = input.environmentRuntime;
|
|
if (!lease || !runtime || lease.companyId !== input.companyId ||
|
|
lease.environmentId !== input.environment.id || !await runtime.supportsTasks({ lease })) {
|
|
throw new Error("Plugin environment does not provide native Runner task execution");
|
|
}
|
|
// The Runner resolves its workspace locally; this is only a relative scope marker.
|
|
const acquisition:SandboxLeaseAcquisition = {outcome:lease.metadata?.runnerTaskReused === true ? "resumed" : "created",providerLeaseId:lease.providerLeaseId!};
|
|
const target: Extract<AdapterExecutionTarget,{kind:"remote";transport:"sandbox"}> = {
|
|
kind: "remote", transport: "sandbox", providerKey: lease.provider ?? "plugin",
|
|
environmentId: input.environment.id, leaseId: lease.id, remoteCwd: ".",
|
|
runnerLifecyclePolicy: lease.metadata?.runnerTaskLifecyclePolicy as { mode: "warm"; idleTimeoutMs: number } | undefined ?? { mode: "per_turn", idleTimeoutMs: null },
|
|
reusableLeaseConfigured: lease.leasePolicy === "reuse_by_environment",
|
|
effectiveCapabilities: { reusableLeases: lease.leasePolicy === "reuse_by_environment", nativeSyncIn: false, nativeSyncOut: false,
|
|
persistentProcessSessions: false, independentControlCommands: false, incrementalSessionOutput: false,
|
|
concurrentSyncOperations: false, duplexCommandStream: false, runnerWebSocketIngress: true },
|
|
sandboxLeaseAcquisition:acquisition,
|
|
...(lease.metadata?.runnerTaskProbe ? { runnerTaskProbe: lease.metadata.runnerTaskProbe as { runId: string; sessionId: string } } : {}),
|
|
runnerTask: async (operation, context) => {
|
|
const result = await runtime.task({ companyId: input.companyId, leaseId: lease.id,
|
|
operation: environmentTaskOperationSchema.parse(operation),
|
|
...(context?.executionContext ? { executionContext: context.executionContext } : {}),
|
|
...(context?.expectedTaskId !== undefined ? { expectedTaskId: context.expectedTaskId } : {}) });
|
|
if (result.kind === "description" && result.taskId !== acquisition.providerLeaseId) {
|
|
acquisition.outcome = "replacement";
|
|
acquisition.providerLeaseId = result.taskId;
|
|
}
|
|
return result;
|
|
},
|
|
};
|
|
return target;
|
|
}
|
|
|
|
if (input.environment.driver === "sandbox") {
|
|
// Keep this gate in lockstep with the shared capability metadata that the
|
|
// environment selector and capabilities API expose; a drift here lets the
|
|
// UI offer environments the runtime then refuses.
|
|
if (!adapterSupportsRemoteManagedEnvironments(input.adapterType)) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = await resolveEnvironmentDriverConfigForRuntime(input.db, input.companyId, {
|
|
id: input.environment.id,
|
|
driver: input.environment.driver as "sandbox",
|
|
config: parseObject(input.environment.config),
|
|
});
|
|
if (parsed.driver !== "sandbox") {
|
|
return null;
|
|
}
|
|
|
|
const remoteCwd =
|
|
typeof input.leaseMetadata?.remoteCwd === "string" && input.leaseMetadata.remoteCwd.trim().length > 0
|
|
? input.leaseMetadata.remoteCwd.trim()
|
|
: DEFAULT_SANDBOX_REMOTE_CWD;
|
|
const timeoutMs = "timeoutMs" in parsed.config ? parsed.config.timeoutMs : null;
|
|
const shellCommand =
|
|
input.leaseMetadata?.shellCommand === "bash" || input.leaseMetadata?.shellCommand === "sh"
|
|
? input.leaseMetadata.shellCommand
|
|
: null;
|
|
|
|
// The low-cardinality public provider family. A plugin-backed / operator-
|
|
// defined key maps to `plugin`, so a raw unbounded key never rides a span.
|
|
const providerFamily = normalizeProviderFamily(parsed.config.provider);
|
|
// The endpoint-gated startup tracer (no-op when tracing is off). Tests inject
|
|
// a recording tracer.
|
|
const tracer = input.tracer ?? getStartupTracer();
|
|
|
|
// Resolve the read-only effective capability snapshot for this lease
|
|
// through the general resolver. Freeze it so a consumer reads it but
|
|
// never changes it. Track a resolution error apart from a genuinely
|
|
// absent snapshot: a rejected resolution must not read as an open grant.
|
|
//
|
|
// Gate the call on the `hasLeaseCapabilityModel` trait, not on whether the
|
|
// service exposes the method: the general resolver's `resolveCapabilities`
|
|
// never returns `null` for a registered driver, and it resolves every
|
|
// capability `false` for a driver with no lease capability model (see
|
|
// `ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT`). Calling it unconditionally
|
|
// would turn "no snapshot" into "every capability denied" for a driver
|
|
// this branch does not otherwise gate on. Reading the trait keeps that
|
|
// behavior change out of this phase: only the `sandbox` driver has a
|
|
// lease capability model today, and this branch only runs for `sandbox`.
|
|
let effectiveCapabilities: Awaited<
|
|
ReturnType<NonNullable<EnvironmentRuntimeService["resolveCapabilities"]>>
|
|
> | null = null;
|
|
let capabilityResolutionFailed = false;
|
|
const driverHasLeaseCapabilityModel =
|
|
getEnvironmentDriverTraits(input.environment.driver)?.hasLeaseCapabilityModel ?? false;
|
|
if (driverHasLeaseCapabilityModel && input.environmentRuntime?.resolveCapabilities && input.lease) {
|
|
try {
|
|
effectiveCapabilities = await input.environmentRuntime.resolveCapabilities({
|
|
environment: input.environment as Environment,
|
|
lease: input.lease,
|
|
});
|
|
} catch {
|
|
// The runtime could not resolve the snapshot. Fail closed for the
|
|
// persistent-session gates below; never grant persistent-session
|
|
// behavior from an unknown capability set.
|
|
capabilityResolutionFailed = true;
|
|
effectiveCapabilities = null;
|
|
}
|
|
}
|
|
|
|
// Gate the sync, session, and execution decisions below on the effective
|
|
// snapshot. A genuinely absent snapshot (no runtime, no lease) keeps the
|
|
// prior behavior, so it never removes a working path. A present snapshot
|
|
// can only remove a capability, never add one back.
|
|
//
|
|
// Native file sync needs BOTH sync verbs: the runner exposes syncIn and
|
|
// syncOut both-or-neither, so a consumer either uses the native path for
|
|
// both directions or keeps the base64 fallback for both. When the snapshot
|
|
// removes either verb, keep the byte-identical base64 fallback. When the
|
|
// resolution failed, fail closed and keep the base64 fallback too: an
|
|
// unverified provider never gets the native sync path. This preserves the
|
|
// existing reusable-lease sync enforcement unchanged.
|
|
const nativeSyncAllowed =
|
|
!capabilityResolutionFailed &&
|
|
(!effectiveCapabilities ||
|
|
(effectiveCapabilities.nativeSyncIn && effectiveCapabilities.nativeSyncOut));
|
|
// A command that opts onto the persistent session needs the provider to keep
|
|
// persistent process sessions. When the snapshot removes that capability,
|
|
// never force the session; the command runs one-shot instead. When the
|
|
// resolution failed, fail closed and never force the session.
|
|
const persistentSessionsAllowed =
|
|
!capabilityResolutionFailed &&
|
|
(!effectiveCapabilities || effectiveCapabilities.persistentProcessSessions);
|
|
|
|
// Resolve the per-run duplex bridge kill switch. It rides the host-side
|
|
// sandbox target on the same seam as `effectiveCapabilities`, so the value
|
|
// stays on the host and never enters the sandbox environment. Fail closed:
|
|
// an absent runtime, an absent method, or a read error keeps the file
|
|
// bridge. The stamp never turns a read error into a grant.
|
|
let enableSandboxDuplexBridge = false;
|
|
if (input.environmentRuntime?.readSandboxDuplexBridgeInput) {
|
|
try {
|
|
const duplexBridgeInput = await input.environmentRuntime.readSandboxDuplexBridgeInput();
|
|
enableSandboxDuplexBridge = duplexBridgeInput.enableDuplexBridge === true;
|
|
} catch {
|
|
enableSandboxDuplexBridge = false;
|
|
}
|
|
}
|
|
|
|
return {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: parsed.config.provider,
|
|
shellCommand,
|
|
remoteCwd,
|
|
enableSandboxDuplexBridge,
|
|
runnerLifecyclePolicy:
|
|
parsed.config.runnerLifecycleMode === "warm"
|
|
? {
|
|
mode: "warm",
|
|
idleTimeoutMs:
|
|
typeof parsed.config.runnerIdleTimeoutMs === "number"
|
|
? parsed.config.runnerIdleTimeoutMs
|
|
: 300_000,
|
|
}
|
|
: parsed.config.runnerLifecycleMode === "per_turn"
|
|
? { mode: "per_turn", idleTimeoutMs: null }
|
|
: null,
|
|
...(typeof parsed.config.runnerIdleTimeoutMs === "number" ? { runnerIdleTimeoutMs: parsed.config.runnerIdleTimeoutMs } : {}),
|
|
reusableLeaseConfigured: parsed.config.reuseLease === true,
|
|
sandboxLeaseAcquisition: sandboxLeaseAcquisitionFromMetadata(
|
|
input.lease?.metadata?.sandboxLeaseAcquisition,
|
|
input.lease?.providerLeaseId,
|
|
),
|
|
// Attach the host duplex observability recorder next to the runner. The bridge
|
|
// binds it to the fixed observability surface. Absent keeps the no-op
|
|
// default, so the surface stays inert on a run with no injected recorder.
|
|
duplexObservabilityRecorder: input.duplexObservabilityRecorder ?? null,
|
|
...(effectiveCapabilities ? { effectiveCapabilities: Object.freeze({ ...effectiveCapabilities }) } : {}),
|
|
...(input.environmentRuntime?.getRunnerIngressEndpoint && input.lease
|
|
? {
|
|
getRunnerIngressEndpoint: ({ port, path }) =>
|
|
input.environmentRuntime!.getRunnerIngressEndpoint({
|
|
environment: input.environment as Environment,
|
|
lease: input.lease!,
|
|
port,
|
|
path,
|
|
}),
|
|
}
|
|
: {}),
|
|
environmentId: input.environment.id ?? null,
|
|
leaseId: input.leaseId ?? null,
|
|
timeoutMs,
|
|
// Run-log streaming defaults ON for sandbox environments so agent CLI
|
|
// output reaches the UI mid-run; `streamRunLogs: false` is an explicit
|
|
// opt-out back to batch-at-end delivery.
|
|
streamRunLogs: parsed.config.streamRunLogs !== false,
|
|
// Interactive ACP output streaming is decided downstream from the effective
|
|
// capability snapshot alone: the process session bridge streams only when
|
|
// the provider keeps persistent process sessions and runs independent
|
|
// control commands. The snapshot is absent when resolution failed, so the
|
|
// bridge fails closed to the output-file poll.
|
|
runner: input.environmentRuntime && input.lease
|
|
? {
|
|
// Provider-backed sandbox RPCs do not surface bounded mid-stream
|
|
// progress for a single stdin upload, so keep the capability disabled
|
|
// here. The client falls back to the chunked upload path when this is
|
|
// false.
|
|
supportsSingleStreamStdinProgress: false,
|
|
// Carry the verified concurrent-sync opt-in to the sync client. The
|
|
// client copies it onto the native path and ignores it on the base64
|
|
// fallback, which always permits concurrency. A null snapshot or a
|
|
// provider that never opted in keeps it false, so an unverified
|
|
// provider never permits concurrent sync operations.
|
|
allowConcurrentSyncOperations: effectiveCapabilities?.concurrentSyncOperations === true,
|
|
execute: async (commandInput) => {
|
|
// Record true start and stop timestamps around the provider await,
|
|
// so the exec span and the result carry a real wall time.
|
|
const startedAtMs = Date.now();
|
|
const startedAt = new Date(startedAtMs).toISOString();
|
|
// Open one `sandbox.exec` span BEFORE the provider await, so the
|
|
// native span duration covers the whole execution and a thrown
|
|
// execution still produces a span. The span parents to the active
|
|
// step span. `startSpan` sits inside a guard; observability must
|
|
// never change execution control flow, and a no-op tracer
|
|
// (tracing off) makes the whole block inert.
|
|
const activeStep = getActiveStepContext();
|
|
const criticalPath = activeStep?.criticalPath ?? true;
|
|
let span: ExecSpan | null = null;
|
|
try {
|
|
span = tracer.startSpan("sandbox.exec", undefined, activeStep?.parentContext);
|
|
} catch {
|
|
span = null;
|
|
}
|
|
try {
|
|
// Classify the span outcome from the provider execution ONLY.
|
|
// The inner try/catch wraps just the provider await, so a thrown
|
|
// provider execution marks the span failed. A later log-callback
|
|
// rejection sits outside this block and never flips a successful
|
|
// execution to failed.
|
|
// Incremental log sink. The provider streams each output chunk
|
|
// through the execute.log notification while the command runs.
|
|
// Serialize the delivery per execute call so the runner sees the
|
|
// chunks in order, and keep the delivered text per stream, so the
|
|
// final-result delivery below emits only the un-streamed suffix
|
|
// and can detect a provider poll fallback that returns a
|
|
// different buffer.
|
|
let incrementalLogChain: Promise<void> = Promise.resolve();
|
|
let deliveredStdout = "";
|
|
let deliveredStderr = "";
|
|
const onIncrementalLog = (
|
|
stream: "stdout" | "stderr",
|
|
chunk: string,
|
|
): Promise<void> => {
|
|
if (stream === "stdout") deliveredStdout += chunk;
|
|
else deliveredStderr += chunk;
|
|
incrementalLogChain = incrementalLogChain.then(() =>
|
|
commandInput.onLog?.(stream, chunk),
|
|
);
|
|
return incrementalLogChain;
|
|
};
|
|
let result;
|
|
try {
|
|
result = await input.environmentRuntime!.execute({
|
|
environment: input.environment as Environment,
|
|
lease: input.lease!,
|
|
command: commandInput.command,
|
|
args: commandInput.args,
|
|
cwd: commandInput.cwd ?? remoteCwd,
|
|
env: commandInput.env,
|
|
stdin: commandInput.stdin,
|
|
timeoutMs: commandInput.timeoutMs,
|
|
commandTimeoutMs: commandInput.commandTimeoutMs,
|
|
onLog: commandInput.onLog ? onIncrementalLog : undefined,
|
|
// The ACP process session bridge sets `useSession` so its
|
|
// long-lived agent command opens the persistent session and
|
|
// streams output, even though it runs with no active step.
|
|
// The effective snapshot gates it: a provider that cannot
|
|
// keep persistent process sessions never forces the session,
|
|
// so the command runs one-shot instead.
|
|
forceSession: persistentSessionsAllowed ? commandInput.useSession : false,
|
|
// The bridge control-plane execs set `bypassSession` so they
|
|
// run one-shot and never queue behind the long-lived agent
|
|
// command on the persistent session. An explicit bypass wins
|
|
// over `forceSession` and over the active-step selection.
|
|
bypassSession: commandInput.bypassSession,
|
|
});
|
|
} catch (error) {
|
|
// The provider execution threw. Mark the span failed with the
|
|
// measured wall time, then rethrow the original error unchanged.
|
|
if (span) {
|
|
try {
|
|
setSandboxExecSpanFailure(span, {
|
|
provider: providerFamily,
|
|
command: commandInput.command,
|
|
wallMs: Date.now() - startedAtMs,
|
|
criticalPath,
|
|
});
|
|
} catch {
|
|
// Observability must not change execution control flow.
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
// The provider execution succeeded. The span timing and outcome
|
|
// come from the command result, not from the log callbacks below.
|
|
const finishedAtMs = Date.now();
|
|
const finishedAt = new Date(finishedAtMs).toISOString();
|
|
const durationMs = finishedAtMs - startedAtMs;
|
|
// `setSandboxExecSpanAttributes` sets ONLY the closed
|
|
// `paperclip.sandbox.startup.exec.*` allowlist: the normalized
|
|
// provider family, the clamped command label, the numeric exit
|
|
// code, the wall / wait-before / sandbox / network times, the
|
|
// critical-path flag, and the outcome. The full command, args,
|
|
// env, stdout, and stderr never ride the span.
|
|
if (span) {
|
|
try {
|
|
setSandboxExecSpanAttributes(span, {
|
|
provider: providerFamily,
|
|
command: commandInput.command,
|
|
exitCode: result.exitCode,
|
|
wallMs: durationMs,
|
|
waitBeforeMs: toFiniteNumber(result.metadata?.getDurationMs),
|
|
sandboxMs: toFiniteNumber(result.metadata?.durationMs),
|
|
criticalPath,
|
|
cacheHit: toBoolean(result.metadata?.cacheHit),
|
|
});
|
|
} catch {
|
|
// Observability must not change execution control flow.
|
|
}
|
|
}
|
|
// Drain the ordered incremental delivery before the final
|
|
// result. The provider streamed chunks arrive as execute.log
|
|
// notifications while the command runs; awaiting the chain keeps
|
|
// the runner order and surfaces a log-sink rejection.
|
|
await incrementalLogChain;
|
|
// Deliver only the suffix the provider did NOT already stream.
|
|
// The streamed chunks usually form an in-order prefix of the
|
|
// final result, so the remaining output is the final text past
|
|
// the delivered text. When the provider streamed nothing, the
|
|
// whole output is the suffix. When it streamed the complete
|
|
// output, the suffix is empty and nothing repeats. When it
|
|
// streamed a prefix and then fell back to a poll whose buffer
|
|
// does not continue that prefix, `undeliveredSuffix` returns the
|
|
// whole final output, so the durable log keeps the complete
|
|
// result and never holds a truncated slice. A rejected `onLog`
|
|
// still propagates to the caller (control flow is unchanged),
|
|
// but the span already carries the successful outcome, so a log
|
|
// failure never marks the execution failed.
|
|
const stdoutSuffix = undeliveredSuffix(deliveredStdout, result.stdout ?? "");
|
|
if (stdoutSuffix) await commandInput.onLog?.("stdout", stdoutSuffix);
|
|
const stderrSuffix = undeliveredSuffix(deliveredStderr, result.stderr ?? "");
|
|
if (stderrSuffix) await commandInput.onLog?.("stderr", stderrSuffix);
|
|
return {
|
|
exitCode: result.exitCode,
|
|
signal: result.signal ?? null,
|
|
timedOut: result.timedOut,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
pid: null,
|
|
startedAt,
|
|
finishedAt,
|
|
durationMs,
|
|
};
|
|
} finally {
|
|
if (span) {
|
|
try {
|
|
span.end();
|
|
} catch {
|
|
// Observability must not change execution control flow.
|
|
}
|
|
}
|
|
}
|
|
},
|
|
// Expose the native file-sync capability only when the provider's
|
|
// worker advertises BOTH sync verbs AND the effective snapshot still
|
|
// grants native sync; otherwise leave syncIn/syncOut undefined so
|
|
// the orchestrator keeps the byte-identical base64 path.
|
|
...(nativeSyncAllowed &&
|
|
input.environmentRuntime.supportsSync({
|
|
environment: input.environment as Environment,
|
|
lease: input.lease,
|
|
})
|
|
? {
|
|
syncIn: (operations, options) =>
|
|
input.environmentRuntime!.syncIn({
|
|
environment: input.environment as Environment,
|
|
lease: input.lease!,
|
|
operations,
|
|
...(options?.minimumTimeoutMs === undefined ? {} : { minimumTimeoutMs: options.minimumTimeoutMs }),
|
|
}),
|
|
syncOut: (operations, options) =>
|
|
input.environmentRuntime!.syncOut({
|
|
environment: input.environment as Environment,
|
|
lease: input.lease!,
|
|
operations,
|
|
...(options?.minimumTimeoutMs === undefined ? {} : { minimumTimeoutMs: options.minimumTimeoutMs }),
|
|
}),
|
|
}
|
|
: {}),
|
|
// Expose the duplex channel only when the effective snapshot grants
|
|
// the opt-in `duplexCommandStream` capability. A null snapshot
|
|
// (resolution failed or the snapshot is not resolvable) leaves the
|
|
// member undefined, so the caller keeps the file bridge. This mirrors
|
|
// the syncIn/syncOut gate above and fails closed.
|
|
// HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback.
|
|
...(effectiveCapabilities?.duplexCommandStream
|
|
? {
|
|
openDuplexChannel: (channelInput) =>
|
|
input.environmentRuntime!.openDuplexChannel({
|
|
environment: input.environment as Environment,
|
|
lease: input.lease!,
|
|
command: channelInput.command,
|
|
}),
|
|
}
|
|
: {}),
|
|
}
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
if (
|
|
!adapterSupportsRemoteManagedEnvironments(input.adapterType) ||
|
|
input.environment.driver !== "ssh"
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = await resolveEnvironmentDriverConfigForRuntime(input.db, input.companyId, {
|
|
id: input.environment.id,
|
|
driver: input.environment.driver as "ssh",
|
|
config: parseObject(input.environment.config),
|
|
});
|
|
if (parsed.driver !== "ssh") {
|
|
return null;
|
|
}
|
|
|
|
const remoteCwd =
|
|
typeof input.leaseMetadata?.remoteCwd === "string" && input.leaseMetadata.remoteCwd.trim().length > 0
|
|
? input.leaseMetadata.remoteCwd.trim()
|
|
: parsed.config.remoteWorkspacePath;
|
|
|
|
return {
|
|
kind: "remote",
|
|
transport: "ssh",
|
|
environmentId: input.environment.id ?? null,
|
|
leaseId: input.leaseId ?? null,
|
|
remoteCwd,
|
|
spec: {
|
|
host: parsed.config.host,
|
|
port: parsed.config.port,
|
|
username: parsed.config.username,
|
|
remoteWorkspacePath: parsed.config.remoteWorkspacePath,
|
|
privateKey: parsed.config.privateKey,
|
|
knownHosts: parsed.config.knownHosts,
|
|
strictHostKeyChecking: parsed.config.strictHostKeyChecking,
|
|
remoteCwd,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function resolveEnvironmentExecutionTransport(
|
|
input: Parameters<typeof resolveEnvironmentExecutionTarget>[0],
|
|
): Promise<Record<string, unknown> | null> {
|
|
return adapterExecutionTargetToRemoteSpec(await resolveEnvironmentExecutionTarget(input)) as Record<string, unknown> | null;
|
|
}
|