Skip to content

Commit 9e80deb

Browse files
authored
feat(kiloclaw): mint per-instance <label>.kiloclaw.ai URLs (PR3) (#3029)
* refactor(kiloclaw): consolidate catch-all proxy blocks All four catch-all paths (/i/:instanceId/*, host-based, cookie-routed, default personal) now share a single `proxyThroughTarget` helper. Previously the host branch used the helper while the other three inlined the same ~130-line HTTP + WebSocket relay; this commit collapses them onto the helper. The helper gains optional `unreachableHint` / `startingUpHint` parameters so the default-personal branch can keep its user-facing hint strings (these are test-asserted). All other behavior is preserved — same status codes, same JSON shapes, identical WebSocket relay semantics. One pre-existing inconsistency is unified rather than preserved: the cookie branch used to return `{ error: 'WebSocket upgrade failed' }` with status 502 when the upstream returned no webSocket. The helper (and the /i and default branches) return the raw containerResponse in that case, which is strictly more informative. No test asserted the cookie-branch-specific response. src/index.ts: +48 / -393 (net -345). * refactor(worker-utils): host label + sandbox-id helpers for cross-package reuse Move the pure sandboxId <-> hostname-label logic (plus sandboxId <-> userId encoding) from `services/kiloclaw/src/auth/` into `@kilocode/worker-utils` so `apps/web` can use it to mint per-instance URLs in PR3 without duplicating the base64url / base32hex encoding. - New subpath exports: `@kilocode/worker-utils/hostname-label` and `@kilocode/worker-utils/sandbox-id`. - The existing `services/kiloclaw/src/auth/hostname-label.ts` and `sandbox-id.ts` become thin re-export shims so the many existing `./auth/hostname-label` / `./auth/sandbox-id` imports inside the worker don't have to migrate all at once. - Tests move with the implementation. No behaviour change. * feat(kiloclaw): route users to per-instance `<label>.kiloclaw.ai` URLs Wires the dashboard to emit per-instance hostnames as `workerUrl` so users of v2+ instances open their instance directly on its virtual host instead of the single shared `claw.kilo.ai` / `claw.kilosessions.ai` endpoint. Completes the PR3 step of the name-based routing rollout; PR1 built the host space, PR2 taught the worker to route by Host. - New env var `KILOCLAW_INSTANCE_URL_TEMPLATE` (e.g. `https://{label}.kiloclaw.ai`). Unset → legacy single-host behaviour (dev default, no change). - `workerUrlForInstance` helper expands the template only when the instance is on `controllerCapabilitiesVersion >= 2`. Pre-v2 instances don't have their per-instance origin in `OPENCLAW_ALLOWED_ORIGINS`, so WebSocket upgrades from the new host would fail openclaw's exact-match origin check; keep them on the legacy host until they restart onto v2. - `getStatus` tRPC procedures (personal + org) thread the new field through and compute `workerUrl` via the helper. No-instance sentinel stays on the legacy URL (no sandboxId yet to label). - `PlatformStatusResponse` type gains `controllerCapabilitiesVersion`; worker DO was already emitting it, this just exposes it to callers. - Worker `KILOCLAW_CHECKIN_URL` flipped from `claw.kilosessions.ai` to `claw.kiloclaw.ai`. Only affects newly-provisioned / restarted machines; running machines continue hitting the legacy URL (still live via the existing custom domain). - Test fixtures (state tests, walkthrough) updated for the new field. - New helper covered by 9 unit tests in `instance-url.test.ts`. * fix(kiloclaw): address PR review — normalize WS no-upgrade, reserve 'claw' label, warn on misconfigured URL template Three PR review findings on the PR2/PR3 routing work. 1. proxyThroughTarget: on a WebSocket request where the upstream returns a non-upgrade response, return a normalized 502 JSON `{ error: 'WebSocket upgrade failed' }` instead of the raw upstream response. The previous helper passed `containerResponse` straight through (matching the pre-refactor /i/ and default branches but changing the cookie-routed branch's contract, which was 502 JSON). Raw upstream bodies on this edge path can leak provider/controller error detail to the Control UI; normalize to a minimal error body and log the upstream status for operators. Unified across all four call sites. 2. Host-based routing: add an explicit `claw` reserved-label guard. With PR3 flipping KILOCLAW_CHECKIN_URL to `claw.kiloclaw.ai`, that hostname now enters the `*.kiloclaw.ai/*` wildcard route. The controller check-in path is registered before the catch-all so it works, but any other path on that host was hitting handleHostBasedRoute → `claw` fails label parsing → 404 "Instance not found" — a confusing error for a reserved operational hostname. Short-circuit the host branch for reserved labels so requests fall through to cookie/default routing and produce the normal catch-all responses instead. Introduces RESERVED_INSTANCE_HOST_LABELS as an explicit set so future reserved hostnames (`api`, `www`, etc.) are trivial to add. 3. workerUrlForInstance: log a one-time `console.warn` when KILOCLAW_INSTANCE_URL_TEMPLATE is set but missing the `{label}` placeholder. Silently falling back to the legacy URL hides the misconfiguration. Guarded by a module-level flag so the warning doesn't spam logs on every getStatus call. * fix(worker-utils): drop .js extensions from sibling imports for Turbopack Next.js / Turbopack can't resolve `./instance-id.js` when apps/web imports @kilocode/worker-utils/hostname-label through the subpath export — the .js rewrite convention only works in resolvers that do the TS→JS extension mapping (Vitest, tsgo). Turbopack treats the literal .js filename and fails. Drop the .js suffix on the three sibling imports that crossed the package boundary. worker-utils uses `moduleResolution: bundler`, which accepts extensionless imports, so the typecheck and Vitest runs stay green. * feat(web): default KILOCLAW_INSTANCE_URL_TEMPLATE to prod on NODE_ENV=production So the per-instance URL rollout goes live automatically on merge, without needing a Vercel env var edit. - New `resolveInstanceUrlTemplate(envVar, nodeEnv)` pure function with three-level resolution: explicit override wins (including empty string as a kill switch), then NODE_ENV=production defaults to the canonical `https://{label}.kiloclaw.ai` template, otherwise empty (dev/test). - Operators can roll back without a code deploy by setting `KILOCLAW_INSTANCE_URL_TEMPLATE=` (empty) in Vercel. - Dev/test stay on legacy localhost unless a dev opts in by setting the dev-parity template (`http://{label}.kiloclaw.localhost:8795`) explicitly. - Factored out of the config-module scope so it's testable without forcing a re-import of config.server.ts, which runs production-only validation on unrelated secrets at module load time. * feat(web): default per-instance URLs on in dev too, derived from KILOCLAW_API_URL Previously `resolveInstanceUrlTemplate` only defaulted on in production; dev/test returned empty so the dashboard kept emitting the legacy `KILOCLAW_API_URL` (usually `http://localhost:8795`) as `workerUrl` until a developer manually added `KILOCLAW_INSTANCE_URL_TEMPLATE` to `apps/web/.env.local`. That hoop defeats the point of merging the feature — local repro of the per-instance flow is exactly what devs need to verify changes. Make the new pattern default in dev too, derived from `KILOCLAW_API_URL`: - `http://localhost:8795` -> `http://{label}.kiloclaw.localhost:8795` - `http://127.0.0.1:9000` -> `http://{label}.kiloclaw.localhost:9000` - Non-loopback / missing / unparsable `KILOCLAW_API_URL` falls back to `http://{label}.kiloclaw.localhost:8795` (the wrangler dev default). Scheme and port are preserved from `KILOCLAW_API_URL` so a dev running wrangler on a non-default port still gets a working template. Opt-out is unchanged: `KILOCLAW_INSTANCE_URL_TEMPLATE=` (empty) in env returns empty and falls back to legacy routing. Tests exercise prod default, dev defaults across loopback/non-loopback URLs, explicit overrides, and the kill-switch opt-out. * feat(kiloclaw): 301 redirect www.kiloclaw.ai -> apex The `*.kiloclaw.ai/*` wildcard route catches `www.kiloclaw.ai`; without an explicit handler it would surface as "Instance not found" 404 because `www` fails hostname-label parsing. Add a canonical-redirect set (currently just `www`) that 301s to the apex host derived from `KILOCLAW_INSTANCE_HOST_SUFFIX` + `KILOCLAW_INSTANCE_URL_SCHEME`, so dev parity works automatically (`www.kiloclaw.localhost:8795` -> `kiloclaw.localhost:8795`) without hardcoding the apex. Redirect target is built via URL setters (pathname/search), not string concatenation, to sidestep the scheme-relative `//` open-redirect class PR2 had to patch out of the capability-gate path. Two new tests cover the prod and dev-parity cases. DNS side: the existing proxied wildcard `AAAA * -> 100::` record on `kiloclaw.ai` already covers `www`, and the wildcard cert SAN matches one-label subdomains. No extra DNS / cert work needed. * fix(kiloclaw,web): address PR review — drop www redirect, harden kill switch Two review findings on the latest PR3 commits. 1. **www redirect removed.** `CANONICAL_APEX_REDIRECT_LABELS` and `buildApexRedirectUrl` lived inside `handleHostBasedRoute`, which runs from the catch-all route. The catch-all is behind the global `authGuard` middleware, so unauthenticated `www.kiloclaw.ai` requests would 401 before the redirect could fire — exactly the traffic the redirect was meant to serve. Tests passed because the auth middleware is mocked to always succeed in the worker test harness. Rather than rearrange the middleware chain to make it work, drop the worker-side redirect entirely: the `www` → apex redirect is handled by Cloudflare DNS/edge routes, which is the right layer for this anyway (no worker invocation cost, runs before any auth, always correct). 2. **Kill switch now uses an explicit `legacy` sentinel.** The previous `KILOCLAW_INSTANCE_URL_TEMPLATE=` (empty string) rollback was brittle: Vercel / Node env pipelines frequently coerce empty entries into "unset", making an empty-string rollback indistinguishable from the default-on path (fails open). Switch to a non-empty word sentinel: `KILOCLAW_INSTANCE_URL_TEMPLATE=legacy` (case-insensitive) disables per-instance URLs. Empty string now falls through to the production/dev defaults, matching "unset" semantics across all env pipelines. Tests updated to cover the new sentinel behavior and to assert that empty string no longer disables the feature.
1 parent acb2ac0 commit 9e80deb

20 files changed

Lines changed: 908 additions & 685 deletions

apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const fakeStatus = {
6767
botVibe: 'Focused, capable, effective',
6868
botEmoji: '🤖',
6969
workerUrl: 'https://claw.kilo.ai',
70+
controllerCapabilitiesVersion: null,
7071
name: 'Fake KiloClaw',
7172
instanceId: 'fake-instance',
7273
inboundEmailAddress: null,

apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ function createStatus(status: KiloClawDashboardStatus['status']): KiloClawDashbo
4848
botVibe: null,
4949
botEmoji: null,
5050
workerUrl: 'https://claw.kilo.ai',
51+
controllerCapabilitiesVersion: null,
5152
instanceId: null,
5253
inboundEmailAddress: null,
5354
inboundEmailEnabled: false,

apps/web/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const baseStatus: KiloClawDashboardStatus = {
4141
botVibe: null,
4242
botEmoji: null,
4343
workerUrl: 'https://claw.kilo.ai',
44+
controllerCapabilitiesVersion: null,
4445
instanceId: null,
4546
inboundEmailAddress: 'amber-river-quiet-maple@kiloclaw.ai',
4647
inboundEmailEnabled: true,

apps/web/src/app/(app)/claw/new/ClawNewClient.state.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const baseStatus: KiloClawDashboardStatus = {
3939
botVibe: null,
4040
botEmoji: null,
4141
workerUrl: 'https://claw.kilo.ai',
42+
controllerCapabilitiesVersion: null,
4243
instanceId: 'instance-1',
4344
inboundEmailAddress: 'amber-river-quiet-maple@kiloclaw.ai',
4445
inboundEmailEnabled: true,
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, it, expect } from '@jest/globals';
2+
import { resolveInstanceUrlTemplate } from './config.server';
3+
4+
describe('resolveInstanceUrlTemplate', () => {
5+
describe('kill switch (KILOCLAW_INSTANCE_URL_TEMPLATE=legacy)', () => {
6+
it('returns empty (legacy routing) when set to the kill-switch sentinel in production', () => {
7+
expect(resolveInstanceUrlTemplate('legacy', 'production', 'https://claw.kilo.ai')).toBe('');
8+
});
9+
10+
it('matches the sentinel case-insensitively', () => {
11+
expect(resolveInstanceUrlTemplate('Legacy', 'production', 'https://claw.kilo.ai')).toBe('');
12+
expect(resolveInstanceUrlTemplate('LEGACY', 'production', 'https://claw.kilo.ai')).toBe('');
13+
});
14+
15+
it('also disables per-instance URLs in dev when set', () => {
16+
expect(resolveInstanceUrlTemplate('legacy', 'development', 'http://localhost:8795')).toBe('');
17+
});
18+
19+
it('treats an explicit empty string as "unset" (falls through to defaults), not as a kill switch', () => {
20+
// Platform env pipelines often coerce empty values to "unset", so
21+
// empty string must not be the rollback signal.
22+
expect(resolveInstanceUrlTemplate('', 'production', 'https://claw.kilo.ai')).toBe(
23+
'https://{label}.kiloclaw.ai'
24+
);
25+
expect(resolveInstanceUrlTemplate('', 'development', 'http://localhost:8795')).toBe(
26+
'http://{label}.kiloclaw.localhost:8795'
27+
);
28+
});
29+
});
30+
31+
describe('production defaults', () => {
32+
it('defaults to the canonical prod template when no override is set', () => {
33+
expect(resolveInstanceUrlTemplate(undefined, 'production', 'https://claw.kilo.ai')).toBe(
34+
'https://{label}.kiloclaw.ai'
35+
);
36+
});
37+
38+
it('honors an explicit override in production', () => {
39+
expect(
40+
resolveInstanceUrlTemplate(
41+
'https://{label}.preview.kiloclaw.ai',
42+
'production',
43+
'https://claw.kilo.ai'
44+
)
45+
).toBe('https://{label}.preview.kiloclaw.ai');
46+
});
47+
});
48+
49+
describe('development / test defaults', () => {
50+
it('derives a loopback-parity template from a localhost KILOCLAW_API_URL', () => {
51+
expect(resolveInstanceUrlTemplate(undefined, 'development', 'http://localhost:8795')).toBe(
52+
'http://{label}.kiloclaw.localhost:8795'
53+
);
54+
});
55+
56+
it('derives a loopback-parity template from a 127.0.0.1 KILOCLAW_API_URL', () => {
57+
expect(resolveInstanceUrlTemplate(undefined, 'development', 'http://127.0.0.1:8795')).toBe(
58+
'http://{label}.kiloclaw.localhost:8795'
59+
);
60+
});
61+
62+
it('preserves the port from KILOCLAW_API_URL when non-default', () => {
63+
expect(resolveInstanceUrlTemplate(undefined, 'development', 'http://localhost:9999')).toBe(
64+
'http://{label}.kiloclaw.localhost:9999'
65+
);
66+
});
67+
68+
it('preserves the scheme from KILOCLAW_API_URL', () => {
69+
expect(resolveInstanceUrlTemplate(undefined, 'development', 'https://localhost:8795')).toBe(
70+
'https://{label}.kiloclaw.localhost:8795'
71+
);
72+
});
73+
74+
it('falls back to the wrangler dev port when KILOCLAW_API_URL is missing', () => {
75+
expect(resolveInstanceUrlTemplate(undefined, 'development', undefined)).toBe(
76+
'http://{label}.kiloclaw.localhost:8795'
77+
);
78+
});
79+
80+
it('falls back when KILOCLAW_API_URL is unparsable', () => {
81+
expect(resolveInstanceUrlTemplate(undefined, 'development', 'not a url')).toBe(
82+
'http://{label}.kiloclaw.localhost:8795'
83+
);
84+
});
85+
86+
it('uses the fallback template when KILOCLAW_API_URL points at a non-loopback host', () => {
87+
// Remote staging — dev mode with a non-local worker. We don't try
88+
// to derive a wildcard host for it; fall back to the loopback
89+
// template. Operators who want a real per-instance URL on remote
90+
// staging set KILOCLAW_INSTANCE_URL_TEMPLATE explicitly.
91+
expect(resolveInstanceUrlTemplate(undefined, 'development', 'https://staging.kilo.ai')).toBe(
92+
'http://{label}.kiloclaw.localhost:8795'
93+
);
94+
});
95+
96+
it('defaults loopback-parity in test mode too', () => {
97+
expect(resolveInstanceUrlTemplate(undefined, 'test', 'http://localhost:8795')).toBe(
98+
'http://{label}.kiloclaw.localhost:8795'
99+
);
100+
});
101+
102+
it('honors a dev-parity override', () => {
103+
expect(
104+
resolveInstanceUrlTemplate(
105+
'http://{label}.kiloclaw.localhost:8795',
106+
'development',
107+
'http://localhost:8795'
108+
)
109+
).toBe('http://{label}.kiloclaw.localhost:8795');
110+
});
111+
});
112+
});

apps/web/src/lib/config.server.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,96 @@ export const KILOCLAW_INTERNAL_API_SECRET = getEnvVariable('KILOCLAW_INTERNAL_AP
209209
export const KILOCLAW_INBOUND_EMAIL_DOMAIN =
210210
getEnvVariable('KILOCLAW_INBOUND_EMAIL_DOMAIN') || 'kiloclaw.ai';
211211

212+
/**
213+
* Per-instance worker URL template.
214+
*
215+
* Per-instance URLs are the default in BOTH production and dev/test so a
216+
* merge of the name-based routing feature flips them on automatically,
217+
* without forcing anyone to edit env files.
218+
*
219+
* Resolution rules (checked in order):
220+
* 1. `KILOCLAW_INSTANCE_URL_TEMPLATE=legacy` (case-insensitive) is the
221+
* explicit **kill switch** — disables per-instance URLs entirely and
222+
* falls back to the single-host `KILOCLAW_API_URL`. Operators can
223+
* roll prod back without a code deploy; devs can disable locally.
224+
* A non-empty sentinel is used (rather than empty string) because
225+
* Vercel / Node env pipelines often coerce empty env entries into
226+
* "unset", making an empty-string rollback unreliable.
227+
* 2. A non-empty `KILOCLAW_INSTANCE_URL_TEMPLATE` is used verbatim.
228+
* Must contain `{label}`; missing placeholder is a misconfiguration
229+
* warned about at render time (see `workerUrlForInstance`).
230+
* 3. Otherwise in `NODE_ENV=production`, default to the canonical
231+
* `https://{label}.kiloclaw.ai` template.
232+
* 4. Otherwise (dev/test) derive a template from `KILOCLAW_API_URL`:
233+
* if `KILOCLAW_API_URL` looks like a loopback URL (`http://localhost:<port>`
234+
* / `http://127.0.0.1:<port>`), emit
235+
* `http://{label}.kiloclaw.localhost:<port>` so the browser
236+
* auto-resolves `*.kiloclaw.localhost` to `127.0.0.1` per RFC 6761.
237+
* If `KILOCLAW_API_URL` is missing or unparsable, fall back to the
238+
* same template with the wrangler dev port (`8795`) — matches
239+
* `.dev.vars.example`.
240+
*
241+
* When the template ends up set and contains `{label}`, `getStatus`
242+
* emits a `workerUrl` pointing at the instance's own virtual host
243+
* (derived from its sandboxId) for instances whose
244+
* `controllerCapabilitiesVersion >= 2`. Pre-v2 instances keep falling
245+
* back to `KILOCLAW_API_URL`.
246+
*
247+
* Exported as a plain function so it's testable without forcing a
248+
* re-import of this entire module (which triggers production-only
249+
* validation of unrelated secrets).
250+
*/
251+
const DEFAULT_DEV_WRANGLER_PORT = '8795';
252+
253+
/**
254+
* Sentinel value for `KILOCLAW_INSTANCE_URL_TEMPLATE` that disables the
255+
* per-instance URL pattern entirely. Case-insensitive match. Picked as
256+
* a non-empty word because empty env values are unreliable across
257+
* Vercel / Node / dotenv pipelines (often dropped or indistinguishable
258+
* from "unset"), which would mean the kill switch silently fails open.
259+
*/
260+
const KILL_SWITCH_SENTINEL = 'legacy';
261+
262+
function deriveDevTemplateFromWorkerUrl(workerUrl: string | undefined): string {
263+
const fallback = `http://{label}.kiloclaw.localhost:${DEFAULT_DEV_WRANGLER_PORT}`;
264+
if (!workerUrl) return fallback;
265+
try {
266+
const parsed = new URL(workerUrl);
267+
// Only derive when we're pointed at a loopback dev worker. Anything
268+
// else (remote staging, preview domains, etc.) uses the same
269+
// fallback — operators can still override explicitly.
270+
if (parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
271+
return fallback;
272+
}
273+
const port = parsed.port || DEFAULT_DEV_WRANGLER_PORT;
274+
return `${parsed.protocol}//{label}.kiloclaw.localhost:${port}`;
275+
} catch {
276+
return fallback;
277+
}
278+
}
279+
280+
export function resolveInstanceUrlTemplate(
281+
envVar: string | undefined,
282+
nodeEnv: string | undefined,
283+
workerUrl: string | undefined
284+
): string {
285+
// Explicit kill switch. Empty string falls through to the production
286+
// / dev defaults — operators must set `legacy` to disable, not "".
287+
if (envVar !== undefined && envVar.toLowerCase() === KILL_SWITCH_SENTINEL) {
288+
return '';
289+
}
290+
// Non-empty explicit override wins.
291+
if (envVar !== undefined && envVar !== '') return envVar;
292+
if (nodeEnv === 'production') return 'https://{label}.kiloclaw.ai';
293+
return deriveDevTemplateFromWorkerUrl(workerUrl);
294+
}
295+
296+
export const KILOCLAW_INSTANCE_URL_TEMPLATE = resolveInstanceUrlTemplate(
297+
process.env.KILOCLAW_INSTANCE_URL_TEMPLATE,
298+
process.env.NODE_ENV,
299+
KILOCLAW_API_URL
300+
);
301+
212302
// KiloClaw Early Bird Checkout
213303
export const STRIPE_KILOCLAW_EARLYBIRD_PRICE_ID = getEnvVariable(
214304
'STRIPE_KILOCLAW_EARLYBIRD_PRICE_ID'
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { describe, it, expect, jest } from '@jest/globals';
2+
import { workerUrlForInstance } from './instance-url';
3+
import { sandboxIdFromUserId, sandboxIdFromInstanceId } from '@kilocode/worker-utils/sandbox-id';
4+
5+
const LEGACY = 'https://claw.kilo.ai';
6+
const TEMPLATE = 'https://{label}.kiloclaw.ai';
7+
8+
describe('workerUrlForInstance', () => {
9+
it('falls back to the legacy URL when the template is unset', () => {
10+
const sandboxId = sandboxIdFromInstanceId('550e8400-e29b-41d4-a716-446655440000');
11+
expect(
12+
workerUrlForInstance({
13+
sandboxId,
14+
controllerCapabilitiesVersion: 2,
15+
template: '',
16+
fallback: LEGACY,
17+
})
18+
).toBe(LEGACY);
19+
});
20+
21+
it('falls back to the legacy URL and warns once when the template has no {label} placeholder', () => {
22+
const sandboxId = sandboxIdFromInstanceId('550e8400-e29b-41d4-a716-446655440000');
23+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
24+
try {
25+
expect(
26+
workerUrlForInstance({
27+
sandboxId,
28+
controllerCapabilitiesVersion: 2,
29+
template: 'https://claw.kiloclaw.ai',
30+
fallback: LEGACY,
31+
})
32+
).toBe(LEGACY);
33+
// Subsequent calls with the same misconfiguration must not spam logs.
34+
workerUrlForInstance({
35+
sandboxId,
36+
controllerCapabilitiesVersion: 2,
37+
template: 'https://claw.kiloclaw.ai',
38+
fallback: LEGACY,
39+
});
40+
expect(warn).toHaveBeenCalledTimes(1);
41+
expect(warn.mock.calls[0][0]).toMatch(/missing the \{label\} placeholder/);
42+
} finally {
43+
warn.mockRestore();
44+
}
45+
});
46+
47+
it('falls back to the legacy URL for pre-v2 instances', () => {
48+
const sandboxId = sandboxIdFromInstanceId('550e8400-e29b-41d4-a716-446655440000');
49+
expect(
50+
workerUrlForInstance({
51+
sandboxId,
52+
controllerCapabilitiesVersion: null,
53+
template: TEMPLATE,
54+
fallback: LEGACY,
55+
})
56+
).toBe(LEGACY);
57+
expect(
58+
workerUrlForInstance({
59+
sandboxId,
60+
controllerCapabilitiesVersion: 1,
61+
template: TEMPLATE,
62+
fallback: LEGACY,
63+
})
64+
).toBe(LEGACY);
65+
});
66+
67+
it('falls back to the legacy URL when sandboxId is null (no-instance sentinel)', () => {
68+
expect(
69+
workerUrlForInstance({
70+
sandboxId: null,
71+
controllerCapabilitiesVersion: 2,
72+
template: TEMPLATE,
73+
fallback: LEGACY,
74+
})
75+
).toBe(LEGACY);
76+
});
77+
78+
it('expands the template for instance-keyed sandboxIds on v2+', () => {
79+
const sandboxId = sandboxIdFromInstanceId('550e8400-e29b-41d4-a716-446655440000');
80+
expect(
81+
workerUrlForInstance({
82+
sandboxId,
83+
controllerCapabilitiesVersion: 2,
84+
template: TEMPLATE,
85+
fallback: LEGACY,
86+
})
87+
).toBe('https://i-550e8400e29b41d4a716446655440000.kiloclaw.ai');
88+
});
89+
90+
it('expands the template for legacy userId sandboxes on v2+', () => {
91+
const sandboxId = sandboxIdFromUserId('oauth/google:118234567890');
92+
expect(
93+
workerUrlForInstance({
94+
sandboxId,
95+
controllerCapabilitiesVersion: 2,
96+
template: TEMPLATE,
97+
fallback: LEGACY,
98+
})
99+
).toMatch(/^https:\/\/u-[0-9a-v]+\.kiloclaw\.ai$/);
100+
});
101+
102+
it('falls back to the legacy URL when the sandboxId cannot be safely labelled', () => {
103+
const overlongSandboxId = sandboxIdFromUserId('a'.repeat(39));
104+
expect(
105+
workerUrlForInstance({
106+
sandboxId: overlongSandboxId,
107+
controllerCapabilitiesVersion: 2,
108+
template: TEMPLATE,
109+
fallback: LEGACY,
110+
})
111+
).toBe(LEGACY);
112+
});
113+
114+
it('uses the hardcoded default when fallback is empty', () => {
115+
expect(
116+
workerUrlForInstance({
117+
sandboxId: null,
118+
controllerCapabilitiesVersion: 2,
119+
template: '',
120+
fallback: '',
121+
})
122+
).toBe('https://claw.kilo.ai');
123+
});
124+
125+
it('works with dev-parity templates (http + port)', () => {
126+
const sandboxId = sandboxIdFromInstanceId('550e8400-e29b-41d4-a716-446655440000');
127+
expect(
128+
workerUrlForInstance({
129+
sandboxId,
130+
controllerCapabilitiesVersion: 2,
131+
template: 'http://{label}.kiloclaw.localhost:8795',
132+
fallback: 'http://localhost:8795',
133+
})
134+
).toBe('http://i-550e8400e29b41d4a716446655440000.kiloclaw.localhost:8795');
135+
});
136+
});

0 commit comments

Comments
 (0)