#13576·paperclip

A scheme-less auth.publicBaseUrl makes the runtime API URL the string "null" and throws TypeError: Invalid URL at startup

Author: SkilLab-TechCreated Sep 17, 2026Updated Sep 17, 2026

Pre-submission checklist

  • I have searched existing open and closed issues and this is not a duplicate.
  • I am on the latest master branch.
  • I have confirmed the error originates in Paperclip itself, not an API provider.

What happened?

If auth.publicBaseUrl (or any of the env vars that feed it: PAPERCLIP_AUTH_PUBLIC_BASE_URL, BETTER_AUTH_URL, BETTER_AUTH_BASE_URL, PAPERCLIP_PUBLIC_URL) is written as host:port without a scheme, the server does not start.

host:port is not invalid input. It is a valid URL with an opaque scheme, and the origin of an opaque-scheme URL serialises to the string "null":

> new URL("runtime-host.example.test:3100").origin
'null'                       // a string, not the null value -- and therefore truthy
> new URL("null")
TypeError: Invalid URL

All line references below are against master at e26d7879 (HEAD of master on 2026-09-17), and every one of them was re-opened at that commit before filing.

Both call sites in server/src/runtime-api.ts guard with try/catch plus a truthiness test, and neither catches this: the parse succeeds, so the catch never runs, and "null" is truthy, so the guard lets it through.

  1. choosePrimaryRuntimeApiUrl (runtime-api.ts:55-62) returns the string "null", which is then exported as PAPERCLIP_RUNTIME_API_URL into every agent run.
  2. buildRuntimeApiCandidateUrls (runtime-api.ts:121-130) reaches new URL(explicitOrigin).protocol with explicitOrigin === "null" and throws TypeError: Invalid URL.

Both are called on the startup path (index.ts:936 and :943) with no try/catch around them, so the process dies during boot. The thrown message names neither the offending config key nor the offending value, so the operator sees only TypeError: Invalid URL from a boot that used to work.

Expected behavior

A public base URL whose origin cannot be used should be treated as absent config — exactly like input that fails to parse today, which already falls through to derived candidates. A missing https:// should not be able to stop the server from starting.

Steps to reproduce

Save the script below as repro.mjs next to a checkout and run node repro.mjs server/src/runtime-api.ts (Node 22+ for TypeScript type stripping; it works the same against a compiled server/dist/runtime-api.js).

import { choosePrimaryRuntimeApiUrl, buildRuntimeApiCandidateUrls } from "./server/src/runtime-api.ts";

// 1. primary URL becomes the string "null"
console.log(choosePrimaryRuntimeApiUrl({
  authPublicBaseUrl: "runtime-host.example.test:3100",
  allowedHostnames: ["runtime-host.example.test"],
  bindHost: "0.0.0.0",
  port: 3102,
}));
// => 'null'          expected: 'http://runtime-host.example.test:3102'

// 2. candidate construction throws
buildRuntimeApiCandidateUrls({
  authPublicBaseUrl: "runtime-host.example.test:3100",
  allowedHostnames: ["runtime-host.example.test"],
  bindHost: "0.0.0.0",
  port: 3102,
  networkInterfacesMap: {},
});
// => TypeError: Invalid URL

The fuller 8-arm repro is included at the end of this issue: 5 regression arms describing behaviour that must not change (including the scheme-inheritance and loopback behaviour that separate open PRs are touching), 1 control arm of genuinely unparseable input that passes on both sides, and the 2 arms above. Run today against master at e26d7879 it scores 6/8 (exit 1), failing exactly the 2 arms above; against the same file with the fix applied it scores 8/8 (exit 0).

Deployment mode

Reproduced on a containerised self-hosted deployment (TLS terminated by a reverse proxy, app bound to 0.0.0.0), but the defect is in pure URL handling and is independent of deployment shape.

Scope note

This is deliberately narrow and is not the same defect as #12811 / #9492 (the runtime API URL being derived from the public origin) or the candidate-ordering work in #9228 / #11564 / #12886. Those are about which reachable origin is chosen; this one is about a valid-but-opaque URL escaping the parse guards and killing the boot. The repro below includes a regression arm that pins today's scheme-inheritance behaviour unchanged, so the fix can land independently of, and without conflicting with, any of those.

I am happy to open a pull request with the fix and the tests.

Full repro (8 arms)

Save as repro-scheme-less-base-url.mjs and run node repro-scheme-less-base-url.mjs server/src/runtime-api.ts (Node 22+).

repro-scheme-less-base-url.mjs
#!/usr/bin/env node
// Repro for: a scheme-less `auth.publicBaseUrl` makes `choosePrimaryRuntimeApiUrl`
// return the literal string "null" and makes `buildRuntimeApiCandidateUrls` throw
// `TypeError: Invalid URL` on the startup path.
//
// Usage:  node repro-scheme-less-base-url.mjs <path-to-runtime-api.{ts,js}>
//
// Arms marked [REGRESSION] describe behaviour `master` already has and that the fix
// must not change -- including the scheme and loopback behaviour that separate open
// pull requests are changing, so this repro stays orthogonal to them.
// Arms marked [BUG] describe the defect: they MUST fail against `master` and pass
// against the patched file, otherwise the repro does not discriminate.
// The [CONTROL] arm is unparseable junk input; it passes on BOTH sides.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";

