mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 17:40:37 +02:00
## 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
188 lines
5.7 KiB
TypeScript
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.",
|
|
);
|
|
}
|