mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
## Thinking Path > - Paperclip provides a control plane for companies that run AI agents. > - Sandboxed agents need a safe execution path for persistent command streams. > - The existing callback transport does not provide a bounded, generic duplex route. > - The host must control capability access, route identity, protocol limits, and close behavior. > - This pull request adds an opt-in duplex command-stream foundation across the sandbox layers. > - The feature stays inert because no current provider declares the capability. ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting (multiple of the above) **Problem or motivation** Sandbox command execution needs a persistent host-to-sandbox stream. The current callback bridge uses a file transport and does not provide this generic route. **Proposed solution** Add a fail-closed provider capability, generic worker protocol messages, a host-owned bounded route, cross-layer service mediation, and a versioned newline-delimited frame codec. **Alternatives considered** Keep the file transport and add feature-specific commands. This does not provide one reusable duplex contract or host-owned route bounds. **Roadmap alignment** This work supports the completed Cloud / Sandbox agents roadmap area and the safe autonomy goal in the product definition. **Additional context** The change passed a two-stage security review. The final code review verdict was approve after fixes for active-stream bounds and service-layer capability mediation. ## What Changed - Add the opt-in `duplexCommandStream` provider capability with fail-closed narrowing. - Add duplex open, write, stop, and close requests and data and exit notifications to the plugin worker protocol. - Add a host-owned route with bounds for chunk size, cumulative bytes, lifetime, protocol errors, pending requests, and pre-bind buffering. - Add close acknowledgement handling with worker retirement when the close remains unconfirmed. - Wire `openDuplexChannel` through the execution target, runtime service, and plugin worker. - Add a versioned frame codec with shared wire-compatibility vectors and split UTF-8 handling. ## Verification - `server/src/__tests__/plugin-worker-manager-duplex.test.ts` passes 18 tests. - `server/src/__tests__/environment-execution-target-duplex.test.ts` passes 11 tests. - `packages/adapter-utils/src/duplex-frame-codec.test.ts` passes 38 tests. - `server/src/__tests__/sandbox-capability-contract.test.ts` passes 15 tests. - Setup-token pseudo-terminal regression tests pass 47 tests. - Server TypeScript check passes. - Continuous integration will run the full required test, typecheck, build, and policy checks. ## Risks - Providers that opt into the capability must implement the complete worker protocol. - Route limit defaults can close a stream when a workload exceeds the configured bounds. - The capability remains disabled for current providers, so current production behavior does not change. ## Model Used OpenAI GPT-5 (`gpt-5`), with tool use and code execution. The model reviewed and prepared this pull request from the supplied implementation and verification record. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
388 lines
16 KiB
TypeScript
388 lines
16 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
SANDBOX_CAPABILITY_KEYS,
|
|
buildSandboxCapabilityNarrowing,
|
|
builtinSandboxProviderVerifiedMethods,
|
|
resolveEffectiveSandboxCapabilities,
|
|
} from "../services/environment-runtime.js";
|
|
|
|
// The worker verbs a fully-capable plug-in provider advertises.
|
|
const ALL_PLUGIN_METHODS = [
|
|
"environmentAcquireLease",
|
|
"environmentResumeLease",
|
|
"environmentReleaseLease",
|
|
"environmentDestroyLease",
|
|
"environmentExecute",
|
|
"environmentSyncIn",
|
|
"environmentSyncOut",
|
|
];
|
|
|
|
describe("sandbox capability contract normalizer", () => {
|
|
it("test_absent_declaration_defers_to_worker_supported_methods_discovery", () => {
|
|
// No declaration at all. The effective set must fall back to what the worker
|
|
// verified, so a third-party provider that implements the sync hooks keeps
|
|
// native sync without declaring it.
|
|
const effective = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["environmentSyncIn", "environmentSyncOut"],
|
|
declared: null,
|
|
});
|
|
|
|
expect(effective.nativeSyncIn).toBe(true);
|
|
expect(effective.nativeSyncOut).toBe(true);
|
|
// The worker did not verify these verbs, so the baseline is false.
|
|
expect(effective.persistentProcessSessions).toBe(false);
|
|
expect(effective.reusableLeases).toBe(false);
|
|
});
|
|
|
|
it("test_effective_capabilities_are_subset_of_verified_and_declared", () => {
|
|
const verifiedMethods = ["environmentExecute"];
|
|
const declared = {
|
|
persistentProcessSessions: true,
|
|
independentControlCommands: false,
|
|
nativeSyncIn: true,
|
|
};
|
|
const effective = resolveEffectiveSandboxCapabilities({ verifiedMethods, declared });
|
|
|
|
// Verified + declared true.
|
|
expect(effective.persistentProcessSessions).toBe(true);
|
|
// Declared false, so removed even though verified.
|
|
expect(effective.independentControlCommands).toBe(false);
|
|
// Declared true but not verified, so removed.
|
|
expect(effective.nativeSyncIn).toBe(false);
|
|
|
|
// Every effective capability must be a subset of the verified set and the
|
|
// declaration: an effective `true` never appears where the worker did not
|
|
// verify or the declaration set `false`.
|
|
const verifiedOnly = resolveEffectiveSandboxCapabilities({ verifiedMethods });
|
|
for (const key of SANDBOX_CAPABILITY_KEYS) {
|
|
if (effective[key]) {
|
|
expect(verifiedOnly[key]).toBe(true);
|
|
expect((declared as Record<string, boolean | undefined>)[key]).not.toBe(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("test_kubernetes_job_lease_disables_native_sync", () => {
|
|
const narrowing = buildSandboxCapabilityNarrowing({
|
|
leasePolicy: "ephemeral",
|
|
leaseMetadata: { backend: "job" },
|
|
});
|
|
const effective = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ALL_PLUGIN_METHODS,
|
|
declared: { nativeSyncIn: true, nativeSyncOut: true },
|
|
narrowing,
|
|
});
|
|
|
|
expect(effective.nativeSyncIn).toBe(false);
|
|
expect(effective.nativeSyncOut).toBe(false);
|
|
// A non-sync capability is unaffected by the job-lease narrowing.
|
|
expect(effective.persistentProcessSessions).toBe(true);
|
|
|
|
// The `nativeFileSyncUnsupported` lease flag narrows the same way.
|
|
const flaggedNarrowing = buildSandboxCapabilityNarrowing({
|
|
leasePolicy: "ephemeral",
|
|
leaseMetadata: { nativeFileSyncUnsupported: true },
|
|
});
|
|
expect(flaggedNarrowing.nativeSyncIn).toBe(false);
|
|
expect(flaggedNarrowing.nativeSyncOut).toBe(false);
|
|
});
|
|
|
|
it("test_persistent_process_sessions_follow_the_verified_and_declared_capability", () => {
|
|
// Session-output streaming now follows the capability snapshot alone, not a
|
|
// config flag. A provider that declares and verifies persistent process
|
|
// sessions keeps the capability when no narrowing removes it.
|
|
const verifiedMethods = ["environmentExecute"];
|
|
const declared = { persistentProcessSessions: true };
|
|
|
|
const narrowing = buildSandboxCapabilityNarrowing({
|
|
leasePolicy: "ephemeral",
|
|
leaseMetadata: {},
|
|
});
|
|
// A normal lease adds no persistent-session narrowing.
|
|
expect(narrowing.persistentProcessSessions).toBeUndefined();
|
|
|
|
const effective = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods,
|
|
declared,
|
|
narrowing,
|
|
});
|
|
expect(effective.persistentProcessSessions).toBe(true);
|
|
});
|
|
|
|
it("test_config_resolution_failure_fails_closed_on_persistent_process_sessions", () => {
|
|
const verifiedMethods = ["environmentExecute"];
|
|
const declared = { persistentProcessSessions: true };
|
|
|
|
// Config resolution failed, so the provider is untrusted. The narrowing must
|
|
// deny persistent process sessions instead of allowing them through. Without
|
|
// the fail-closed guard this narrowing key stays undefined and
|
|
// `persistentProcessSessions` resolves to true.
|
|
const narrowing = buildSandboxCapabilityNarrowing({
|
|
leasePolicy: "ephemeral",
|
|
leaseMetadata: {},
|
|
configResolutionFailed: true,
|
|
});
|
|
expect(narrowing.persistentProcessSessions).toBe(false);
|
|
|
|
const effective = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods,
|
|
declared,
|
|
narrowing,
|
|
});
|
|
expect(effective.persistentProcessSessions).toBe(false);
|
|
|
|
// Native sync and reusable lease enforcement stay unchanged on failure.
|
|
const syncNarrowing = buildSandboxCapabilityNarrowing({
|
|
leasePolicy: "reuse_by_environment",
|
|
leaseMetadata: { backend: "job" },
|
|
configResolutionFailed: true,
|
|
});
|
|
expect(syncNarrowing.reusableLeases).toBe(true);
|
|
expect(syncNarrowing.nativeSyncIn).toBe(false);
|
|
expect(syncNarrowing.nativeSyncOut).toBe(false);
|
|
});
|
|
|
|
it("test_builtin_provider_branch_uses_same_normalizer_as_plugin_branch", () => {
|
|
const declared = { reusableLeases: true, persistentProcessSessions: true };
|
|
|
|
// A built-in provider maps its own methods to the same verb names.
|
|
const builtinMethods = builtinSandboxProviderVerifiedMethods({
|
|
supportsReusableLeases: true,
|
|
execute: () => undefined,
|
|
});
|
|
const builtinEffective = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: builtinMethods,
|
|
declared,
|
|
});
|
|
|
|
// A plug-in provider that advertises the equivalent verbs.
|
|
const pluginEffective = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: [
|
|
"environmentResumeLease",
|
|
"environmentReleaseLease",
|
|
"environmentDestroyLease",
|
|
"environmentExecute",
|
|
],
|
|
declared,
|
|
});
|
|
|
|
// The one normalizer drives both branches, so equivalent verb sets resolve
|
|
// to the identical effective capabilities.
|
|
expect(builtinEffective).toEqual(pluginEffective);
|
|
expect(builtinEffective.reusableLeases).toBe(true);
|
|
expect(builtinEffective.persistentProcessSessions).toBe(true);
|
|
// A built-in provider has no native sync hooks, so it never verifies sync.
|
|
expect(builtinEffective.nativeSyncIn).toBe(false);
|
|
|
|
// A built-in provider without an execute method verifies no exec capability.
|
|
const noExec = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: builtinSandboxProviderVerifiedMethods({ supportsReusableLeases: false }),
|
|
declared: { persistentProcessSessions: true },
|
|
});
|
|
expect(noExec.persistentProcessSessions).toBe(false);
|
|
});
|
|
|
|
it("test_present_declaration_never_grants_beyond_verified_supported_methods", () => {
|
|
// One case per capability: the declaration sets the flag `true`, the worker
|
|
// lacks a prerequisite verb, and the effective value stays `false`.
|
|
for (const key of SANDBOX_CAPABILITY_KEYS) {
|
|
const effective = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: [],
|
|
declared: { [key]: true },
|
|
});
|
|
expect(effective[key]).toBe(false);
|
|
}
|
|
|
|
// A single missing prerequisite verb is enough: reusable leases needs
|
|
// resume, release, and destroy, so resume alone does not grant it.
|
|
const resumeOnly = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["environmentResumeLease"],
|
|
declared: { reusableLeases: true },
|
|
});
|
|
expect(resumeOnly.reusableLeases).toBe(false);
|
|
});
|
|
|
|
it("test_reusable_provider_without_destroy_support_resolves_false", () => {
|
|
// A provider that verifies resume and release but not destroy is not
|
|
// eligible for reusable leases. The reuse path destroys a stale lease when a
|
|
// resume fails, so a provider without destroy support would strand the lease.
|
|
const resumeAndReleaseOnly = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["environmentResumeLease", "environmentReleaseLease"],
|
|
declared: { reusableLeases: true },
|
|
});
|
|
expect(resumeAndReleaseOnly.reusableLeases).toBe(false);
|
|
|
|
// Adding the destroy verb makes the same provider eligible.
|
|
const allReuseVerbs = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: [
|
|
"environmentResumeLease",
|
|
"environmentReleaseLease",
|
|
"environmentDestroyLease",
|
|
],
|
|
declared: { reusableLeases: true },
|
|
});
|
|
expect(allReuseVerbs.reusableLeases).toBe(true);
|
|
});
|
|
|
|
it("test_generic_one_shot_provider_does_not_get_session_output_streaming", () => {
|
|
// The regression: a generic one-shot provider (for example Modal) verifies
|
|
// `environmentExecute` and declares the two broad session capabilities, yet
|
|
// it never emits incremental session output. Both broad capabilities resolve
|
|
// true, but `incrementalSessionOutput` must stay false because the provider
|
|
// did not declare the opt-in behavior. The session-output streaming gate
|
|
// reads `incrementalSessionOutput`, so this provider keeps the poll path.
|
|
const effective = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["environmentExecute"],
|
|
declared: {
|
|
persistentProcessSessions: true,
|
|
independentControlCommands: true,
|
|
},
|
|
});
|
|
|
|
expect(effective.persistentProcessSessions).toBe(true);
|
|
expect(effective.independentControlCommands).toBe(true);
|
|
// Opt-in denied: the provider did not declare incremental session output.
|
|
expect(effective.incrementalSessionOutput).toBe(false);
|
|
});
|
|
|
|
it("test_incremental_session_output_is_opt_in_and_needs_a_declaration", () => {
|
|
// An absent declaration denies the opt-in capability even when the worker
|
|
// verifies the prerequisite verb. This differs from a worker-property
|
|
// capability, which defers to the verified baseline.
|
|
const undeclared = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["environmentExecute"],
|
|
declared: null,
|
|
});
|
|
expect(undeclared.incrementalSessionOutput).toBe(false);
|
|
|
|
// A provider that declares the capability and verifies the prerequisite gets
|
|
// the streaming path.
|
|
const declared = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["environmentExecute"],
|
|
declared: { incrementalSessionOutput: true },
|
|
});
|
|
expect(declared.incrementalSessionOutput).toBe(true);
|
|
|
|
// A declaration never grants the capability without the verified verb.
|
|
const declaredButUnverified = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: [],
|
|
declared: { incrementalSessionOutput: true },
|
|
});
|
|
expect(declaredButUnverified.incrementalSessionOutput).toBe(false);
|
|
});
|
|
|
|
it("test_config_resolution_failure_fails_closed_on_incremental_session_output", () => {
|
|
// Config resolution failed, so the provider is untrusted. The narrowing must
|
|
// deny incremental session output even with a positive declaration, so the
|
|
// session-output streaming gate fails closed to the poll path.
|
|
const narrowing = buildSandboxCapabilityNarrowing({
|
|
leasePolicy: "ephemeral",
|
|
leaseMetadata: {},
|
|
configResolutionFailed: true,
|
|
});
|
|
expect(narrowing.incrementalSessionOutput).toBe(false);
|
|
|
|
const effective = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["environmentExecute"],
|
|
declared: { incrementalSessionOutput: true },
|
|
narrowing,
|
|
});
|
|
expect(effective.incrementalSessionOutput).toBe(false);
|
|
});
|
|
|
|
it("test_concurrent_sync_operations_is_opt_in_and_needs_both_sync_verbs", () => {
|
|
// Parallel bidirectional file sync is opt-in and direction-neutral. It needs
|
|
// both sync verbs, so a provider that verifies only one direction cannot get
|
|
// the capability. An absent declaration denies it even with both verbs.
|
|
const undeclared = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["environmentSyncIn", "environmentSyncOut"],
|
|
declared: null,
|
|
});
|
|
expect(undeclared.concurrentSyncOperations).toBe(false);
|
|
|
|
// A positive declaration with both verified verbs resolves true.
|
|
const bothVerbs = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["environmentSyncIn", "environmentSyncOut"],
|
|
declared: { concurrentSyncOperations: true },
|
|
});
|
|
expect(bothVerbs.concurrentSyncOperations).toBe(true);
|
|
|
|
// Only the inbound verb: the outbound prerequisite is missing, so it resolves
|
|
// false.
|
|
const inOnly = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["environmentSyncIn"],
|
|
declared: { concurrentSyncOperations: true },
|
|
});
|
|
expect(inOnly.concurrentSyncOperations).toBe(false);
|
|
|
|
// Only the outbound verb: the inbound prerequisite is missing, so it resolves
|
|
// false.
|
|
const outOnly = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["environmentSyncOut"],
|
|
declared: { concurrentSyncOperations: true },
|
|
});
|
|
expect(outOnly.concurrentSyncOperations).toBe(false);
|
|
});
|
|
|
|
it("test_duplex_command_stream_absent_declaration_resolves_false", () => {
|
|
// The duplex channel is opt-in and fail-closed. An absent declaration denies
|
|
// the capability even when the worker verifies the duplex open verb. This
|
|
// matches the incremental-session-output pattern: an opt-in behavioral
|
|
// guarantee needs a positive declaration, not just a verified verb.
|
|
const undeclared = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["duplexChannelOpen"],
|
|
declared: null,
|
|
});
|
|
expect(undeclared.duplexCommandStream).toBe(false);
|
|
});
|
|
|
|
it("test_duplex_command_stream_needs_verified_worker_method", () => {
|
|
// A declaration never grants the capability without the verified duplex open
|
|
// verb. A provider that declares the capability but whose worker does not
|
|
// report the duplex open method resolves false.
|
|
const declaredButUnverified = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["environmentExecute"],
|
|
declared: { duplexCommandStream: true },
|
|
});
|
|
expect(declaredButUnverified.duplexCommandStream).toBe(false);
|
|
});
|
|
|
|
it("test_duplex_command_stream_declared_and_verified_resolves_true_but_narrowing_removes_it", () => {
|
|
// A provider that declares the capability and whose worker verifies the
|
|
// duplex open verb gets the capability.
|
|
const granted = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["duplexChannelOpen"],
|
|
declared: { duplexCommandStream: true },
|
|
});
|
|
expect(granted.duplexCommandStream).toBe(true);
|
|
|
|
// Per-target narrowing still removes a verified and declared capability, so a
|
|
// lease that cannot use the duplex channel keeps the file bridge.
|
|
const narrowed = resolveEffectiveSandboxCapabilities({
|
|
verifiedMethods: ["duplexChannelOpen"],
|
|
declared: { duplexCommandStream: true },
|
|
narrowing: { duplexCommandStream: false },
|
|
});
|
|
expect(narrowed.duplexCommandStream).toBe(false);
|
|
});
|
|
|
|
it("test_unknown_or_unavailable_verification_resolves_false", () => {
|
|
const declaredAll = {
|
|
reusableLeases: true,
|
|
nativeSyncIn: true,
|
|
nativeSyncOut: true,
|
|
persistentProcessSessions: true,
|
|
independentControlCommands: true,
|
|
incrementalSessionOutput: true,
|
|
concurrentSyncOperations: true,
|
|
};
|
|
|
|
for (const verifiedMethods of [null, undefined, [] as string[]]) {
|
|
const effective = resolveEffectiveSandboxCapabilities({ verifiedMethods, declared: declaredAll });
|
|
for (const key of SANDBOX_CAPABILITY_KEYS) {
|
|
expect(effective[key]).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
});
|