diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index baed34649b..678552b25b 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -28,10 +28,14 @@ All environment variables that Paperclip uses for server configuration. | `PAPERCLIP_HIDDEN_SETTINGS` | (unset) | Comma-separated settings surfaces to hide from the UI and floor at the API, for operators hosting Paperclip for others (managed cloud, internal shared server). See [Hiding settings surfaces](#hiding-settings-surfaces). | | `PAPERCLIP_SETTING_DEFAULTS` | (unset) | JSON object replacing the schema default of selected instance settings, for hosting operators. See [Operator setting defaults](#operator-setting-defaults). | -Daytona connectivity for `paperclip_runner` uses authenticated preview WSS and -is gated by the instance experimental setting `enableRunnerPreviewIngress` -(default `false`). The setting has no effect on legacy adapters or callback -bridges. +Daytona connectivity for `paperclip_runner` uses authenticated provider +WebSocket ingress and follows the instance experimental setting +`enableNativeRunner` (default `false`). There is no separate ingress opt-in. +Disabling Paperclip Runner blocks fresh native starts while persisted native +runs retain their recovery path. The deprecated `enableRunnerPreviewIngress` +key remains accepted in stored and managed configuration for version-skew +compatibility, but it has no runtime effect. The setting has no effect on +legacy adapters or callback bridges. ### Preinstalled remote runner images diff --git a/packages/adapter-utils/src/runner-connectivity.test.ts b/packages/adapter-utils/src/runner-connectivity.test.ts index 5d825559bc..68a8b6d36d 100644 --- a/packages/adapter-utils/src/runner-connectivity.test.ts +++ b/packages/adapter-utils/src/runner-connectivity.test.ts @@ -38,7 +38,7 @@ describe("paperclip runner transport routing", () => { runId: "00000000-0000-4000-8000-000000000001", localConnectUrl: "ws://127.0.0.1:3100/api/runner/v1/connect/00000000-0000-4000-8000-000000000001", - enableRunnerPreviewIngress: false, + runnerIngressAuthorized: false, }); expect(result.mode).toBe("local_loopback"); }); @@ -59,13 +59,34 @@ describe("paperclip runner transport routing", () => { runId: "00000000-0000-4000-8000-000000000001", localConnectUrl: "ws://127.0.0.1/unused", runnerPublicUrl: "wss://paperclip.example.test", - enableRunnerPreviewIngress: true, + runnerIngressAuthorized: true, }); expect(result.mode).toBe("provider_ingress"); expect(getRunnerIngressEndpoint).toHaveBeenCalledOnce(); }); - it("does not request preview ingress while the new-runner rollout flag is off", async () => { + it("accepts the deprecated ingress input alias for existing consumers", async () => { + const target: AdapterExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd: "/workspace", + leaseId: "lease-legacy", + effectiveCapabilities: capabilities, + getRunnerIngressEndpoint: vi.fn(async () => ingress()), + }; + + const result = await resolvePaperclipRunnerTransport({ + target, + runId: "00000000-0000-4000-8000-000000000001", + localConnectUrl: "ws://127.0.0.1/unused", + enableRunnerPreviewIngress: true, + }); + + expect(result.mode).toBe("provider_ingress"); + }); + + it("lets resolved authorization override the deprecated ingress alias", async () => { const getRunnerIngressEndpoint = vi.fn(async () => ingress()); const target: AdapterExecutionTarget = { kind: "remote", @@ -82,9 +103,18 @@ describe("paperclip runner transport routing", () => { runId: "00000000-0000-4000-8000-000000000001", localConnectUrl: "ws://127.0.0.1/unused", runnerPublicUrl: "wss://paperclip.example.test", - enableRunnerPreviewIngress: false, + runnerIngressAuthorized: false, + enableRunnerPreviewIngress: true, }), ).rejects.toMatchObject({ code: "runner_ingress_unavailable" }); + const missingAuthorization = { + target, + runId: "00000000-0000-4000-8000-000000000002", + localConnectUrl: "ws://127.0.0.1/unused", + } as Parameters[0]; + await expect( + resolvePaperclipRunnerTransport(missingAuthorization), + ).rejects.toMatchObject({ code: "runner_ingress_unavailable" }); expect(getRunnerIngressEndpoint).not.toHaveBeenCalled(); }); @@ -110,7 +140,7 @@ describe("paperclip runner transport routing", () => { localConnectUrl: "ws://127.0.0.1/unused", runnerPublicUrl: "wss://paperclip.example.test/runner-base/", runnerCaBundlePath: "/etc/paperclip/runner-ca.pem", - enableRunnerPreviewIngress: false, + runnerIngressAuthorized: false, }); expect(result).toEqual({ mode: "direct_outbound", @@ -138,7 +168,7 @@ describe("paperclip runner transport routing", () => { runId: "00000000-0000-4000-8000-000000000001", localConnectUrl: "ws://127.0.0.1/unused", runnerPublicUrl: "wss://paperclip.example.test", - enableRunnerPreviewIngress: true, + runnerIngressAuthorized: true, }), ).rejects.toThrow("preview unavailable"); }); @@ -161,7 +191,7 @@ describe("paperclip runner transport routing", () => { runId: "00000000-0000-4000-8000-000000000001", localConnectUrl: "ws://127.0.0.1/unused", runnerPublicUrl: "wss://paperclip.example.test", - enableRunnerPreviewIngress: true, + runnerIngressAuthorized: true, }), ).rejects.toMatchObject({ code: "runner_ingress_unavailable" }); }); diff --git a/packages/adapter-utils/src/runner-connectivity.ts b/packages/adapter-utils/src/runner-connectivity.ts index c50985acd6..c59dfeb560 100644 --- a/packages/adapter-utils/src/runner-connectivity.ts +++ b/packages/adapter-utils/src/runner-connectivity.ts @@ -35,6 +35,19 @@ export type PaperclipRunnerTransport = readonly ingress: RunnerIngressEndpoint; }; +type RunnerIngressAuthorization = + | { + /** Per-run authorization resolved by the native runtime selection policy. */ + readonly runnerIngressAuthorized: boolean; + /** @deprecated Use runnerIngressAuthorized. Retained for API compatibility. */ + readonly enableRunnerPreviewIngress?: boolean; + } + | { + readonly runnerIngressAuthorized?: never; + /** @deprecated Use runnerIngressAuthorized. Retained for API compatibility. */ + readonly enableRunnerPreviewIngress: boolean; + }; + export class PaperclipRunnerTransportError extends Error { readonly code: | "runner_transport_ineligible" @@ -98,13 +111,12 @@ export async function resolvePaperclipRunnerTransport(input: { localConnectUrl: string; runnerPublicUrl?: string | null; runnerCaBundlePath?: string | null; - enableRunnerPreviewIngress: boolean; getRunnerIngressEndpoint?: (input: { leaseId: string; port: number; path: string; }) => Promise; -}): Promise { +} & RunnerIngressAuthorization): Promise { if (input.target.kind === "local") { return { mode: "local_loopback", connectUrl: input.localConnectUrl }; } @@ -124,10 +136,12 @@ export async function resolvePaperclipRunnerTransport(input: { input.target.transport === "sandbox" && input.target.effectiveCapabilities?.runnerWebSocketIngress === true ) { - if (!input.enableRunnerPreviewIngress) { + const ingressAuthorized = + input.runnerIngressAuthorized ?? input.enableRunnerPreviewIngress ?? false; + if (!ingressAuthorized) { throw new PaperclipRunnerTransportError( "runner_ingress_unavailable", - "Runner preview ingress is disabled for this Paperclip instance.", + "Runner ingress is not authorized for this Paperclip Runner run.", ); } const getRunnerIngressEndpoint = diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 2c103bd39a..ca9aa77993 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -53,7 +53,7 @@ export const INSTANCE_FEATURE_CATALOG: Record { expect(normalizeExperimentalSettings({ enablePipelines: true }).enableApps).toBe(false); }); + it("retains the deprecated ingress key for stored-settings compatibility", () => { + expect( + normalizeExperimentalSettings({ enableRunnerPreviewIngress: true }) + .enableRunnerPreviewIngress, + ).toBe(true); + }); + it("defaults enableConferenceRoomChat to false for empty and legacy stored settings", () => { expect(normalizeExperimentalSettings(undefined).enableConferenceRoomChat).toBe(false); expect(normalizeExperimentalSettings({}).enableConferenceRoomChat).toBe(false); diff --git a/server/src/__tests__/managed-config.test.ts b/server/src/__tests__/managed-config.test.ts index 370999e348..b8362381f8 100644 --- a/server/src/__tests__/managed-config.test.ts +++ b/server/src/__tests__/managed-config.test.ts @@ -57,6 +57,14 @@ describe("parseManagedConfigEnv", () => { }); }); + it("accepts the deprecated runner ingress key for managed-config compatibility", () => { + const config = parseManagedConfigEnv( + envWith(validDoc({ features: { enableRunnerPreviewIngress: true } })), + ); + + expect(config?.features).toEqual({ enableRunnerPreviewIngress: true }); + }); + it("accepts empty features {} and autoInstall [] sections", () => { const config = parseManagedConfigEnv( envWith(validDoc({ features: {}, plugins: { autoInstall: [] } })), diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index a0b2445dc6..503c350d08 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -128,6 +128,7 @@ import { executePaperclipNativeSession, finalizeNativeRun, isNativeSessionId, + isRunnerIngressAuthorized, materializeLegacyQuestionResponseWakeProjection, materializeNativeInteractionResponses, NativeCancellationPendingRecoveryError, @@ -20613,9 +20614,8 @@ export function heartbeatService( : {}), }, runnerExecutionTarget: executionTarget, - enableRunnerPreviewIngress: - resolvedInstanceSettings.experimental - .enableRunnerPreviewIngress === true, + runnerIngressAuthorized: + isRunnerIngressAuthorized(nativeRuntimeResolution), runnerPublicUrl: runtimeEnv.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null, runnerCaBundlePath: diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index c78de591aa..f1a1889a72 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -127,6 +127,7 @@ import { readRemoteProviderPackManifest, providerSessionIdentityTransitionIsAllowed, providerPlanMarkdown, + resolveRemoteRunnerTransportMode, renewNativeSessionExecutionLease, runtimeInputLifecycleMetric, runtimeQuestionFallbackFromEvent, @@ -931,6 +932,35 @@ describe("remote runner build metadata", () => { }); }); +describe("remote runner transport authorization", () => { + const ingressTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd: "/workspace", + leaseId: "lease-1", + effectiveCapabilities: { runnerWebSocketIngress: true }, + } as const; + + it("fails before selecting sandbox ingress for an unauthorized run", () => { + expect(() => + resolveRemoteRunnerTransportMode({ + target: ingressTarget as never, + runnerIngressAuthorized: false, + }), + ).toThrow("runner_ingress_unavailable"); + }); + + it("selects sandbox ingress for a resolved native run", () => { + expect( + resolveRemoteRunnerTransportMode({ + target: ingressTarget as never, + runnerIngressAuthorized: true, + }), + ).toBe("listen_ws"); + }); +}); + describe("runtime question fallback", () => { const questionSet = { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index b1a79b2386..9263ba5016 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -2728,7 +2728,8 @@ export async function executePaperclipNativeSession(input: { /** Resolved adapter env; the runner transport applies a provider allowlist before spawn. */ runnerEnvironment?: NodeJS.ProcessEnv; runnerExecutionTarget?: AdapterExecutionTarget | null; - enableRunnerPreviewIngress?: boolean; + /** Resolved per-run authorization; not an independent instance setting. */ + runnerIngressAuthorized?: boolean; runnerPublicUrl?: string | null; runnerCaBundlePath?: string | null; runnerRemoteBinaryPath?: string | null; @@ -4622,6 +4623,29 @@ function createRemoteRunnerProcessLauncher(input: { }; } +/** + * Select the remote runner transport before any artifact is staged or provider + * endpoint is acquired. Sandbox ingress is available only to a run already + * authorized by native runtime selection. + */ +export function resolveRemoteRunnerTransportMode(input: { + target: AdapterExecutionTarget; + runnerIngressAuthorized: boolean; +}): "listen_ws" | "dial_wss" { + if (input.target.kind !== "remote") { + throw new Error("runner_transport_ineligible: remote target is required"); + } + const requiredMode = + input.target.transport === "sandbox" && + input.target.effectiveCapabilities?.runnerWebSocketIngress === true + ? "listen_ws" + : "dial_wss"; + if (requiredMode === "listen_ws" && !input.runnerIngressAuthorized) { + throw new Error("runner_ingress_unavailable"); + } + return requiredMode; +} + /** Production runnerd backend seam, exported so provider wiring can be regression tested. */ export async function createRunnerdBackend(input: { db: Db; @@ -4635,7 +4659,8 @@ export async function createRunnerdBackend(input: { }) => Promise; runnerEnvironment?: NodeJS.ProcessEnv; runnerExecutionTarget?: AdapterExecutionTarget | null; - enableRunnerPreviewIngress?: boolean; + /** Resolved per-run authorization; not an independent instance setting. */ + runnerIngressAuthorized?: boolean; runnerPublicUrl?: string | null; runnerCaBundlePath?: string | null; runnerRemoteBinaryPath?: string | null; @@ -6104,17 +6129,11 @@ export async function createRunnerdBackend(input: { }; } - const requiredMode = - target.transport === "sandbox" && - target.effectiveCapabilities?.runnerWebSocketIngress === true - ? "listen_ws" - : "dial_wss"; - if ( - requiredMode === "listen_ws" && - input.enableRunnerPreviewIngress !== true - ) { - throw new Error("runner_ingress_unavailable"); - } + const requiredMode = resolveRemoteRunnerTransportMode({ + target, + runnerIngressAuthorized: + input.runnerIngressAuthorized === true, + }); let transport: PaperclipRunnerTransport; if (requiredMode === "dial_wss") { // Validate eligibility before staging any artifact. @@ -6128,8 +6147,8 @@ export async function createRunnerdBackend(input: { localConnectUrl: "ws://127.0.0.1/unused", runnerPublicUrl: input.runnerPublicUrl, runnerCaBundlePath: input.runnerCaBundlePath, - enableRunnerPreviewIngress: - input.enableRunnerPreviewIngress === true, + runnerIngressAuthorized: + input.runnerIngressAuthorized === true, }), ); if ( @@ -6164,7 +6183,7 @@ export async function createRunnerdBackend(input: { localConnectUrl: "ws://127.0.0.1/unused", runnerPublicUrl: input.runnerPublicUrl, runnerCaBundlePath: input.runnerCaBundlePath, - enableRunnerPreviewIngress: true, + runnerIngressAuthorized: true, }), ); } diff --git a/server/src/services/native-runtime/runtime-mode.test.ts b/server/src/services/native-runtime/runtime-mode.test.ts index 50ac8154e0..892fccdb0f 100644 --- a/server/src/services/native-runtime/runtime-mode.test.ts +++ b/server/src/services/native-runtime/runtime-mode.test.ts @@ -4,6 +4,7 @@ import { BUILTIN_ADAPTER_TYPES } from "../../adapters/builtin-adapter-types.js"; import { NativeRunnerSelectionError, NativeRuntimeEligibilityError, + isRunnerIngressAuthorized, resolveHeartbeatNativeRuntimeMode, resolveHeartbeatRuntimeMode, resolveNativeRuntimeMode, @@ -122,6 +123,40 @@ describe("resolveNativeRuntimeMode", () => { })); }); + it("authorizes ingress from the native runtime decision without a second flag", () => { + const freshNative = resolveHeartbeatNativeRuntimeMode({ + ...eligible, + persisted: { + runtimeMode: null, + runtimeModeReason: null, + runtimeModeResolvedAt: null, + }, + }); + const persistedNative = resolveHeartbeatNativeRuntimeMode({ + ...eligible, + enabled: false, + persisted: { + runtimeMode: "native", + runtimeModeReason: "eligible_opt_in", + runtimeModeResolvedAt: new Date(), + }, + }); + const directLegacy = resolveHeartbeatNativeRuntimeMode({ + ...eligible, + enabled: false, + agent: { ...eligible.agent, adapterType: "codex_local" }, + persisted: { + runtimeMode: null, + runtimeModeReason: null, + runtimeModeResolvedAt: null, + }, + }); + + expect(isRunnerIngressAuthorized(freshNative)).toBe(true); + expect(isRunnerIngressAuthorized(persistedNative)).toBe(true); + expect(isRunnerIngressAuthorized(directLegacy)).toBe(false); + }); + it.each(["paused", "terminated", "pending_approval"])( "refuses persisted native recovery for a %s agent", (status) => { diff --git a/server/src/services/native-runtime/runtime-mode.ts b/server/src/services/native-runtime/runtime-mode.ts index 532da664ec..48c474a7b8 100644 --- a/server/src/services/native-runtime/runtime-mode.ts +++ b/server/src/services/native-runtime/runtime-mode.ts @@ -46,6 +46,17 @@ export type NativeRuntimeResolution = authorityDecision: NativeStatusDecision; }; +/** + * Runner ingress follows the resolved runtime decision, not a second instance + * flag. Persisted native runs therefore keep their transport during recovery + * after the rollout flag is disabled, while legacy runs never gain ingress. + */ +export function isRunnerIngressAuthorized( + resolution: NativeRuntimeResolution, +): boolean { + return resolution.kind === "native"; +} + export class NativeRunnerSelectionError extends Error { constructor(readonly code: string, message: string) { super(message); diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 3d2642d256..a77826ba6f 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -70,8 +70,6 @@ const STATUS_CARDS_TOGGLE_SELECTOR = 'button[aria-label="Toggle status cards experimental setting"]'; const AUTO_RECOVERY_TOGGLE_SELECTOR = 'button[aria-label="Toggle task graph liveness auto-recovery"]'; -const RUNNER_PREVIEW_INGRESS_TOGGLE_SELECTOR = - 'button[aria-label="Toggle runner preview ingress experimental setting"]'; const PAPERCLIP_RUNNER_TOGGLE_SELECTOR = 'button[aria-label="Toggle Paperclip Runner experimental setting"]'; @@ -305,24 +303,14 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) }); }); - it("renders and patches the Runner Preview Ingress experimental toggle", async () => { + it("does not expose the retired Runner Preview Ingress setting separately", async () => { + currentExperimentalSettings.enableRunnerPreviewIngress = true; await renderPage(); - expect(container.textContent).toContain("Runner Preview Ingress"); - const toggle = container.querySelector( - RUNNER_PREVIEW_INGRESS_TOGGLE_SELECTOR, - ); - expect(toggle?.getAttribute("aria-checked")).toBe("false"); - - await act(async () => { - toggle?.click(); - }); - await flushReact(); - - expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ - enableRunnerPreviewIngress: true, - }); - expect(toggle?.getAttribute("aria-checked")).toBe("true"); + expect(container.textContent).not.toContain("Runner Preview Ingress"); + expect(container.querySelector( + 'button[aria-label="Toggle runner preview ingress experimental setting"]', + )).toBeNull(); }); it("keeps Paperclip Runner default-off and exposes an explicit opt-in", async () => { diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 350cf4113f..cc123da50f 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -364,8 +364,6 @@ export function InstanceExperimentalSettings() { ); const enableEnvironments = experimentalQuery.data?.enableEnvironments === true; const enableNativeRunner = experimentalQuery.data?.enableNativeRunner === true; - const enableRunnerPreviewIngress = - experimentalQuery.data?.enableRunnerPreviewIngress === true; const enableManagedSandboxOnly = experimentalQuery.data?.enableManagedSandboxOnly === true; const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true; const enableApps = experimentalQuery.data?.enableApps === true; @@ -736,7 +734,7 @@ export function InstanceExperimentalSettings() { toggleMutation.mutate({ enableNativeRunner: checked }) @@ -747,19 +745,6 @@ export function InstanceExperimentalSettings() { ariaLabel="Toggle Paperclip Runner experimental setting" /> - - toggleMutation.mutate({ enableRunnerPreviewIngress: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableRunnerPreviewIngress" - managed={managedKeys.enableRunnerPreviewIngress} - ariaLabel="Toggle runner preview ingress experimental setting" - /> - {inWorktree ? (