diff --git a/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx b/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx index 1cdd0c5e04..3354912b07 100644 --- a/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx +++ b/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx @@ -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, diff --git a/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.test.ts b/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.test.ts index f2f729fe2a..dc5d7f1805 100644 --- a/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.test.ts +++ b/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.test.ts @@ -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, diff --git a/apps/web/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts b/apps/web/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts index 9c478097fa..ebc7df3245 100644 --- a/apps/web/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts +++ b/apps/web/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts @@ -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, diff --git a/apps/web/src/app/(app)/claw/new/ClawNewClient.state.test.ts b/apps/web/src/app/(app)/claw/new/ClawNewClient.state.test.ts index e64aaa12db..7d7316a41c 100644 --- a/apps/web/src/app/(app)/claw/new/ClawNewClient.state.test.ts +++ b/apps/web/src/app/(app)/claw/new/ClawNewClient.state.test.ts @@ -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, diff --git a/apps/web/src/lib/config.server.test.ts b/apps/web/src/lib/config.server.test.ts new file mode 100644 index 0000000000..cf53dd347d --- /dev/null +++ b/apps/web/src/lib/config.server.test.ts @@ -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'); + }); + }); +}); diff --git a/apps/web/src/lib/config.server.ts b/apps/web/src/lib/config.server.ts index f3bb4df35f..a7ccad9d6f 100644 --- a/apps/web/src/lib/config.server.ts +++ b/apps/web/src/lib/config.server.ts @@ -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:` + * / `http://127.0.0.1:`), emit + * `http://{label}.kiloclaw.localhost:` 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( + 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' diff --git a/apps/web/src/lib/kiloclaw/instance-url.test.ts b/apps/web/src/lib/kiloclaw/instance-url.test.ts new file mode 100644 index 0000000000..3cc97697fd --- /dev/null +++ b/apps/web/src/lib/kiloclaw/instance-url.test.ts @@ -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'); + }); +}); diff --git a/apps/web/src/lib/kiloclaw/instance-url.ts b/apps/web/src/lib/kiloclaw/instance-url.ts new file mode 100644 index 0000000000..8cd01e3026 --- /dev/null +++ b/apps/web/src/lib/kiloclaw/instance-url.ts @@ -0,0 +1,70 @@ +/** + * Per-instance worker URL minting. + * + * Returns the dashboard-facing URL the browser should use to talk to a + * specific KiloClaw instance. + * + * When `KILOCLAW_INSTANCE_URL_TEMPLATE` is set (e.g. + * `https://{label}.kiloclaw.ai`) AND the instance is on the post-PR1 + * controller contract (`controllerCapabilitiesVersion >= 2`), the + * template is expanded with the sandboxId's hostname label. Otherwise + * falls back to the legacy single-host `KILOCLAW_API_URL`. + * + * The capability gate matters: v1 machines don't have the per-instance + * origin in their OpenClaw allowlist, so WebSocket upgrades from the + * per-instance host would fail openclaw's exact-match origin check. + * Keeping v1 instances on the legacy host until they restart onto v2 + * avoids a user-visible regression. + * + * Inputs: + * - `sandboxId`: DO's authoritative sandboxId (null for no-instance sentinel) + * - `controllerCapabilitiesVersion`: from the worker's `getStatus` + * (null → treat as pre-v1, legacy host) + * - `template`: `KILOCLAW_INSTANCE_URL_TEMPLATE` (empty → legacy host) + * - `fallback`: `KILOCLAW_API_URL` (empty → "https://claw.kilo.ai") + */ + +import { hostnameLabelFromSandboxId } from '@kilocode/worker-utils/hostname-label'; + +const MIN_CAPABILITY_VERSION_FOR_PER_INSTANCE_URL = 2; + +const DEFAULT_LEGACY_URL = 'https://claw.kilo.ai'; + +/** + * Process-local guard so the "misconfigured template" warning fires + * once per worker/Node process instead of on every getStatus call. + * Resets on cold start, which is the right granularity for operator + * feedback after a config change. + */ +let warnedAboutMissingLabelPlaceholder = false; + +export function workerUrlForInstance(params: { + sandboxId: string | null; + controllerCapabilitiesVersion: number | null; + template: string; + fallback: string; +}): string { + const { sandboxId, controllerCapabilitiesVersion, template, fallback } = params; + const legacyUrl = fallback || DEFAULT_LEGACY_URL; + + if (!template) return legacyUrl; + if (!template.includes('{label}')) { + // Operator set a template but forgot the placeholder. Silently + // falling back to the legacy URL hides the misconfiguration; emit + // a one-time warning so it shows up in logs. + if (!warnedAboutMissingLabelPlaceholder) { + warnedAboutMissingLabelPlaceholder = true; + console.warn( + '[workerUrlForInstance] KILOCLAW_INSTANCE_URL_TEMPLATE is set but missing the {label} placeholder; falling back to legacy URL' + ); + } + return legacyUrl; + } + if (!sandboxId) return legacyUrl; + if ((controllerCapabilitiesVersion ?? 0) < MIN_CAPABILITY_VERSION_FOR_PER_INSTANCE_URL) { + return legacyUrl; + } + const label = hostnameLabelFromSandboxId(sandboxId); + if (!label) return legacyUrl; + return template.replace('{label}', label); +} diff --git a/apps/web/src/lib/kiloclaw/types.ts b/apps/web/src/lib/kiloclaw/types.ts index fe0a6fb343..8006ba0cf7 100644 --- a/apps/web/src/lib/kiloclaw/types.ts +++ b/apps/web/src/lib/kiloclaw/types.ts @@ -225,6 +225,15 @@ export type PlatformStatusResponse = { botNature: string | null; botVibe: string | null; botEmoji: string | null; + /** + * Version of the controller-configuration contract the running machine + * was started with. Bumped by the worker whenever the set of env vars / + * config it writes into a machine changes in a way callers care about. + * `null` means the instance has never been started under a versioned + * contract (treat as pre-v1 / legacy). See + * `services/kiloclaw/src/config.ts` (`WORKER_CONTROLLER_CAPABILITIES_VERSION`). + */ + controllerCapabilitiesVersion: number | null; }; /** A single registry DO's entries + migration status. */ @@ -594,7 +603,14 @@ export type ChatCredentials = { /** Combined status returned by tRPC getStatus */ export type KiloClawDashboardStatus = PlatformStatusResponse & { - /** Worker base URL for constructing the "Open" link. Falls back to claw.kilo.ai. */ + /** + * Worker base URL for constructing the "Open" link. + * + * When `KILOCLAW_INSTANCE_URL_TEMPLATE` is configured and the instance + * is on `controllerCapabilitiesVersion >= 2`, this is the per-instance + * virtual host (e.g. `https://i-.kiloclaw.ai`). Otherwise it falls + * back to `KILOCLAW_API_URL` (production: `https://claw.kilo.ai`). + */ workerUrl: string; name: string | null; /** Postgres row ID. Used to construct /i/{instanceId} proxy paths for instance-keyed instances. */ diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index c5dc52f5a1..a9276d9515 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -17,7 +17,8 @@ import { isValidCustomSecretKey, isValidConfigPath, } from '@kilocode/kiloclaw-secret-catalog'; -import { KILOCLAW_API_URL } from '@/lib/config.server'; +import { KILOCLAW_API_URL, KILOCLAW_INSTANCE_URL_TEMPLATE } from '@/lib/config.server'; +import { workerUrlForInstance } from '@/lib/kiloclaw/instance-url'; import { db, type DrizzleTransaction } from '@/lib/drizzle'; import { insertKiloClawSubscriptionChangeLog } from '@kilocode/db'; import { @@ -914,6 +915,7 @@ function createNoInstanceStatus(userId: string, workerUrl: string): KiloClawDash botVibe: null, botEmoji: null, workerUrl, + controllerCapabilitiesVersion: null, name: null, instanceId: null, inboundEmailAddress: null, @@ -2345,10 +2347,13 @@ export const kiloclawRouter = createTRPCRouter({ getStatus: baseProcedure.query(async ({ ctx }) => { const instance = await getActiveInstance(ctx.user.id); - const workerUrl = KILOCLAW_API_URL || 'https://claw.kilo.ai'; + const legacyWorkerUrl = KILOCLAW_API_URL || 'https://claw.kilo.ai'; if (!instance) { - return createNoInstanceStatus(ctx.user.id, workerUrl); + // No instance yet → no sandboxId yet → per-instance URL can't be + // minted. Serve the legacy host; the real host kicks in once the + // dashboard provisions and re-fetches status. + return createNoInstanceStatus(ctx.user.id, legacyWorkerUrl); } const client = new KiloClawInternalClient(); @@ -2357,6 +2362,13 @@ export const kiloclawRouter = createTRPCRouter({ getInboundEmailAddressForInstance(instance.id), ]); + const workerUrl = workerUrlForInstance({ + sandboxId: status.sandboxId, + controllerCapabilitiesVersion: status.controllerCapabilitiesVersion, + template: KILOCLAW_INSTANCE_URL_TEMPLATE, + fallback: legacyWorkerUrl, + }); + return { ...status, name: instance.name ?? null, diff --git a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts index 2f42d95671..f30e970ac2 100644 --- a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts +++ b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts @@ -17,7 +17,8 @@ import { isValidCustomSecretKey, isValidConfigPath, } from '@kilocode/kiloclaw-secret-catalog'; -import { KILOCLAW_API_URL } from '@/lib/config.server'; +import { KILOCLAW_API_URL, KILOCLAW_INSTANCE_URL_TEMPLATE } from '@/lib/config.server'; +import { workerUrlForInstance } from '@/lib/kiloclaw/instance-url'; import { sentryLogger } from '@/lib/utils.server'; import { db } from '@/lib/drizzle'; import { @@ -309,7 +310,7 @@ export const organizationKiloclawRouter = createTRPCRouter({ getStatus: organizationMemberProcedure.query(async ({ ctx, input }) => { const instance = await getActiveOrgInstance(ctx.user.id, input.organizationId); - const workerUrl = KILOCLAW_API_URL || 'https://claw.kilo.ai'; + const legacyWorkerUrl = KILOCLAW_API_URL || 'https://claw.kilo.ai'; // No org instance → return a "no instance" sentinel so the frontend // renders setup entry points. Without this guard, workerInstanceId(null) @@ -351,7 +352,8 @@ export const organizationKiloclawRouter = createTRPCRouter({ botNature: null, botVibe: null, botEmoji: null, - workerUrl, + workerUrl: legacyWorkerUrl, + controllerCapabilitiesVersion: null, name: null, instanceId: null, inboundEmailAddress: null, @@ -365,6 +367,13 @@ export const organizationKiloclawRouter = createTRPCRouter({ getInboundEmailAddressForInstance(instance.id), ]); + const workerUrl = workerUrlForInstance({ + sandboxId: status.sandboxId, + controllerCapabilitiesVersion: status.controllerCapabilitiesVersion, + template: KILOCLAW_INSTANCE_URL_TEMPLATE, + fallback: legacyWorkerUrl, + }); + return { ...status, name: instance.name ?? null, diff --git a/packages/worker-utils/package.json b/packages/worker-utils/package.json index 5a9540a46b..8523d8263a 100644 --- a/packages/worker-utils/package.json +++ b/packages/worker-utils/package.json @@ -6,6 +6,8 @@ "exports": { ".": "./src/index.ts", "./instance-id": "./src/instance-id.ts", + "./sandbox-id": "./src/sandbox-id.ts", + "./hostname-label": "./src/hostname-label.ts", "./redact-headers": "./src/redact-headers.ts", "./kiloclaw-billing-observability": "./src/kiloclaw-billing-observability.ts" }, diff --git a/services/kiloclaw/src/auth/hostname-label.test.ts b/packages/worker-utils/src/hostname-label.test.ts similarity index 99% rename from services/kiloclaw/src/auth/hostname-label.test.ts rename to packages/worker-utils/src/hostname-label.test.ts index 961347151f..3197f90612 100644 --- a/services/kiloclaw/src/auth/hostname-label.test.ts +++ b/packages/worker-utils/src/hostname-label.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { sandboxIdFromUserId } from './sandbox-id'; -import { sandboxIdFromInstanceId } from '@kilocode/worker-utils/instance-id'; +import { sandboxIdFromInstanceId } from './instance-id'; import { hostnameLabelFromSandboxId, sandboxIdFromHostnameLabel, diff --git a/packages/worker-utils/src/hostname-label.ts b/packages/worker-utils/src/hostname-label.ts new file mode 100644 index 0000000000..4f5a59d727 --- /dev/null +++ b/packages/worker-utils/src/hostname-label.ts @@ -0,0 +1,240 @@ +/** + * Hostname label <-> sandboxId translation for per-instance virtual hosting + * on `*.kiloclaw.ai` (or a configured dev-suffix). + * + * Two instance shapes map to two label prefixes: + * + * instance-keyed sandboxId "ki_{32hex}" <-> "i-{32hex}" + * legacy sandboxId base64url(userId) <-> "u-{base32hex(userId)}" + * + * Prefix disambiguates the two cases without a database lookup. + * + * The per-instance URL used to inject per-instance origins into + * `OPENCLAW_ALLOWED_ORIGINS` and (post-PR2) to route incoming requests by + * `Host` is built from two env-configurable pieces: + * + * KILOCLAW_INSTANCE_HOST_SUFFIX default ".kiloclaw.ai" + * KILOCLAW_INSTANCE_URL_SCHEME default "https" + * + * Dev parity: set the suffix to `.kiloclaw.localhost:8795` and the scheme to + * `http` and the worker will both inject `http://