mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 22:46:38 +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
213 lines
7.1 KiB
TypeScript
213 lines
7.1 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { AdapterExecutionTarget } from "./execution-target.js";
|
|
import {
|
|
buildDirectRunnerConnectUrl,
|
|
resolvePaperclipRunnerTransport,
|
|
type RunnerIngressEndpoint,
|
|
} from "./runner-connectivity.js";
|
|
|
|
const capabilities = {
|
|
reusableLeases: false,
|
|
nativeSyncIn: false,
|
|
nativeSyncOut: false,
|
|
persistentProcessSessions: true,
|
|
independentControlCommands: true,
|
|
incrementalSessionOutput: true,
|
|
concurrentSyncOperations: false,
|
|
duplexCommandStream: false,
|
|
runnerWebSocketIngress: true,
|
|
} as const;
|
|
|
|
function ingress(): RunnerIngressEndpoint {
|
|
const endpoint: RunnerIngressEndpoint = {
|
|
kind: "authenticated_websocket",
|
|
websocketUrl:
|
|
"wss://43127-sandbox.proxy.daytona.test/api/runner/v1/connect/00000000-0000-4000-8000-000000000001",
|
|
secretHeaders: [{ name: "X-Daytona-Preview-Token", value: "secret" }],
|
|
generation: "generation-1",
|
|
refresh: async () => endpoint,
|
|
close: async () => undefined,
|
|
};
|
|
return endpoint;
|
|
}
|
|
|
|
describe("paperclip runner transport routing", () => {
|
|
it("keeps same-host runnerd on plaintext loopback", async () => {
|
|
const result = await resolvePaperclipRunnerTransport({
|
|
target: { kind: "local" },
|
|
runId: "00000000-0000-4000-8000-000000000001",
|
|
localConnectUrl:
|
|
"ws://127.0.0.1:3100/api/runner/v1/connect/00000000-0000-4000-8000-000000000001",
|
|
runnerIngressAuthorized: false,
|
|
});
|
|
expect(result.mode).toBe("local_loopback");
|
|
});
|
|
|
|
it("selects provider ingress for Daytona-style capability even when a public URL exists", async () => {
|
|
const getRunnerIngressEndpoint = vi.fn(async () => ingress());
|
|
const target: AdapterExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "daytona",
|
|
remoteCwd: "/workspace",
|
|
leaseId: "lease-1",
|
|
effectiveCapabilities: capabilities,
|
|
getRunnerIngressEndpoint,
|
|
};
|
|
const result = await resolvePaperclipRunnerTransport({
|
|
target,
|
|
runId: "00000000-0000-4000-8000-000000000001",
|
|
localConnectUrl: "ws://127.0.0.1/unused",
|
|
runnerPublicUrl: "wss://paperclip.example.test",
|
|
runnerIngressAuthorized: true,
|
|
});
|
|
expect(result.mode).toBe("provider_ingress");
|
|
expect(getRunnerIngressEndpoint).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
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",
|
|
transport: "sandbox",
|
|
providerKey: "daytona",
|
|
remoteCwd: "/workspace",
|
|
leaseId: "lease-1",
|
|
effectiveCapabilities: capabilities,
|
|
getRunnerIngressEndpoint,
|
|
};
|
|
await expect(
|
|
resolvePaperclipRunnerTransport({
|
|
target,
|
|
runId: "00000000-0000-4000-8000-000000000001",
|
|
localConnectUrl: "ws://127.0.0.1/unused",
|
|
runnerPublicUrl: "wss://paperclip.example.test",
|
|
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();
|
|
});
|
|
|
|
it("selects direct WSS only for a remote target with an explicit URL", async () => {
|
|
const target: AdapterExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "ssh",
|
|
remoteCwd: "/workspace",
|
|
spec: {
|
|
host: "runner.internal",
|
|
port: 22,
|
|
username: "runner",
|
|
remoteWorkspacePath: "/workspace",
|
|
remoteCwd: "/workspace",
|
|
privateKey: null,
|
|
knownHosts: null,
|
|
strictHostKeyChecking: true,
|
|
},
|
|
};
|
|
const result = await resolvePaperclipRunnerTransport({
|
|
target,
|
|
runId: "00000000-0000-4000-8000-000000000001",
|
|
localConnectUrl: "ws://127.0.0.1/unused",
|
|
runnerPublicUrl: "wss://paperclip.example.test/runner-base/",
|
|
runnerCaBundlePath: "/etc/paperclip/runner-ca.pem",
|
|
runnerIngressAuthorized: false,
|
|
});
|
|
expect(result).toEqual({
|
|
mode: "direct_outbound",
|
|
connectUrl:
|
|
"wss://paperclip.example.test/runner-base/api/runner/v1/connect/00000000-0000-4000-8000-000000000001",
|
|
caBundlePath: "/etc/paperclip/runner-ca.pem",
|
|
});
|
|
});
|
|
|
|
it("fails the selected ingress mode without falling through to direct WSS", async () => {
|
|
const target: AdapterExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "daytona",
|
|
remoteCwd: "/workspace",
|
|
leaseId: "lease-1",
|
|
effectiveCapabilities: capabilities,
|
|
getRunnerIngressEndpoint: async () => {
|
|
throw new Error("preview unavailable");
|
|
},
|
|
};
|
|
await expect(
|
|
resolvePaperclipRunnerTransport({
|
|
target,
|
|
runId: "00000000-0000-4000-8000-000000000001",
|
|
localConnectUrl: "ws://127.0.0.1/unused",
|
|
runnerPublicUrl: "wss://paperclip.example.test",
|
|
runnerIngressAuthorized: true,
|
|
}),
|
|
).rejects.toThrow("preview unavailable");
|
|
});
|
|
|
|
it("never routes Daytona through direct outbound when ingress capability is unavailable", async () => {
|
|
const target: AdapterExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "daytona",
|
|
remoteCwd: "/workspace",
|
|
leaseId: "lease-1",
|
|
effectiveCapabilities: {
|
|
...capabilities,
|
|
runnerWebSocketIngress: false,
|
|
},
|
|
};
|
|
await expect(
|
|
resolvePaperclipRunnerTransport({
|
|
target,
|
|
runId: "00000000-0000-4000-8000-000000000001",
|
|
localConnectUrl: "ws://127.0.0.1/unused",
|
|
runnerPublicUrl: "wss://paperclip.example.test",
|
|
runnerIngressAuthorized: true,
|
|
}),
|
|
).rejects.toMatchObject({ code: "runner_ingress_unavailable" });
|
|
});
|
|
|
|
it.each([
|
|
"ws://paperclip.example.test",
|
|
"wss://user@paperclip.example.test",
|
|
"wss://paperclip.example.test?token=secret",
|
|
"wss://paperclip.example.test#fragment",
|
|
])("rejects unsafe direct runner URL %s", (runnerPublicUrl) => {
|
|
expect(() =>
|
|
buildDirectRunnerConnectUrl({
|
|
runnerPublicUrl,
|
|
runId: "00000000-0000-4000-8000-000000000001",
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|