Unify Paperclip Runner experimental controls (#12666)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip Runner is an experimental execution adapter. > - The adapter and its required sandbox ingress had separate settings. > - A user could enable one setting and still have an unusable runner configuration. > - The runtime already makes one durable native or legacy decision for each run. > - This pull request uses that runtime decision for ingress authorization. > - The benefit is one clear opt-in with safe recovery for existing native runs. ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves the experimental settings and transport authorization for Paperclip Runner. **Subsystem affected** Cross-cutting. This change affects the React settings UI, shared settings contracts, adapter utilities, and server runtime selection. **Current behavior** Settings shows separate Paperclip Runner and Runner Preview Ingress controls. A user can enable the runner but leave required sandbox ingress disabled. **Proposed behavior** Settings shows only Paperclip Runner. Its native runtime decision also authorizes provider WebSocket ingress when the execution target requires it. A persisted native run keeps its recovery transport after the setting is disabled. **Reason and benefit** Paperclip Runner is one experimental capability. One opt-in removes an invalid partial configuration and makes the rollout boundary easier to understand. **Breaking changes** The Runner Preview Ingress card is removed. The old `enableRunnerPreviewIngress` key remains accepted in stored settings and managed configuration, but it has no server runtime effect. The public adapter-utils input remains compatible through a deprecated alias. **Additional context** Refs: #12638, #12641, #12656. ## What Changed - Removed the separate Runner Preview Ingress card from Experimental Settings. - Made resolved native runtime selection authorize required provider ingress. - Preserved ingress recovery for persisted native runs after the rollout flag is disabled. - Kept the old settings key and adapter-utils input as deprecated compatibility contracts. - Added focused UI, runtime policy, transport, stored-settings, and managed-config regression tests. - Updated deployment documentation and feature descriptions. ## Verification - GitHub Actions will run typecheck, tests, build, policy, and browser shards. - Focused tests cover the single settings control, runtime authorization, fail-closed transport selection, the deprecated public input, and old managed configuration. - No local tests were run, per the maintainer request to use GitHub Actions for verification. - `git diff --check` passes. ## Risks Low to moderate risk. The effective ingress gate changes from a separate stored flag to the resolved native run decision. Fresh runs still require `enableNativeRunner`. Persisted native runs remain recoverable. Legacy adapters never receive ingress authorization. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5, with reasoning, tool use, and code execution. ## 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 - [ ] 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
This commit is contained in:
parent
1c499b6b4d
commit
ee2a190626
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof resolvePaperclipRunnerTransport>[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" });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<RunnerIngressEndpoint>;
|
||||
}): Promise<PaperclipRunnerTransport> {
|
||||
} & RunnerIngressAuthorization): Promise<PaperclipRunnerTransport> {
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
|||
enableNativeRunner: {
|
||||
title: "Paperclip Runner",
|
||||
description:
|
||||
"Allow new Codex agents to use the experimental Rust Paperclip Runner transport.",
|
||||
"Allow new Codex agents to use the experimental Rust Paperclip Runner, including authenticated sandbox ingress when required.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
|
|
@ -278,9 +278,9 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
|||
selfHostedDefault: false,
|
||||
},
|
||||
enableRunnerPreviewIngress: {
|
||||
title: "Runner Preview Ingress",
|
||||
title: "Runner Preview Ingress (Deprecated)",
|
||||
description:
|
||||
"Let paperclip_runner connect through an authenticated sandbox-provider WebSocket ingress. Legacy adapters never use this path.",
|
||||
"Compatibility-only key retained for older managed configs. Runner ingress follows the Paperclip Runner setting.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
|
|
|
|||
|
|
@ -103,7 +103,10 @@ export interface InstanceExperimentalSettings {
|
|||
* redeploy.
|
||||
*/
|
||||
enableSandboxDuplexBridge: boolean;
|
||||
/** Default-off rollout gate for paperclip_runner provider WebSocket ingress. */
|
||||
/**
|
||||
* @deprecated Compatibility-only. Provider WebSocket ingress now follows
|
||||
* enableNativeRunner and this value has no runtime effect.
|
||||
*/
|
||||
enableRunnerPreviewIngress: boolean;
|
||||
/**
|
||||
* Worktree preview instances (`PAPERCLIP_IN_WORKTREE=true`) suppress the
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ export const instanceExperimentalSettingsSchema = z.object({
|
|||
// off the host keeps the file bridge for every run with no manifest change and
|
||||
// no redeploy. The host reads this per run before it selects the transport.
|
||||
enableSandboxDuplexBridge: z.boolean().default(false),
|
||||
// Deprecated compatibility key. Runner ingress follows enableNativeRunner;
|
||||
// this remains accepted so older stored rows and managed configs keep loading.
|
||||
enableRunnerPreviewIngress: z.boolean().default(false),
|
||||
enableWorktreeRunExecution: z.boolean().default(false),
|
||||
worktreeRunExecutionActivatedAt: z.string().datetime().nullable().default(null),
|
||||
|
|
|
|||
|
|
@ -71,6 +71,13 @@ describe("instance settings service", () => {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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: [] } })),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement>(
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
|
||||
<ExperimentalToggleCard
|
||||
title="Paperclip Runner"
|
||||
description="Allow new Codex agents to select the experimental Rust Paperclip Runner. Onboarding continues to use legacy adapters. Turning this off hides the choice without affecting existing native runs."
|
||||
description="Allow new Codex agents to select the experimental Rust Paperclip Runner, including authenticated runner ingress when a sandbox requires it. Onboarding continues to use legacy adapters. Turning this off hides the choice without affecting existing native runs."
|
||||
checked={enableNativeRunner}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleMutation.mutate({ enableNativeRunner: checked })
|
||||
|
|
@ -747,19 +745,6 @@ export function InstanceExperimentalSettings() {
|
|||
ariaLabel="Toggle Paperclip Runner experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Runner Preview Ingress"
|
||||
description="Let Paperclip Runner agents connect through an authenticated sandbox-provider WebSocket preview. Legacy adapters continue using their existing transports."
|
||||
checked={enableRunnerPreviewIngress}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleMutation.mutate({ enableRunnerPreviewIngress: checked })
|
||||
}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableRunnerPreviewIngress"
|
||||
managed={managedKeys.enableRunnerPreviewIngress}
|
||||
ariaLabel="Toggle runner preview ingress experimental setting"
|
||||
/>
|
||||
|
||||
{inWorktree ? (
|
||||
<Card className="block p-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
|
|||
Loading…
Reference in New Issue