Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const fakeStatus = {
botVibe: 'Focused, capable, effective',
botEmoji: '🤖',
workerUrl: 'https://claw.kilo.ai',
controllerCapabilitiesVersion: null,
name: 'Fake KiloClaw',
instanceId: 'fake-instance',
inboundEmailAddress: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function createStatus(status: KiloClawDashboardStatus['status']): KiloClawDashbo
botVibe: null,
botEmoji: null,
workerUrl: 'https://claw.kilo.ai',
controllerCapabilitiesVersion: null,
instanceId: null,
inboundEmailAddress: null,
inboundEmailEnabled: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const baseStatus: KiloClawDashboardStatus = {
botVibe: null,
botEmoji: null,
workerUrl: 'https://claw.kilo.ai',
controllerCapabilitiesVersion: null,
instanceId: null,
inboundEmailAddress: 'amber-river-quiet-maple@kiloclaw.ai',
inboundEmailEnabled: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const baseStatus: KiloClawDashboardStatus = {
botVibe: null,
botEmoji: null,
workerUrl: 'https://claw.kilo.ai',
controllerCapabilitiesVersion: null,
instanceId: 'instance-1',
inboundEmailAddress: 'amber-river-quiet-maple@kiloclaw.ai',
inboundEmailEnabled: true,
Expand Down
112 changes: 112 additions & 0 deletions apps/web/src/lib/config.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, it, expect } from '@jest/globals';
import { resolveInstanceUrlTemplate } from './config.server';

describe('resolveInstanceUrlTemplate', () => {
describe('kill switch (KILOCLAW_INSTANCE_URL_TEMPLATE=legacy)', () => {
it('returns empty (legacy routing) when set to the kill-switch sentinel in production', () => {
expect(resolveInstanceUrlTemplate('legacy', 'production', 'https://claw.kilo.ai')).toBe('');
});

it('matches the sentinel case-insensitively', () => {
expect(resolveInstanceUrlTemplate('Legacy', 'production', 'https://claw.kilo.ai')).toBe('');
expect(resolveInstanceUrlTemplate('LEGACY', 'production', 'https://claw.kilo.ai')).toBe('');
});

it('also disables per-instance URLs in dev when set', () => {
expect(resolveInstanceUrlTemplate('legacy', 'development', 'http://localhost:8795')).toBe('');
});

it('treats an explicit empty string as "unset" (falls through to defaults), not as a kill switch', () => {
// Platform env pipelines often coerce empty values to "unset", so
// empty string must not be the rollback signal.
expect(resolveInstanceUrlTemplate('', 'production', 'https://claw.kilo.ai')).toBe(
'https://{label}.kiloclaw.ai'
);
expect(resolveInstanceUrlTemplate('', 'development', 'http://localhost:8795')).toBe(
'http://{label}.kiloclaw.localhost:8795'
);
});
});

describe('production defaults', () => {
it('defaults to the canonical prod template when no override is set', () => {
expect(resolveInstanceUrlTemplate(undefined, 'production', 'https://claw.kilo.ai')).toBe(
'https://{label}.kiloclaw.ai'
);
});

it('honors an explicit override in production', () => {
expect(
resolveInstanceUrlTemplate(
'https://{label}.preview.kiloclaw.ai',
'production',
'https://claw.kilo.ai'
)
).toBe('https://{label}.preview.kiloclaw.ai');
});
});

describe('development / test defaults', () => {
it('derives a loopback-parity template from a localhost KILOCLAW_API_URL', () => {
expect(resolveInstanceUrlTemplate(undefined, 'development', 'http://localhost:8795')).toBe(
'http://{label}.kiloclaw.localhost:8795'
);
});

it('derives a loopback-parity template from a 127.0.0.1 KILOCLAW_API_URL', () => {
expect(resolveInstanceUrlTemplate(undefined, 'development', 'http://127.0.0.1:8795')).toBe(
'http://{label}.kiloclaw.localhost:8795'
);
});

it('preserves the port from KILOCLAW_API_URL when non-default', () => {
expect(resolveInstanceUrlTemplate(undefined, 'development', 'http://localhost:9999')).toBe(
'http://{label}.kiloclaw.localhost:9999'
);
});

it('preserves the scheme from KILOCLAW_API_URL', () => {
expect(resolveInstanceUrlTemplate(undefined, 'development', 'https://localhost:8795')).toBe(
'https://{label}.kiloclaw.localhost:8795'
);
});

it('falls back to the wrangler dev port when KILOCLAW_API_URL is missing', () => {
expect(resolveInstanceUrlTemplate(undefined, 'development', undefined)).toBe(
'http://{label}.kiloclaw.localhost:8795'
);
});

it('falls back when KILOCLAW_API_URL is unparsable', () => {
expect(resolveInstanceUrlTemplate(undefined, 'development', 'not a url')).toBe(
'http://{label}.kiloclaw.localhost:8795'
);
});

it('uses the fallback template when KILOCLAW_API_URL points at a non-loopback host', () => {
// Remote staging — dev mode with a non-local worker. We don't try
// to derive a wildcard host for it; fall back to the loopback
// template. Operators who want a real per-instance URL on remote
// staging set KILOCLAW_INSTANCE_URL_TEMPLATE explicitly.
expect(resolveInstanceUrlTemplate(undefined, 'development', 'https://staging.kilo.ai')).toBe(
'http://{label}.kiloclaw.localhost:8795'
);
});

it('defaults loopback-parity in test mode too', () => {
expect(resolveInstanceUrlTemplate(undefined, 'test', 'http://localhost:8795')).toBe(
'http://{label}.kiloclaw.localhost:8795'
);
});

it('honors a dev-parity override', () => {
expect(
resolveInstanceUrlTemplate(
'http://{label}.kiloclaw.localhost:8795',
'development',
'http://localhost:8795'
)
).toBe('http://{label}.kiloclaw.localhost:8795');
});
});
});
90 changes: 90 additions & 0 deletions apps/web/src/lib/config.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,96 @@ export const KILOCLAW_INTERNAL_API_SECRET = getEnvVariable('KILOCLAW_INTERNAL_AP
export const KILOCLAW_INBOUND_EMAIL_DOMAIN =
getEnvVariable('KILOCLAW_INBOUND_EMAIL_DOMAIN') || 'kiloclaw.ai';

/**
* Per-instance worker URL template.
*
* Per-instance URLs are the default in BOTH production and dev/test so a
* merge of the name-based routing feature flips them on automatically,
* without forcing anyone to edit env files.
*
* Resolution rules (checked in order):
* 1. `KILOCLAW_INSTANCE_URL_TEMPLATE=legacy` (case-insensitive) is the
* explicit **kill switch** — disables per-instance URLs entirely and
* falls back to the single-host `KILOCLAW_API_URL`. Operators can
* roll prod back without a code deploy; devs can disable locally.
* A non-empty sentinel is used (rather than empty string) because
* Vercel / Node env pipelines often coerce empty env entries into
* "unset", making an empty-string rollback unreliable.
* 2. A non-empty `KILOCLAW_INSTANCE_URL_TEMPLATE` is used verbatim.
* Must contain `{label}`; missing placeholder is a misconfiguration
* warned about at render time (see `workerUrlForInstance`).
* 3. Otherwise in `NODE_ENV=production`, default to the canonical
* `https://{label}.kiloclaw.ai` template.
* 4. Otherwise (dev/test) derive a template from `KILOCLAW_API_URL`:
* if `KILOCLAW_API_URL` looks like a loopback URL (`http://localhost:<port>`
* / `http://127.0.0.1:<port>`), emit
* `http://{label}.kiloclaw.localhost:<port>` so the browser
* auto-resolves `*.kiloclaw.localhost` to `127.0.0.1` per RFC 6761.
* If `KILOCLAW_API_URL` is missing or unparsable, fall back to the
* same template with the wrangler dev port (`8795`) — matches
* `.dev.vars.example`.
*
* When the template ends up set and contains `{label}`, `getStatus`
* emits a `workerUrl` pointing at the instance's own virtual host
* (derived from its sandboxId) for instances whose
* `controllerCapabilitiesVersion >= 2`. Pre-v2 instances keep falling
* back to `KILOCLAW_API_URL`.
*
* Exported as a plain function so it's testable without forcing a
* re-import of this entire module (which triggers production-only
* validation of unrelated secrets).
*/
const DEFAULT_DEV_WRANGLER_PORT = '8795';

/**
* Sentinel value for `KILOCLAW_INSTANCE_URL_TEMPLATE` that disables the
* per-instance URL pattern entirely. Case-insensitive match. Picked as
* a non-empty word because empty env values are unreliable across
* Vercel / Node / dotenv pipelines (often dropped or indistinguishable
* from "unset"), which would mean the kill switch silently fails open.
*/
const KILL_SWITCH_SENTINEL = 'legacy';

function deriveDevTemplateFromWorkerUrl(workerUrl: string | undefined): string {
const fallback = `http://{label}.kiloclaw.localhost:${DEFAULT_DEV_WRANGLER_PORT}`;
if (!workerUrl) return fallback;
try {
const parsed = new URL(workerUrl);
// Only derive when we're pointed at a loopback dev worker. Anything
// else (remote staging, preview domains, etc.) uses the same
// fallback — operators can still override explicitly.
if (parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
return fallback;
}
const port = parsed.port || DEFAULT_DEV_WRANGLER_PORT;
return `${parsed.protocol}//{label}.kiloclaw.localhost:${port}`;
} catch {
return fallback;
}
}

export function resolveInstanceUrlTemplate(
envVar: string | undefined,
nodeEnv: string | undefined,
workerUrl: string | undefined
): string {
// Explicit kill switch. Empty string falls through to the production
// / dev defaults — operators must set `legacy` to disable, not "".
if (envVar !== undefined && envVar.toLowerCase() === KILL_SWITCH_SENTINEL) {
return '';
}
// Non-empty explicit override wins.
if (envVar !== undefined && envVar !== '') return envVar;
if (nodeEnv === 'production') return 'https://{label}.kiloclaw.ai';
return deriveDevTemplateFromWorkerUrl(workerUrl);
}

export const KILOCLAW_INSTANCE_URL_TEMPLATE = resolveInstanceUrlTemplate(
Comment thread
pandemicsyn marked this conversation as resolved.
process.env.KILOCLAW_INSTANCE_URL_TEMPLATE,
process.env.NODE_ENV,
KILOCLAW_API_URL
);

// KiloClaw Early Bird Checkout
export const STRIPE_KILOCLAW_EARLYBIRD_PRICE_ID = getEnvVariable(
'STRIPE_KILOCLAW_EARLYBIRD_PRICE_ID'
Expand Down
136 changes: 136 additions & 0 deletions apps/web/src/lib/kiloclaw/instance-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, it, expect, jest } from '@jest/globals';
import { workerUrlForInstance } from './instance-url';
import { sandboxIdFromUserId, sandboxIdFromInstanceId } from '@kilocode/worker-utils/sandbox-id';

const LEGACY = 'https://claw.kilo.ai';
const TEMPLATE = 'https://{label}.kiloclaw.ai';

describe('workerUrlForInstance', () => {
it('falls back to the legacy URL when the template is unset', () => {
const sandboxId = sandboxIdFromInstanceId('550e8400-e29b-41d4-a716-446655440000');
expect(
workerUrlForInstance({
sandboxId,
controllerCapabilitiesVersion: 2,
template: '',
fallback: LEGACY,
})
).toBe(LEGACY);
});

it('falls back to the legacy URL and warns once when the template has no {label} placeholder', () => {
const sandboxId = sandboxIdFromInstanceId('550e8400-e29b-41d4-a716-446655440000');
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
expect(
workerUrlForInstance({
sandboxId,
controllerCapabilitiesVersion: 2,
template: 'https://claw.kiloclaw.ai',
fallback: LEGACY,
})
).toBe(LEGACY);
// Subsequent calls with the same misconfiguration must not spam logs.
workerUrlForInstance({
sandboxId,
controllerCapabilitiesVersion: 2,
template: 'https://claw.kiloclaw.ai',
fallback: LEGACY,
});
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toMatch(/missing the \{label\} placeholder/);
} finally {
warn.mockRestore();
}
});

it('falls back to the legacy URL for pre-v2 instances', () => {
const sandboxId = sandboxIdFromInstanceId('550e8400-e29b-41d4-a716-446655440000');
expect(
workerUrlForInstance({
sandboxId,
controllerCapabilitiesVersion: null,
template: TEMPLATE,
fallback: LEGACY,
})
).toBe(LEGACY);
expect(
workerUrlForInstance({
sandboxId,
controllerCapabilitiesVersion: 1,
template: TEMPLATE,
fallback: LEGACY,
})
).toBe(LEGACY);
});

it('falls back to the legacy URL when sandboxId is null (no-instance sentinel)', () => {
expect(
workerUrlForInstance({
sandboxId: null,
controllerCapabilitiesVersion: 2,
template: TEMPLATE,
fallback: LEGACY,
})
).toBe(LEGACY);
});

it('expands the template for instance-keyed sandboxIds on v2+', () => {
const sandboxId = sandboxIdFromInstanceId('550e8400-e29b-41d4-a716-446655440000');
expect(
workerUrlForInstance({
sandboxId,
controllerCapabilitiesVersion: 2,
template: TEMPLATE,
fallback: LEGACY,
})
).toBe('https://i-550e8400e29b41d4a716446655440000.kiloclaw.ai');
});

it('expands the template for legacy userId sandboxes on v2+', () => {
const sandboxId = sandboxIdFromUserId('oauth/google:118234567890');
expect(
workerUrlForInstance({
sandboxId,
controllerCapabilitiesVersion: 2,
template: TEMPLATE,
fallback: LEGACY,
})
).toMatch(/^https:\/\/u-[0-9a-v]+\.kiloclaw\.ai$/);
});

it('falls back to the legacy URL when the sandboxId cannot be safely labelled', () => {
const overlongSandboxId = sandboxIdFromUserId('a'.repeat(39));
expect(
workerUrlForInstance({
sandboxId: overlongSandboxId,
controllerCapabilitiesVersion: 2,
template: TEMPLATE,
fallback: LEGACY,
})
).toBe(LEGACY);
});

it('uses the hardcoded default when fallback is empty', () => {
expect(
workerUrlForInstance({
sandboxId: null,
controllerCapabilitiesVersion: 2,
template: '',
fallback: '',
})
).toBe('https://claw.kilo.ai');
});

it('works with dev-parity templates (http + port)', () => {
const sandboxId = sandboxIdFromInstanceId('550e8400-e29b-41d4-a716-446655440000');
expect(
workerUrlForInstance({
sandboxId,
controllerCapabilitiesVersion: 2,
template: 'http://{label}.kiloclaw.localhost:8795',
fallback: 'http://localhost:8795',
})
).toBe('http://i-550e8400e29b41d4a716446655440000.kiloclaw.localhost:8795');
});
});
Loading