Files
PaperClipAI/packages/adapter-utils/src/runner-connectivity.ts
Dotta ee2a190626 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
2026-09-01 09:21:23 -05:00

188 lines
5.7 KiB
TypeScript

import type { AdapterExecutionTarget } from "./execution-target.js";
export const PAPERCLIP_RUNNER_INGRESS_PORT = 43_127;
export const PAPERCLIP_RUNNER_CONNECT_PATH_PREFIX = "/api/runner/v1/connect";
export interface SecretHeader {
readonly name: string;
readonly value: string;
}
export interface RunnerIngressEndpoint {
readonly kind: "authenticated_websocket";
readonly websocketUrl: string;
readonly secretHeaders: readonly SecretHeader[];
readonly generation: string;
refresh(): Promise<RunnerIngressEndpoint>;
close(): Promise<void>;
}
export type PaperclipRunnerTransport =
| {
readonly mode: "local_loopback";
readonly connectUrl: string;
}
| {
readonly mode: "direct_outbound";
readonly connectUrl: string;
readonly caBundlePath?: string;
}
| {
readonly mode: "provider_ingress";
readonly listenAddress: "0.0.0.0";
readonly listenPort: number;
readonly listenPath: string;
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"
| "runner_direct_wss_failed"
| "runner_ingress_unavailable";
constructor(
code: PaperclipRunnerTransportError["code"],
message: string,
options?: ErrorOptions,
) {
super(`${code}: ${message}`, options);
this.name = "PaperclipRunnerTransportError";
this.code = code;
}
}
function connectPath(runId: string): string {
if (!runId || runId.includes("/") || runId.includes("?") || runId.includes("#")) {
throw new PaperclipRunnerTransportError(
"runner_transport_ineligible",
"Runner run id is not safe for a WebSocket route.",
);
}
return `${PAPERCLIP_RUNNER_CONNECT_PATH_PREFIX}/${encodeURIComponent(runId)}`;
}
export function buildDirectRunnerConnectUrl(input: {
runnerPublicUrl: string;
runId: string;
}): string {
let url: URL;
try {
url = new URL(input.runnerPublicUrl);
} catch (error) {
throw new PaperclipRunnerTransportError(
"runner_direct_wss_failed",
"The configured runner public URL is invalid.",
{ cause: error },
);
}
if (url.protocol !== "wss:") {
throw new PaperclipRunnerTransportError(
"runner_direct_wss_failed",
"Remote runner connectivity requires a wss: runner public URL.",
);
}
if (url.username || url.password || url.search || url.hash) {
throw new PaperclipRunnerTransportError(
"runner_direct_wss_failed",
"Runner public URLs cannot contain userinfo, a query string, or a fragment.",
);
}
url.pathname = `${url.pathname.replace(/\/$/, "")}${connectPath(input.runId)}`;
return url.toString();
}
export async function resolvePaperclipRunnerTransport(input: {
target: AdapterExecutionTarget;
runId: string;
localConnectUrl: string;
runnerPublicUrl?: string | null;
runnerCaBundlePath?: string | null;
getRunnerIngressEndpoint?: (input: {
leaseId: string;
port: number;
path: string;
}) => Promise<RunnerIngressEndpoint>;
} & RunnerIngressAuthorization): Promise<PaperclipRunnerTransport> {
if (input.target.kind === "local") {
return { mode: "local_loopback", connectUrl: input.localConnectUrl };
}
if (
input.target.transport === "sandbox" &&
input.target.providerKey === "daytona" &&
input.target.effectiveCapabilities?.runnerWebSocketIngress !== true
) {
throw new PaperclipRunnerTransportError(
"runner_ingress_unavailable",
"Daytona runner execution requires provider WebSocket ingress capability.",
);
}
if (
input.target.transport === "sandbox" &&
input.target.effectiveCapabilities?.runnerWebSocketIngress === true
) {
const ingressAuthorized =
input.runnerIngressAuthorized ?? input.enableRunnerPreviewIngress ?? false;
if (!ingressAuthorized) {
throw new PaperclipRunnerTransportError(
"runner_ingress_unavailable",
"Runner ingress is not authorized for this Paperclip Runner run.",
);
}
const getRunnerIngressEndpoint =
input.getRunnerIngressEndpoint ?? input.target.getRunnerIngressEndpoint;
if (!input.target.leaseId || !getRunnerIngressEndpoint) {
throw new PaperclipRunnerTransportError(
"runner_ingress_unavailable",
"The sandbox runner ingress provider is unavailable for this lease.",
);
}
const path = connectPath(input.runId);
const ingress = await getRunnerIngressEndpoint({
leaseId: input.target.leaseId,
port: PAPERCLIP_RUNNER_INGRESS_PORT,
path,
});
return {
mode: "provider_ingress",
listenAddress: "0.0.0.0",
listenPort: PAPERCLIP_RUNNER_INGRESS_PORT,
listenPath: path,
ingress,
};
}
if (input.runnerPublicUrl) {
return {
mode: "direct_outbound",
connectUrl: buildDirectRunnerConnectUrl({
runnerPublicUrl: input.runnerPublicUrl,
runId: input.runId,
}),
...(input.runnerCaBundlePath
? { caBundlePath: input.runnerCaBundlePath }
: {}),
};
}
throw new PaperclipRunnerTransportError(
"runner_transport_ineligible",
"The remote execution target has neither runner WebSocket ingress nor an explicit runner public URL.",
);
}