Files
paperclip/server/src/__tests__/runtime-api.test.ts
T
Devin Foley 367d4cab72 Fix SSH callback URL selection for LAN and private networks (#4799)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents can run on remote hosts via SSH environments
> - When a remote agent needs to call back to the Paperclip API, it
needs a reachable URL
> - But the runtime API URL candidate builder did not account for
private network topologies where the server is only reachable via LAN or
VPN addresses
> - Agents on SSH hosts were failing to connect because the callback URL
pointed to localhost or an unreachable address
> - This PR fixes callback URL selection to honor `PAPERCLIP_API_URL`,
prefer LAN-reachable candidates, filter unreachable link-local
addresses, and include interface hosts in onboarding invite URLs
> - The benefit is SSH-based agents can reliably reach the Paperclip API
on private networks without manual URL configuration

## What Changed

- `runtime-api.ts`: Added `PAPERCLIP_API_URL` as a first-priority
candidate in `buildRuntimeApiCandidateUrls`; extracted
`collectReachableInterfaceHosts` to enumerate non-loopback,
non-link-local network interface IPs with IPv4 preference
- `server/src/index.ts`: Export `PAPERCLIP_API_URL` from the server
environment so it is available to callback candidate resolution
- `server/src/routes/access.ts`: Include LAN interface hosts in
onboarding invite connection candidates
- `server/src/config.ts`: Attempted auto-allowing LAN interface hosts,
then reverted to the per-instance allowlist approach (both commits
included for history clarity)

## Verification

- `pnpm test` — all existing and new tests pass, including new tests for
LAN candidate ordering and link-local filtering
- `pnpm typecheck` — clean
- Manual: start a Paperclip server on a machine with a LAN IP, create an
SSH environment pointing to another host on the same LAN, verify the
agent's callback URL uses the LAN IP rather than localhost

## Risks

- Low-medium. The candidate list now includes more addresses (all
non-loopback LAN interfaces). These are candidates for the agent to try,
not an allowlist — the server's allowed hostnames still gate which
origins are accepted. Ordering change (LAN preferred over loopback)
could affect existing setups where localhost was intentionally
preferred.

## Model Used

Codex GPT 5.4 high via Paperclip.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-04-29 15:56:17 -07:00

148 lines
4.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
buildRuntimeApiCandidateUrls,
choosePrimaryRuntimeApiUrl,
collectReachableInterfaceHosts,
} from "../runtime-api.js";
describe("runtime API discovery", () => {
it("prefers the explicit public base URL for the primary runtime URL", () => {
expect(
choosePrimaryRuntimeApiUrl({
authPublicBaseUrl: "https://paperclip.example.com/base/path",
allowedHostnames: ["198.51.100.10"],
bindHost: "0.0.0.0",
port: 3102,
}),
).toBe("https://paperclip.example.com");
});
it("builds ordered callback candidates from explicit, allowed, bind, and interface hosts", () => {
expect(
buildRuntimeApiCandidateUrls({
authPublicBaseUrl: null,
allowedHostnames: ["198.51.100.10", "runtime-host.example.test", "203.0.113.42"],
bindHost: "0.0.0.0",
port: 3102,
networkInterfacesMap: {
en0: [
{
address: "203.0.113.42",
family: "IPv4",
internal: false,
netmask: "255.255.255.0",
cidr: "203.0.113.42/24",
mac: "00:00:00:00:00:00",
},
{
address: "fe80::1",
family: "IPv6",
internal: false,
netmask: "ffff:ffff:ffff:ffff::",
cidr: "fe80::1/64",
mac: "00:00:00:00:00:00",
scopeid: 1,
},
],
lo0: [
{
address: "127.0.0.1",
family: "IPv4",
internal: true,
netmask: "255.0.0.0",
cidr: "127.0.0.1/8",
mac: "00:00:00:00:00:00",
},
],
},
}),
).toEqual([
"http://198.51.100.10:3102",
"http://runtime-host.example.test:3102",
"http://203.0.113.42:3102",
]);
});
it("tries the preferred API URL before derived callback candidates", () => {
expect(
buildRuntimeApiCandidateUrls({
preferredApiUrl: "https://agent-entry.example.test/base/path",
authPublicBaseUrl: "https://paperclip.example.test/app",
allowedHostnames: ["198.51.100.10"],
bindHost: "0.0.0.0",
port: 3102,
networkInterfacesMap: {},
}),
).toEqual([
"https://agent-entry.example.test",
"https://paperclip.example.test",
"https://198.51.100.10:3102",
]);
});
it("adds host.docker.internal when the explicit base URL is loopback", () => {
expect(
buildRuntimeApiCandidateUrls({
authPublicBaseUrl: "http://127.0.0.1:3102",
allowedHostnames: [],
bindHost: "127.0.0.1",
port: 3102,
networkInterfacesMap: {},
}),
).toEqual([
"http://127.0.0.1:3102",
"http://host.docker.internal:3102",
]);
});
it("prefers usable interface hosts and skips link-local addresses", () => {
expect(
collectReachableInterfaceHosts({
networkInterfacesMap: {
en0: [
{
address: "fe80::1",
family: "IPv6",
internal: false,
netmask: "ffff:ffff:ffff:ffff::",
cidr: "fe80::1/64",
mac: "00:00:00:00:00:00",
scopeid: 1,
},
{
address: "192.168.6.178",
family: "IPv4",
internal: false,
netmask: "255.255.252.0",
cidr: "192.168.6.178/22",
mac: "00:00:00:00:00:00",
},
{
address: "fd7a:115c:a1e0::8a3a:a11d",
family: "IPv6",
internal: false,
netmask: "ffff:ffff:ffff::",
cidr: "fd7a:115c:a1e0::8a3a:a11d/48",
mac: "00:00:00:00:00:00",
scopeid: 0,
},
],
en1: [
{
address: "169.254.10.20",
family: "IPv4",
internal: false,
netmask: "255.255.0.0",
cidr: "169.254.10.20/16",
mac: "00:00:00:00:00:00",
},
],
},
}),
).toEqual([
"192.168.6.178",
"fd7a:115c:a1e0::8a3a:a11d",
]);
});
});