paperclipai/paperclip · error

Invalid configured Paperclip API origin

Error message

Invalid configured Paperclip API origin

What it means

runnerApiUrl parses the configured Paperclip API origin with new URL(apiUrl) and enforces a strict origin policy: protocol must be http: or https:, and username, password, search (query), and hash (fragment) must all be empty. Any violation throws "Invalid configured Paperclip API origin". This prevents the runner from sending authenticated agent JWTs to a malformed or smuggling-prone URL.

Source

Thrown at server/src/services/native-runtime/runner-api-client.ts:93

      if (operation.method === "DELETE" && target.some(id => activeIds.includes(id))) throw forbidden("The active runner task cannot delete itself");
    }
  }
  const contentType = (input.contentType ?? "application/json").split(";", 1)[0].trim().toLowerCase();
  if (input.body !== undefined && !input.files?.length) {
    const requestSchema = operation.requestBody?.content?.[contentType]?.schema as { type?: string } | undefined;
    if (contentType.includes("json") && requestSchema?.type === "object" && (!input.body || typeof input.body !== "object" || Array.isArray(input.body))) {
      throw badRequest("This operation requires body to be a JSON object. Pass the object directly, not a JSON-encoded string.");
    }
    if (contentType.includes("json") && requestSchema?.type === "array" && !Array.isArray(input.body)) {
      throw badRequest("This operation requires body to be a JSON array. Pass the array directly, not a JSON-encoded string.");
    }
  }
  return { input, operation };
}

export function runnerApiUrl(operation: RunnerApiOperation, input: RunnerApiCall, context: RunnerApiContext, apiUrl: string): URL {
  const origin = new URL(apiUrl);
  if (!["http:", "https:"].includes(origin.protocol) || origin.username || origin.password || origin.search || origin.hash) throw new Error("Invalid configured Paperclip API origin");
  const params = { ...input.pathParams };
  if (operation.path.includes("{companyId}")) params.companyId ??= context.companyId;
  const names = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
  for (const name of Object.keys(params)) if (!names.includes(name)) throw badRequest(`Unknown path parameter: ${name}`);
  const path = operation.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
    const value = params[name];
    if (!value || value === "." || value === ".." || /[\\/\x00-\x1f]/.test(value) || /%[0-9a-f]{2}/i.test(value)) throw badRequest(`Invalid or missing path parameter: ${name}`);
    return encodeURIComponent(value);
  });
  const url = new URL(path, origin.origin);
  if (url.origin !== origin.origin || !url.pathname.startsWith("/api/")) throw badRequest("Invalid API path");
  for (const [key, value] of Object.entries(input.query ?? {})) {
    if (value === undefined || value === null) continue;
    const parameter = operation.parameters.find((entry) => entry.in === "query" && entry.name === key);
    const values = Array.isArray(value) ? value : [value];
    if (values.some((entry) => !["string", "number", "boolean"].includes(typeof entry))) throw badRequest(`Query parameter ${key} must contain scalar values`);
    if (Array.isArray(value) && parameter?.explode === false) url.searchParams.set(key, values.join(","));
    else for (const entry of values) url.searchParams.append(key, String(entry));

View on GitHub (pinned to 01ad858492)

Solutions

  1. Set PAPERCLIP_API_URL (or binding.apiUrl) to a bare origin: scheme + host + optional port only, e.g. https://api.example.com or http://localhost:3100.
  2. Remove any query string, fragment, or userinfo (user:pass@) from the configured URL.
  3. Use http: or https: only; fix the scheme if a relative or non-http URL was supplied.
  4. Validate the value at deploy time with a quick check: new URL(v) && ['http:','https:'].includes(new URL(v).protocol) && !new URL(v).search && !new URL(v).hash && !new URL(v).username && !new URL(v).password.

Example fix

// before
PAPERCLIP_API_URL=https://api.example.com/?env=prod
// after
PAPERCLIP_API_URL=https://api.example.com
Defensive patterns

Strategy: validation

Validate before calling

function isValidApiOrigin(v: string | undefined): v is string {
  if (!v) return false;
  try {
    const u = new URL(v);
    return ["http:", "https:"].includes(u.protocol) && !u.username && !u.password && !u.search && !u.hash;
  } catch { return false; }
}
if (!isValidApiOrigin(process.env.PAPERCLIP_API_URL)) throw new Error("Set PAPERCLIP_API_URL to a bare http(s) origin");

Type guard

function isBareHttpOrigin(u: URL): boolean {
  return ["http:", "https:"].includes(u.protocol) && !u.username && !u.password && !u.search && !u.hash;
}

Try / catch

try {
  const url = runnerApiUrl(operation, input, context, apiUrl);
} catch (err) {
  if (err instanceof Error && err.message === "Invalid configured Paperclip API origin") {
    // correct PAPERCLIP_API_URL / binding.apiUrl to scheme://host[:port]
  } else throw err;
}

Prevention

When it happens

Trigger: io.apiUrl (from binding.apiUrl ?? process.env.PAPERCLIP_API_URL) is something like "https://api.example.com/path?x=1", "https://user:pass@host", "ftp://host", or contains a trailing fragment — anything failing the protocol/credentials/query/hash checks.

Common situations: PAPERCLIP_API_URL set with a trailing path, query string, or '#'; credentials embedded in the URL from a copied connection string; wrong scheme (e.g. "localhost:3100" parsed as an unexpected protocol); misconfigured reverse-proxy base URL.

Understand the failure class

Background: "Invalid URL" errors: why new URL(), URI.parse, and reqwest::Url reject your string — missing scheme, whitespace, and bad path format — this error's family across 39 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10). Data as JSON: /api/errors/d3a775bb18d7a773. Report an issue: GitHub.