const modulePath = resolve(process.argv[2] ?? "./runtime-api.ts");
const md5 = createHash("md5").update(readFileSync(modulePath)).digest("hex");
const mod = await import(pathToFileURL(modulePath).href);

const IFACES = {
  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" }],
};

const arms = [
  {
    name: "[REGRESSION] primary honours an explicit public base URL",
    run: () => mod.choosePrimaryRuntimeApiUrl({
      authPublicBaseUrl: "https://paperclip.example.com/base/path",
      allowedHostnames: ["198.51.100.10"], bindHost: "0.0.0.0", port: 3102,
    }),
    expect: "https://paperclip.example.com",
  },
  {
    name: "[REGRESSION] primary prefers a loopback bind host",
    run: () => mod.choosePrimaryRuntimeApiUrl({
      authPublicBaseUrl: null,
      allowedHostnames: ["192.168.1.50"], bindHost: "127.0.0.1", port: 3100,
    }),
    expect: "http://127.0.0.1:3100",
  },
  {
    name: "[REGRESSION] host.docker.internal is added when the public base is loopback",
    run: () => mod.buildRuntimeApiCandidateUrls({
      authPublicBaseUrl: "http://127.0.0.1:3102",
      allowedHostnames: [], bindHost: "127.0.0.1", port: 3102, networkInterfacesMap: {},
    }),
    expect: ["http://127.0.0.1:3102", "http://host.docker.internal:3102"],
  },
  {
    name: "[REGRESSION] reachable interfaces exclude loopback and link-local",
    run: () => mod.collectReachableInterfaceHosts({ networkInterfacesMap: IFACES }),
    expect: ["203.0.113.42"],
  },
  {
    name: "[REGRESSION] derived candidates keep inheriting the public scheme (orthogonal to #12811/#9492)",
    run: () => mod.buildRuntimeApiCandidateUrls({
      preferredApiUrl: "https://paperclip.example.com",
      authPublicBaseUrl: "https://paperclip.example.com",
      allowedHostnames: ["paperclip.example.com"], bindHost: "0.0.0.0", port: 3100,
      networkInterfacesMap: IFACES,
    }),
    expect: ["https://paperclip.example.com", "https://paperclip.example.com:3100", "https://203.0.113.42:3100"],
  },
  {
    name: "[CONTROL] a public base URL that does not parse is ignored (passes on both sides)",
    run: () => mod.buildRuntimeApiCandidateUrls({
      authPublicBaseUrl: "ht!tp://%%%",
      allowedHostnames: ["runtime-host.example.test"], bindHost: "127.0.0.1", port: 3102,
      networkInterfacesMap: {},
    }),
    expect: ["http://runtime-host.example.test:3102", "http://127.0.0.1:3102"],
  },
  {
    name: "[BUG] a scheme-less public base URL must not become the string \"null\"",
    run: () => mod.choosePrimaryRuntimeApiUrl({
      authPublicBaseUrl: "runtime-host.example.test:3100",
      allowedHostnames: ["runtime-host.example.test"], bindHost: "0.0.0.0", port: 3102,
    }),
    expect: "http://runtime-host.example.test:3102",
  },
  {
    name: "[BUG] a scheme-less public base URL must not throw while building candidates",
    run: () => mod.buildRuntimeApiCandidateUrls({
      authPublicBaseUrl: "runtime-host.example.test:3100",
      allowedHostnames: ["runtime-host.example.test"], bindHost: "0.0.0.0", port: 3102,
      networkInterfacesMap: {},
    }),
    expect: ["http://runtime-host.example.test:3102"],
  },
];

console.log(`module : ${modulePath}`);
console.log(`md5    : ${md5}`);
console.log("");

let failures = 0;
for (const arm of arms) {
  let actual;
  try {
    actual = arm.run();
  } catch (error) {
    actual = `THREW ${error.constructor.name}: ${error.message}`;
  }
  const ok = JSON.stringify(actual) === JSON.stringify(arm.expect);
  if (!ok) failures += 1;
  console.log(`${ok ? "PASS" : "FAIL"} ${arm.name}`);
  if (!ok) {
    console.log(`     expected: ${JSON.stringify(arm.expect)}`);
    console.log(`     actual  : ${JSON.stringify(actual)}`);
  }
}

console.log("");
console.log(`${arms.length - failures}/${arms.length} arms passed`);
process.exit(failures === 0 ? 0 : 1);

Output against master at e26d7879:

PASS [REGRESSION] primary honours an explicit public base URL
PASS [REGRESSION] primary prefers a loopback bind host
PASS [REGRESSION] host.docker.internal is added when the public base is loopback
PASS [REGRESSION] reachable interfaces exclude loopback and link-local
PASS [REGRESSION] derived candidates keep inheriting the public scheme (orthogonal to #12811/#9492)
PASS [CONTROL] a public base URL that does not parse is ignored (passes on both sides)
FAIL [BUG] a scheme-less public base URL must not become the string "null"
     expected: "http://runtime-host.example.test:3102"
     actual  : "null"
FAIL [BUG] a scheme-less public base URL must not throw while building candidates
     expected: ["http://runtime-host.example.test:3102"]
     actual  : "THREW TypeError: Invalid URL"

6/8 arms passed