Skip to content

Commit eaef9eb

Browse files
adam-rlclaude
andauthored
feat: allow RUNLOOP_BASE_URL to include a port override (#240)
## Summary Relax `RUNLOOP_BASE_URL` validation so an optional `:port` may be supplied (e.g. `https://api.runloop.pro:8443`), enabling non-standard-port deployments and local testing. ## Changes - **`src/utils/config.ts`** — `checkBaseDomain()` no longer rejects a port. It still requires the `https://api.<domain>` shape and still rejects path, query, and fragment. The port flows through to all API calls via `baseUrl()` (which returns the raw env value). Derived platform/SSH/tunnel hostnames are unaffected since `runloopBaseDomain()` uses `URL.hostname`, which excludes the port. - **`README.md`** — documents the optional `:port`. - **`tests/__tests__/utils/baseUrl.test.ts`** (new) — covers port acceptance, continued rejection of path/query/non-https, that `baseUrl()` includes the port, and that `runloopBaseDomain()` strips both the `api.` prefix and the port. ## Notes `https` is still required, so local `http://` overrides remain rejected. Happy to relax that too if local dev needs it. ## Verification - `pnpm run build` (tsc) passes - eslint + prettier clean on changed files - 7/7 new tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b605dc0 commit eaef9eb

3 files changed

Lines changed: 92 additions & 9 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ By default the CLI and MCP server connect to `https://api.runloop.ai`. To use a
6262
export RUNLOOP_BASE_URL=https://api.runloop.pro
6363
```
6464

65-
The URL must be of the form `https://api.<domain>`. The CLI derives other service hostnames from the domain portion:
65+
The URL must be of the form `https://api.<domain>`, optionally with a `:port` (e.g. `https://api.runloop.pro:8443`). The CLI derives other service hostnames from the domain portion:
6666

6767
| Service | Host |
6868
|----------|------|

src/utils/config.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,10 @@ export function checkBaseDomain(): void {
7070
process.exit(1);
7171
}
7272

73-
if (
74-
parsed.port ||
75-
parsed.pathname.replace(/\/+$/, "") ||
76-
parsed.search ||
77-
parsed.hash
78-
) {
73+
if (parsed.pathname.replace(/\/+$/, "") || parsed.search || parsed.hash) {
7974
console.error(
80-
`Error: RUNLOOP_BASE_URL must not contain port, path, query, or fragment: ${raw}\n` +
81-
`Expected format: https://api.<domain>`,
75+
`Error: RUNLOOP_BASE_URL must not contain path, query, or fragment: ${raw}\n` +
76+
`Expected format: https://api.<domain> (an optional :port is allowed)`,
8277
);
8378
process.exit(1);
8479
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Tests for RUNLOOP_BASE_URL handling, including optional port overrides.
3+
*/
4+
5+
import {
6+
describe,
7+
it,
8+
expect,
9+
beforeEach,
10+
afterEach,
11+
jest,
12+
} from "@jest/globals";
13+
import {
14+
checkBaseDomain,
15+
baseUrl,
16+
runloopBaseDomain,
17+
_resetBaseDomainCache,
18+
} from "../../../src/utils/config.js";
19+
20+
describe("RUNLOOP_BASE_URL", () => {
21+
const original = process.env.RUNLOOP_BASE_URL;
22+
let exitSpy: jest.SpiedFunction<typeof process.exit>;
23+
let errorSpy: jest.SpiedFunction<typeof console.error>;
24+
25+
beforeEach(() => {
26+
_resetBaseDomainCache();
27+
exitSpy = jest.spyOn(process, "exit").mockImplementation(((
28+
code?: number,
29+
) => {
30+
throw new Error(`process.exit(${code})`);
31+
}) as never);
32+
errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
33+
});
34+
35+
afterEach(() => {
36+
if (original === undefined) delete process.env.RUNLOOP_BASE_URL;
37+
else process.env.RUNLOOP_BASE_URL = original;
38+
exitSpy.mockRestore();
39+
errorSpy.mockRestore();
40+
_resetBaseDomainCache();
41+
});
42+
43+
describe("checkBaseDomain", () => {
44+
it("accepts a bare api.<domain> URL", () => {
45+
process.env.RUNLOOP_BASE_URL = "https://api.runloop.pro";
46+
expect(() => checkBaseDomain()).not.toThrow();
47+
expect(exitSpy).not.toHaveBeenCalled();
48+
});
49+
50+
it("accepts an api.<domain> URL with a port override", () => {
51+
process.env.RUNLOOP_BASE_URL = "https://api.runloop.pro:8443";
52+
expect(() => checkBaseDomain()).not.toThrow();
53+
expect(exitSpy).not.toHaveBeenCalled();
54+
});
55+
56+
it("rejects a URL with a path", () => {
57+
process.env.RUNLOOP_BASE_URL = "https://api.runloop.pro/v1";
58+
expect(() => checkBaseDomain()).toThrow();
59+
expect(exitSpy).toHaveBeenCalledWith(1);
60+
});
61+
62+
it("rejects a URL with a query string", () => {
63+
process.env.RUNLOOP_BASE_URL = "https://api.runloop.pro?foo=bar";
64+
expect(() => checkBaseDomain()).toThrow();
65+
expect(exitSpy).toHaveBeenCalledWith(1);
66+
});
67+
68+
it("rejects a non-https URL", () => {
69+
process.env.RUNLOOP_BASE_URL = "http://api.runloop.pro:8443";
70+
expect(() => checkBaseDomain()).toThrow();
71+
expect(exitSpy).toHaveBeenCalledWith(1);
72+
});
73+
});
74+
75+
describe("baseUrl", () => {
76+
it("includes the port override in the API base URL", () => {
77+
process.env.RUNLOOP_BASE_URL = "https://api.runloop.pro:8443";
78+
expect(baseUrl()).toBe("https://api.runloop.pro:8443");
79+
});
80+
});
81+
82+
describe("runloopBaseDomain", () => {
83+
it("strips the api. prefix and the port", () => {
84+
process.env.RUNLOOP_BASE_URL = "https://api.runloop.pro:8443";
85+
expect(runloopBaseDomain()).toBe("runloop.pro");
86+
});
87+
});
88+
});

0 commit comments

Comments
 (0)