Files
PaperClipAI/packages/adapter-utils/src/runner-connectivity.test.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

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();
});
});