diff --git a/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx b/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx new file mode 100644 index 0000000000..9024b0d6e2 --- /dev/null +++ b/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useMutation } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { TRPCClientError } from '@trpc/client'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from '@/components/ui/card'; +import KiloClawbsterIcon from '@/components/icons/KiloClawbsterIcon'; +import type { InstallPayload } from '@/lib/kiloclaw/install'; +import type { InstallSource } from '@/lib/kiloclaw/install-sources'; + +type InstallClientProps = { + source: InstallSource; + sourceLabel: string; + payload: InstallPayload; +}; + +/** + * Install confirmation. The signed payload was fetched and verified + * server-side (and paid-access gated) in `page.tsx`; this is a permission-style + * confirm screen ("you're installing X from kilo.ai" plus what it does and the + * description) that the user explicitly confirms or cancels. + * + * Dispatch happens ONLY on an explicit Confirm click, which fires the + * `installFromSource` POST mutation, never on GET render. A cross-site POST + * can't carry the SameSite session cookie, so this closes the CSRF / + * lure-a-click class that a GET-dispatch route would re-open. + */ +export function InstallClient({ source, sourceLabel, payload }: InstallClientProps) { + const router = useRouter(); + const trpc = useTRPC(); + // `navigating` keeps the buttons disabled through the post-success redirect + // so a double-click can't fire a second dispatch while the route changes. + const [navigating, setNavigating] = useState(false); + const install = useMutation(trpc.kiloclaw.installFromSource.mutationOptions()); + + async function onInstall() { + try { + const result = await install.mutateAsync({ + source, + slug: payload.slug, + // Bind the dispatch to the exact payload shown here; the server rejects + // if the byte changed since this page rendered. + signature: payload.signature, + }); + setNavigating(true); + if (result.ok) { + // Open the conversation the dispatch created, so the user lands + // directly in the installed chat, not the blank conversation index. + router.push(`/claw/chat/${result.conversationId}`); + return; + } + // No active instance yet, so send them to set one up. We intentionally do + // NOT persist the install intent across the (long, multi-step) onboarding + // flow; the user finishes setup, then installs again from the byte page. + router.push('/claw/new'); + } catch (err) { + let message = 'Could not install this byte. Please try again.'; + if (err instanceof TRPCClientError) { + if (err.data?.code === 'NOT_FOUND') { + message = 'This install link is no longer available.'; + } else if (err.data?.code === 'CONFLICT') { + message = 'This byte changed since you opened this page. Please reload and try again.'; + } + } + toast.error(message); + } + } + + const busy = install.isPending || navigating; + + return ( +
+ + + + + {sourceLabel} + + {payload.title} + + You’re installing a {sourceLabel} from kilo.ai. Clicking Confirm Install starts a new + KiloClaw conversation and runs its prompt on your behalf. If you don’t want to install + this, then click Cancel. + + + +

+ This {sourceLabel} installs a skill to: +

+

+ {payload.description} +

+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/app/(app)/claw/install/[source]/[slug]/page.tsx b/apps/web/src/app/(app)/claw/install/[source]/[slug]/page.tsx new file mode 100644 index 0000000000..58c8bdb34a --- /dev/null +++ b/apps/web/src/app/(app)/claw/install/[source]/[slug]/page.tsx @@ -0,0 +1,61 @@ +import { notFound, redirect } from 'next/navigation'; +import { TRPCError } from '@trpc/server'; +import { getUserFromAuthOrRedirect } from '@/lib/user/server'; +import { requireKiloClawAccess } from '@/lib/kiloclaw/access-gate'; +import { fetchInstallPayload } from '@/lib/kiloclaw/install'; +import { INSTALL_SOURCES, isInstallSource } from '@/lib/kiloclaw/install-sources'; +import { InstallClient } from './InstallClient'; + +type InstallPageProps = { + params: Promise<{ source: string; slug: string }>; +}; + +/** + * One-click install preview for a signed source payload (ClawByte today, + * more sources later). Rendered as a Server Component — loading this page + * does NO install work. The actual chat dispatch happens only on an explicit + * Install click in `InstallClient`, which fires the `installFromSource` POST + * mutation. That split is load-bearing: a GET must never dispatch, or a + * third-party page could pop a prompt into a user's chat just by getting + * them to load the URL (CSRF / lure-a-click). + * + * Gating, in order: + * 1. Auth — the parent claw layout (`getUserFromAuthOrRedirect`) bounces + * unauth users to sign-in; `callbackPath` preserves this pathname so they + * return here after signing in. We call it again to get the user id. + * 2. Active paid access — fetching + verifying the signed byte is paid-user + * compute (outbound HTTP + Ed25519 verify). A logged-in user without an + * active subscription/trial must NOT be able to trigger it, so we gate + * before the fetch and route no-access users into the subscribe/provision + * funnel (`/claw/new`) instead of pulling the byte. + * 3. Payload fetch + verify — only after the access gate passes. + * + * Unknown source / unsigned byte / failed verification / slug mismatch → + * `notFound()` (404). All cases logged in detail by `fetchInstallPayload`. + */ +export default async function InstallPage({ params }: InstallPageProps) { + const { source, slug } = await params; + if (!isInstallSource(source)) notFound(); + + const user = await getUserFromAuthOrRedirect(); + + try { + await requireKiloClawAccess(user.id); + } catch (err) { + // No active subscription/trial → don't pull the byte. Send them to the + // subscribe/provision flow (which presents the marketing/sign-up page). + // We intentionally don't persist install intent across that flow; the user + // installs again from the byte page once they're set up. + if (err instanceof TRPCError && err.code === 'FORBIDDEN') { + redirect('/claw/new'); + } + throw err; + } + + const payload = await fetchInstallPayload(source, slug); + if (!payload) notFound(); + + return ( + + ); +} diff --git a/apps/web/src/components/icons/KiloClawbsterIcon.tsx b/apps/web/src/components/icons/KiloClawbsterIcon.tsx new file mode 100644 index 0000000000..6c57522fb9 --- /dev/null +++ b/apps/web/src/components/icons/KiloClawbsterIcon.tsx @@ -0,0 +1,349 @@ +export default function KiloClawbsterIcon({ className }: { className?: string }) { + return ( + + ); +} diff --git a/apps/web/src/lib/kiloclaw/install-dispatch.test.ts b/apps/web/src/lib/kiloclaw/install-dispatch.test.ts new file mode 100644 index 0000000000..fde09f4015 --- /dev/null +++ b/apps/web/src/lib/kiloclaw/install-dispatch.test.ts @@ -0,0 +1,306 @@ +import { TRPCError } from '@trpc/server'; +import { dispatchInstallFromSource } from './install-dispatch'; +import type { DispatchInstallFromSourceDeps } from './install-dispatch'; +import type { InstallPayload } from './install'; +import type { PostMessageAsUserResult } from '@kilocode/kilo-chat'; + +const VALID_PAYLOAD: InstallPayload = { + slug: 'deep-research', + title: 'Source Hunter', + description: 'Deep research that finds primary sources.', + prompt: 'Research [topic] for me.', + signature: 'sig-base64', + signatureKeyId: 'kid-abc', + signedAt: '2026-05-28T00:00:00.000Z', + signatureVersion: 1, +}; + +const ACTIVE_INSTANCE = { + id: 'instance-1', + userId: 'user-1', + sandboxId: 'sb-1', +} as unknown as Awaited>; + +const RUNTIME_SANDBOX_ID = 'ki_runtime_sandbox'; + +function makeDeps( + overrides: Partial = {} +): DispatchInstallFromSourceDeps { + return { + fetchInstallPayload: overrides.fetchInstallPayload ?? (async () => VALID_PAYLOAD), + getActiveInstance: overrides.getActiveInstance ?? (async () => ACTIVE_INSTANCE), + resolveRuntimeSandboxId: overrides.resolveRuntimeSandboxId ?? (async () => RUNTIME_SANDBOX_ID), + requireKiloClawAccessAtInstance: overrides.requireKiloClawAccessAtInstance ?? (async () => {}), + postMessageAsUser: + overrides.postMessageAsUser ?? + (async () => + ({ + ok: true, + conversationId: 'conv-1', + messageId: 'msg-1', + conversationCreated: false, + }) satisfies PostMessageAsUserResult), + }; +} + +const ARGS = { + userId: 'user-1', + source: 'byte' as const, + slug: 'deep-research', + expectedSignature: VALID_PAYLOAD.signature, +}; + +describe('dispatchInstallFromSource', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('happy path: fetches, looks up instance, dispatches, returns ok', async () => { + const fetchSpy = jest.fn(async () => VALID_PAYLOAD); + const instanceSpy = jest.fn(async () => ACTIVE_INSTANCE); + const dispatchSpy = jest.fn( + async () => + ({ + ok: true, + conversationId: 'conv-1', + messageId: 'msg-1', + conversationCreated: true, + }) satisfies PostMessageAsUserResult + ); + const infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {}); + + const result = await dispatchInstallFromSource( + ARGS, + makeDeps({ + fetchInstallPayload: fetchSpy, + getActiveInstance: instanceSpy, + postMessageAsUser: dispatchSpy, + }) + ); + + expect(result).toEqual({ + ok: true, + conversationId: 'conv-1', + messageId: 'msg-1', + conversationCreated: true, + }); + + // Dispatch re-fetches uncached so a changed/revoked/deleted byte is seen. + expect(fetchSpy).toHaveBeenCalledWith('byte', 'deep-research', { bypassCache: true }); + expect(instanceSpy).toHaveBeenCalledWith('user-1'); + expect(dispatchSpy).toHaveBeenCalledWith({ + userId: 'user-1', + sandboxId: RUNTIME_SANDBOX_ID, // NOT the registry row's sandboxId + message: 'Research [topic] for me.', + source: 'install', + forceNewConversation: true, // each install gets its own conversation + correlation: { reason: 'clawbyte:deep-research' }, + }); + + // Audit log emitted with the signing/dispatch fields. + expect(infoSpy).toHaveBeenCalledTimes(1); + const logged = JSON.parse(infoSpy.mock.calls[0]![0] as string); + expect(logged).toMatchObject({ + event: 'install_dispatched', + userId: 'user-1', + source: 'byte', + slug: 'deep-research', + signatureKeyId: 'kid-abc', + signedAt: '2026-05-28T00:00:00.000Z', + conversationId: 'conv-1', + messageId: 'msg-1', + conversationCreated: true, + }); + expect(logged.dispatchedAt).toEqual(expect.any(String)); + }); + + it('throws NOT_FOUND when fetchInstallPayload returns null', async () => { + await expect( + dispatchInstallFromSource(ARGS, makeDeps({ fetchInstallPayload: async () => null })) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('throws CONFLICT (and does NOT dispatch) when the re-fetched signature differs', async () => { + const dispatchSpy = jest.fn(); + // Re-fetched payload is a newer, still-validly-signed version (different + // signature) than the one the user reviewed. + const changed: InstallPayload = { ...VALID_PAYLOAD, signature: 'different-sig' }; + await expect( + dispatchInstallFromSource( + ARGS, + makeDeps({ + fetchInstallPayload: async () => changed, + postMessageAsUser: dispatchSpy as never, + }) + ) + ).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it('returns no_instance (and does NOT dispatch) when user has no active instance', async () => { + const dispatchSpy = jest.fn(); + const result = await dispatchInstallFromSource( + ARGS, + makeDeps({ + getActiveInstance: async () => null, + postMessageAsUser: dispatchSpy as never, + }) + ); + + expect(result).toEqual({ ok: false, code: 'no_instance' }); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it('fails closed (and does NOT dispatch) when the resolved instance is not entitled', async () => { + // clawAccessProcedure passed, but the resolved instance is not entitled + // (inconsistent billing anchor). The per-instance check throws. + const dispatchSpy = jest.fn(); + const resolveSpy = jest.fn(async () => RUNTIME_SANDBOX_ID); + await expect( + dispatchInstallFromSource( + ARGS, + makeDeps({ + requireKiloClawAccessAtInstance: async () => { + throw new TRPCError({ code: 'FORBIDDEN', message: 'not entitled' }); + }, + resolveRuntimeSandboxId: resolveSpy, + postMessageAsUser: dispatchSpy as never, + }) + ) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + // Refused before resolving the sandbox or dispatching. + expect(resolveSpy).not.toHaveBeenCalled(); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it('uses the runtime sandbox id, not the registry row, to dispatch', async () => { + jest.spyOn(console, 'info').mockImplementation(() => {}); + + // Make the registry row carry a legacy sandbox id; the resolver returns + // the modern ki_ value the active worker/chat are keyed on. + const LEGACY_REGISTRY_SANDBOX = 'legacy_userbase64_sandbox'; + const MODERN_RUNTIME_SANDBOX = 'ki_active_runtime_sandbox'; + const halfMigratedInstance = { + ...ACTIVE_INSTANCE!, + sandboxId: LEGACY_REGISTRY_SANDBOX, + } as typeof ACTIVE_INSTANCE; + + let dispatchedWith: Parameters[0] | null = + null; + const dispatchSpy: DispatchInstallFromSourceDeps['postMessageAsUser'] = async params => { + dispatchedWith = params; + return { + ok: true, + conversationId: 'conv-x', + messageId: 'msg-x', + conversationCreated: false, + } satisfies PostMessageAsUserResult; + }; + + await dispatchInstallFromSource( + ARGS, + makeDeps({ + getActiveInstance: async () => halfMigratedInstance, + resolveRuntimeSandboxId: async () => MODERN_RUNTIME_SANDBOX, + postMessageAsUser: dispatchSpy, + }) + ); + + expect(dispatchedWith).not.toBeNull(); + expect(dispatchedWith!.sandboxId).toBe(MODERN_RUNTIME_SANDBOX); + expect(dispatchedWith!.sandboxId).not.toBe(LEGACY_REGISTRY_SANDBOX); + }); + + it('returns no_instance when runtime sandbox id resolves to null', async () => { + // Instance row exists but the runtime status reports no sandbox yet + // (e.g. provisioning still warming up). Surface this as the same UX + // class as no-instance so the client lands on /claw/new and re-tries. + const dispatchSpy = jest.fn(); + const result = await dispatchInstallFromSource( + ARGS, + makeDeps({ + resolveRuntimeSandboxId: async () => null, + postMessageAsUser: dispatchSpy as never, + }) + ); + + expect(result).toEqual({ ok: false, code: 'no_instance' }); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it('maps kilo-chat no_conversation to typed no_instance result', async () => { + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const result = await dispatchInstallFromSource( + ARGS, + makeDeps({ + postMessageAsUser: async () => + ({ + ok: false, + code: 'no_conversation', + error: 'user has no conversation', + }) satisfies PostMessageAsUserResult, + }) + ); + expect(result).toEqual({ ok: false, code: 'no_instance' }); + expect(errSpy).toHaveBeenCalled(); + }); + + it('throws INTERNAL_SERVER_ERROR on kilo-chat forbidden (auth misconfig)', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + await expect( + dispatchInstallFromSource( + ARGS, + makeDeps({ + postMessageAsUser: async () => + ({ ok: false, code: 'forbidden', error: 'bad key' }) satisfies PostMessageAsUserResult, + }) + ) + ).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR' }); + }); + + it('throws INTERNAL_SERVER_ERROR on kilo-chat internal/timeout error', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + await expect( + dispatchInstallFromSource( + ARGS, + makeDeps({ + postMessageAsUser: async () => + ({ + ok: false, + code: 'internal', + error: 'timed out', + }) satisfies PostMessageAsUserResult, + }) + ) + ).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR' }); + }); + + it('throws INTERNAL_SERVER_ERROR on kilo-chat invalid_request', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + await expect( + dispatchInstallFromSource( + ARGS, + makeDeps({ + postMessageAsUser: async () => + ({ + ok: false, + code: 'invalid_request', + error: 'message empty', + }) satisfies PostMessageAsUserResult, + }) + ) + ).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR' }); + }); + + it('rethrown TRPCError keeps the original code', async () => { + // Sanity check: we use TRPCError so callers can inspect .code. + let caught: TRPCError | undefined; + try { + await dispatchInstallFromSource(ARGS, makeDeps({ fetchInstallPayload: async () => null })); + } catch (err) { + caught = err as TRPCError; + } + expect(caught).toBeInstanceOf(TRPCError); + expect(caught?.code).toBe('NOT_FOUND'); + }); +}); diff --git a/apps/web/src/lib/kiloclaw/install-dispatch.ts b/apps/web/src/lib/kiloclaw/install-dispatch.ts new file mode 100644 index 0000000000..a494e0e0e8 --- /dev/null +++ b/apps/web/src/lib/kiloclaw/install-dispatch.ts @@ -0,0 +1,226 @@ +import 'server-only'; +import { TRPCError } from '@trpc/server'; +import { fetchInstallPayload } from './install'; +import type { InstallSource } from './install-sources'; +import { requireKiloClawAccessAtInstance } from './access-gate'; +import { + getActiveInstance, + workerInstanceId, + type ActiveKiloClawInstance, +} from './instance-registry'; +import { KiloClawInternalClient } from './kiloclaw-internal-client'; +import { postMessageAsUser } from './kilo-chat-internal-client'; + +/** + * Server-side dispatch for the `installFromSource` tRPC mutation, extracted + * here so the decision logic is unit-testable without a full tRPC + DB + * setup. The mutation is a thin wrapper that supplies `userId` from + * `ctx.user.id`. + * + * Outcomes: + * - `{ ok: true, ... }` — payload verified, message dispatched to the user's + * kiloclaw chat as their own user-turn. Client redirects to `/claw/chat`. + * - `{ ok: false, code: 'no_instance' }` — caller has no active kiloclaw + * instance yet. Client redirects to `/claw/new` to provision; the install + * intent is not persisted across that flow (the user re-installs from the + * byte page once set up). + * + * Other failure modes throw a `TRPCError`: + * - `NOT_FOUND` — byte missing upstream, signature failed, slug mismatch, + * or verification config broken. Already logged in detail by + * `fetchInstallPayload`; the throw is a one-liner for the client. + * - `INTERNAL_SERVER_ERROR` — kilo-chat returned `forbidden` (internal-auth + * misconfigured) or `internal` (network/timeout/unknown). These should + * page on-call; client gets a generic error. + */ + +export type DispatchInstallFromSourceArgs = { + userId: string; + source: InstallSource; + slug: string; + // The Ed25519 signature of the payload the user actually reviewed on the + // confirmation page. We re-fetch + re-verify server-side, then require the + // re-fetched payload's signature to match this, so a byte edited+re-signed + // between preview and confirm can't dispatch a different (still-valid) + // prompt than the one the user approved. + expectedSignature: string; +}; + +export type DispatchInstallFromSourceResult = + | { + ok: true; + conversationId: string; + messageId: string; + conversationCreated: boolean; + } + | { ok: false; code: 'no_instance' }; + +// Dependency injection points kept narrow for testing. Real callers always +// use the production implementations. +export type DispatchInstallFromSourceDeps = { + fetchInstallPayload: typeof fetchInstallPayload; + getActiveInstance: typeof getActiveInstance; + resolveRuntimeSandboxId: ( + userId: string, + instance: ActiveKiloClawInstance + ) => Promise; + requireKiloClawAccessAtInstance: typeof requireKiloClawAccessAtInstance; + postMessageAsUser: typeof postMessageAsUser; +}; + +/** + * Resolve the *runtime* sandbox id the chat is currently keyed on, not the + * Postgres registry row's `sandbox_id`. Matches the dashboard/status path + * (`client.getStatus(userId, workerInstanceId(instance)).sandboxId`). + * + * Why this matters: during half-migrated states the registry row may still + * carry a legacy sandbox id while the active worker / chat are on + * `ki_`. Dispatching against the registry value in that state + * would write the install message into a stale conversation that the user + * never sees. + */ +async function defaultResolveRuntimeSandboxId( + userId: string, + instance: ActiveKiloClawInstance +): Promise { + const client = new KiloClawInternalClient(); + const status = await client.getStatus(userId, workerInstanceId(instance)); + return status.sandboxId ?? null; +} + +const defaultDeps: DispatchInstallFromSourceDeps = { + fetchInstallPayload, + getActiveInstance, + resolveRuntimeSandboxId: defaultResolveRuntimeSandboxId, + requireKiloClawAccessAtInstance, + postMessageAsUser, +}; + +export async function dispatchInstallFromSource( + args: DispatchInstallFromSourceArgs, + deps: DispatchInstallFromSourceDeps = defaultDeps +): Promise { + const { userId, source, slug, expectedSignature } = args; + + // Uncached read: this is the confirm-time dispatch, so a byte changed, + // revoked, or deleted since the preview rendered must be seen now (a stale + // cached payload would still match the reviewed signature and dispatch). + const payload = await deps.fetchInstallPayload(source, slug, { bypassCache: true }); + if (!payload) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'This install link is not available.', + }); + } + + // Bind the dispatch to exactly what the user reviewed. The signature is the + // cryptographic identity of the signed content (slug/title/description/ + // prompt); if it no longer matches, the byte changed since the confirmation + // page rendered, so refuse rather than run a prompt the user didn't approve. + if (payload.signature !== expectedSignature) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'This byte changed since you reviewed it. Please reload and confirm again.', + }); + } + + const instance = await deps.getActiveInstance(userId); + if (!instance) { + // Don't dispatch yet — the user has no instance to deliver into. The + // client redirects them to `/claw/new` to provision; they re-install + // from the byte page afterward (intent is intentionally not persisted). + return { ok: false, code: 'no_instance' }; + } + + // Bind entitlement to THIS exact instance. The `clawAccessProcedure` gate + // only proves the user has some active access; in an inconsistent billing + // state (e.g. the current subscription anchored to a different/destroyed row + // while an orphaned active instance remains) that gate can pass while the + // resolved instance is not entitled. Re-check access for the resolved + // instance and fail closed, so a prompt is never dispatched into an + // unentitled runtime. Throws TRPCError FORBIDDEN/NOT_FOUND on mismatch. + await deps.requireKiloClawAccessAtInstance(userId, instance.id); + + // Use the runtime sandbox id (not the registry row's `sandboxId`) so + // half-migrated rows don't dispatch into a stale conversation. See + // `defaultResolveRuntimeSandboxId` for the why. + const runtimeSandboxId = await deps.resolveRuntimeSandboxId(userId, instance); + if (!runtimeSandboxId) { + // Instance row exists but the runtime isn't reporting a sandbox yet — + // provisioning still in flight. Same UX class as no-instance, so the + // client lands on /claw/new and re-tries once chat is ready. + return { ok: false, code: 'no_instance' }; + } + + const dispatchedAt = new Date().toISOString(); + // Correlation.reason is capped at 200 chars in the shared schema. Install + // slugs are accepted up to 200, so `clawbyte:${slug}` can exceed 200 and + // get rejected as invalid_request. Truncate the audit field rather than + // bouncing an otherwise-valid install; the slug also appears verbatim in + // the install_dispatched log line below. + const reason = `clawbyte:${slug}`.slice(0, 200); + const result = await deps.postMessageAsUser({ + userId, + sandboxId: runtimeSandboxId, + message: payload.prompt, + source: 'install', + // Each install gets its own dedicated conversation rather than appending + // to whatever the user last chatted in. (`forceNewConversation` already + // implies creation, so `autoCreateConversation` is omitted as redundant.) + forceNewConversation: true, + correlation: { reason }, + }); + + if (result.ok) { + // Audit log — durable storage is a separate open question; log-only + // for v1 so on-call can grep by these fields. The shape is intentionally + // flat-keyed JSON-stringified so it survives structured-log shipping. + console.info( + JSON.stringify({ + event: 'install_dispatched', + userId, + source, + slug, + signatureKeyId: payload.signatureKeyId, + signedAt: payload.signedAt, + dispatchedAt, + conversationId: result.conversationId, + messageId: result.messageId, + conversationCreated: result.conversationCreated, + }) + ); + return { + ok: true, + conversationId: result.conversationId, + messageId: result.messageId, + conversationCreated: result.conversationCreated, + }; + } + + // result.ok === false: log loudly and throw. Each code is operationally + // distinct (auth bug vs. transient infra) so the log line carries enough + // to grep on. + console.error( + JSON.stringify({ + event: 'install_dispatch_failed', + userId, + source, + slug, + signatureKeyId: payload.signatureKeyId, + kilochatCode: result.code, + kilochatError: result.error, + }) + ); + + // `no_conversation` from kilo-chat would mean the instance exists but + // its chat hasn't been provisioned yet — same UX class as the + // no_instance case above, so map it to the typed result for consistency. + if (result.code === 'no_conversation') { + return { ok: false, code: 'no_instance' }; + } + + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Could not install this byte. Please try again.', + }); +} diff --git a/apps/web/src/lib/kiloclaw/install-sources.test.ts b/apps/web/src/lib/kiloclaw/install-sources.test.ts new file mode 100644 index 0000000000..5cdc167564 --- /dev/null +++ b/apps/web/src/lib/kiloclaw/install-sources.test.ts @@ -0,0 +1,21 @@ +import { isInstallSource } from './install-sources'; + +describe('isInstallSource', () => { + it('accepts registered source keys', () => { + expect(isInstallSource('byte')).toBe(true); + }); + + it('rejects unknown sources', () => { + expect(isInstallSource('skill')).toBe(false); + expect(isInstallSource('')).toBe(false); + }); + + it('rejects inherited Object prototype names (own-property check)', () => { + // Naive `value in INSTALL_SOURCES` would pass these through and then + // crash downstream lookup. Object.hasOwn keeps them out. + expect(isInstallSource('toString')).toBe(false); + expect(isInstallSource('hasOwnProperty')).toBe(false); + expect(isInstallSource('constructor')).toBe(false); + expect(isInstallSource('__proto__')).toBe(false); + }); +}); diff --git a/apps/web/src/lib/kiloclaw/install-sources.ts b/apps/web/src/lib/kiloclaw/install-sources.ts new file mode 100644 index 0000000000..2dc2368ec1 --- /dev/null +++ b/apps/web/src/lib/kiloclaw/install-sources.ts @@ -0,0 +1,25 @@ +const KILO_AI_BASE = (process.env.KILO_AI_BASE_URL ?? 'https://kilo.ai').replace(/\/$/, ''); + +export const INSTALL_SOURCES = { + byte: { + label: 'ClawByte', + urlTemplate: `${KILO_AI_BASE}/kiloclaw/bytes/{slug}/data.json`, + }, +} as const; + +export type InstallSource = keyof typeof INSTALL_SOURCES; + +// Tuple of registered source keys for `z.enum(...)` input validation on the +// `installFromSource` tRPC mutation. Derived from the registry so adding a +// new source is a one-line change here, not two. The cast is sound at +// runtime — INSTALL_SOURCES always has at least one entry. +export const INSTALL_SOURCE_KEYS = Object.keys(INSTALL_SOURCES) as [ + InstallSource, + ...InstallSource[], +]; + +export function isInstallSource(value: string): value is InstallSource { + // Own-property check (not `value in`) so inherited names like `toString` + // or `hasOwnProperty` can't pass the guard and then crash the lookup. + return Object.hasOwn(INSTALL_SOURCES, value); +} diff --git a/apps/web/src/lib/kiloclaw/install.test.ts b/apps/web/src/lib/kiloclaw/install.test.ts new file mode 100644 index 0000000000..91c3579daf --- /dev/null +++ b/apps/web/src/lib/kiloclaw/install.test.ts @@ -0,0 +1,262 @@ +import crypto from 'node:crypto'; + +// Generate a real Ed25519 keypair once for the whole test file. Tests sign +// fixtures with the private half and pin the public half via env var. +const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519'); +const PUBLIC_PEM = publicKey.export({ type: 'spki', format: 'pem' }) as string; + +// Derive the matching kid the way the signer / verifier do. +const PUBLIC_DER = publicKey.export({ type: 'spki', format: 'der' }); +const EXPECTED_KID = crypto + .createHash('sha256') + .update(PUBLIC_DER) + .digest('base64url') + .slice(0, 16); + +// Configure env BEFORE importing the module under test so the install +// fetcher's env lookups see the right key. +process.env.CLAWBYTE_SIGNING_PUBLIC_KEY = PUBLIC_PEM; + +// eslint-disable-next-line import/first +import { fetchInstallPayload } from './install'; + +type RawPayload = { + slug: string; + title: string; + description: string; + prompt: string; + signature?: string; + signatureKeyId?: string; + signedAt?: string; + signatureVersion?: number; +}; + +function signPayload( + base: Omit, + overrides: Partial> = {}, + signWith: crypto.KeyObject = privateKey +): RawPayload { + const signatureVersion = overrides.signatureVersion ?? 1; + const signatureKeyId = overrides.signatureKeyId ?? EXPECTED_KID; + const signedAt = overrides.signedAt ?? new Date().toISOString(); + const envelope = JSON.stringify({ + v: signatureVersion, + kid: signatureKeyId, + slug: base.slug, + title: base.title, + description: base.description, + prompt: base.prompt, + signedAt, + }); + const signature = crypto.sign(null, Buffer.from(envelope, 'utf8'), signWith).toString('base64'); + return { ...base, signature, signatureKeyId, signedAt, signatureVersion }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +const VALID_BASE = { + slug: 'deep-research', + title: 'Source Hunter', + description: 'Deep research that finds primary sources.', + prompt: 'Research [topic] for me.', +}; + +describe('fetchInstallPayload', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns the parsed payload for a valid signed response', async () => { + const signed = signPayload(VALID_BASE); + jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse(signed)); + + const result = await fetchInstallPayload('byte', 'deep-research'); + + expect(result).not.toBeNull(); + expect(result?.slug).toBe('deep-research'); + expect(result?.prompt).toBe('Research [topic] for me.'); + expect(result?.signatureKeyId).toBe(EXPECTED_KID); + }); + + it('reads cached by default and uncached when bypassCache is set', async () => { + const signed = signPayload(VALID_BASE); + // Fresh Response per call: a single Response body can only be read once. + const fetchSpy = jest + .spyOn(global, 'fetch') + .mockImplementation(async () => jsonResponse(signed)); + + await fetchInstallPayload('byte', 'deep-research'); + await fetchInstallPayload('byte', 'deep-research', { bypassCache: true }); + + // Preview: short revalidate window. Dispatch: no-store, so a changed or + // revoked byte is seen immediately rather than served from cache. + expect(fetchSpy.mock.calls[0]![1]).toMatchObject({ next: { revalidate: 300 } }); + expect(fetchSpy.mock.calls[1]![1]).toMatchObject({ cache: 'no-store' }); + }); + + it('returns null when upstream is 404', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 404 })); + const result = await fetchInstallPayload('byte', 'missing-slug'); + expect(result).toBeNull(); + }); + + it('throws when upstream is non-OK and non-404', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue(new Response('boom', { status: 500 })); + await expect(fetchInstallPayload('byte', 'deep-research')).rejects.toThrow(/500/); + }); + + it('rejects (does not follow) a redirect from the upstream origin (SSRF)', async () => { + // With `redirect: 'error'`, the platform fetch rejects rather than + // following a 3xx — so a compromised/abused trusted origin can't bounce + // the fetch to an attacker host. Simulate that rejection. + jest + .spyOn(global, 'fetch') + .mockRejectedValue(new TypeError('fetch failed: redirect mode is set to error')); + await expect(fetchInstallPayload('byte', 'deep-research')).rejects.toThrow( + /redirects are not followed/ + ); + }); + + it('rejects payload signed by a different key (kid mismatch)', async () => { + const { privateKey: foreignPriv, publicKey: foreignPub } = + crypto.generateKeyPairSync('ed25519'); + const foreignKid = crypto + .createHash('sha256') + .update(foreignPub.export({ type: 'spki', format: 'der' })) + .digest('base64url') + .slice(0, 16); + const signed = signPayload(VALID_BASE, { signatureKeyId: foreignKid }, foreignPriv); + jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse(signed)); + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await fetchInstallPayload('byte', 'deep-research'); + + expect(result).toBeNull(); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('key id mismatch')); + }); + + it('rejects tampered prompt (signature no longer verifies)', async () => { + const signed = signPayload(VALID_BASE); + const tampered = { ...signed, prompt: 'MALICIOUS PROMPT' }; + jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse(tampered)); + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await fetchInstallPayload('byte', 'deep-research'); + + expect(result).toBeNull(); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('signature did not verify')); + }); + + it('rejects tampered title (signature no longer verifies)', async () => { + const signed = signPayload(VALID_BASE); + const tampered = { ...signed, title: 'Different Title' }; + jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse(tampered)); + jest.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await fetchInstallPayload('byte', 'deep-research'); + expect(result).toBeNull(); + }); + + it('rejects an unsupported signature version', async () => { + const signed = signPayload(VALID_BASE, { signatureVersion: 99 }); + jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse(signed)); + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await fetchInstallPayload('byte', 'deep-research'); + + expect(result).toBeNull(); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('unsupported signature version')); + }); + + it('rejects a signature older than the TTL', async () => { + const ancient = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString(); + const signed = signPayload(VALID_BASE, { signedAt: ancient }); + jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse(signed)); + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await fetchInstallPayload('byte', 'deep-research'); + + expect(result).toBeNull(); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('too old')); + }); + + it('rejects a signature with signedAt in the future', async () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // +1h + const signed = signPayload(VALID_BASE, { signedAt: future }); + jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse(signed)); + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await fetchInstallPayload('byte', 'deep-research'); + + expect(result).toBeNull(); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('in the future')); + }); + + it('returns null and logs on an unsigned (Zod-invalid) payload', async () => { + // Treat schema-mismatched upstream responses as "unavailable" rather + // than throwing — matches the "byte not found" UX so the page can + // hand a single notFound() to the user. + jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse(VALID_BASE)); + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await fetchInstallPayload('byte', 'deep-research'); + + expect(result).toBeNull(); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('invalid upstream payload')); + }); + + it('rejects when signed payload.slug does not match the requested slug', async () => { + // A validly-signed byte for a different slug — protects against CDN / + // upstream swapping byte A's payload for a request targeting byte B. + const signed = signPayload({ ...VALID_BASE, slug: 'different-byte' }); + jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse(signed)); + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await fetchInstallPayload('byte', 'deep-research'); + + expect(result).toBeNull(); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('slug mismatch')); + }); + + it('returns null and logs when CLAWBYTE_SIGNING_PUBLIC_KEY is unset', async () => { + // Treat missing/unparseable verifier config as a verification failure + // rather than a thrown 500, so the install page returns a controlled + // "not available" (the route surfaces null as notFound()) and ops can + // distinguish it from "byte deleted upstream" via the log line. + const saved = process.env.CLAWBYTE_SIGNING_PUBLIC_KEY; + delete process.env.CLAWBYTE_SIGNING_PUBLIC_KEY; + try { + const signed = signPayload(VALID_BASE); + jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse(signed)); + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await fetchInstallPayload('byte', 'deep-research'); + + expect(result).toBeNull(); + expect(errSpy).toHaveBeenCalledWith( + expect.stringContaining('CLAWBYTE_SIGNING_PUBLIC_KEY is not configured') + ); + } finally { + process.env.CLAWBYTE_SIGNING_PUBLIC_KEY = saved; + } + }); + + it('rejects an oversize upstream response', async () => { + // Build a JSON body that exceeds MAX_RESPONSE_BYTES (256 KiB) so the + // bounded reader bails out before Zod parsing even runs. + const huge = 'x'.repeat(300 * 1024); + const signed = signPayload({ ...VALID_BASE, description: huge }); + jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse(signed)); + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await fetchInstallPayload('byte', 'deep-research'); + + expect(result).toBeNull(); + expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/exceeded \d+ bytes|exceeds limit/)); + }); +}); diff --git a/apps/web/src/lib/kiloclaw/install.ts b/apps/web/src/lib/kiloclaw/install.ts new file mode 100644 index 0000000000..3f9a7d964c --- /dev/null +++ b/apps/web/src/lib/kiloclaw/install.ts @@ -0,0 +1,281 @@ +import crypto from 'node:crypto'; +import { z } from 'zod'; +import { MESSAGE_TEXT_MAX_CHARS } from '@kilocode/kilo-chat'; +import { INSTALL_SOURCES, type InstallSource } from './install-sources'; + +/** + * Ed25519 signature verification for install payloads. + * + * The signed envelope and key-id derivation match the signer's exact shape + * in kilocode-landing's `src/lib/crabbytes-signing.ts`. If you change either + * side (envelope fields, key order, kid derivation), update both files + * together — otherwise verification will silently fail. + */ +const SUPPORTED_SIGNATURE_VERSION = 1; + +// Reject signatures older than this. Prevents an attacker who manages to +// capture a one-time signed payload from replaying it indefinitely after a +// later key rotation or content takedown. 30 days is generous; tighten if +// the byte catalog churns frequently. +const MAX_SIGNATURE_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +// Bound the upstream response so a malicious or misbehaving source can't +// force us to buffer and parse arbitrarily large JSON before Zod's per- +// field caps fire. 256 KiB is well above any realistic signed byte +// payload (`prompt` is capped at 32k, `description` at 2k, body etc. are +// dropped by Zod) and well below memory-pressure thresholds for a Vercel +// serverless render. +const MAX_RESPONSE_BYTES = 256 * 1024; + +// IMPORTANT: this schema intentionally contains ONLY fields that are covered +// by the Ed25519 signature (slug/title/description/prompt — see +// `canonicalEnvelopeString`) plus the signature metadata itself. Marketing- +// only fields the signer leaves unsigned (tagline, category, tags, body, +// ratings, …) are deliberately NOT modelled here: Zod strips them on parse, +// so it is impossible to render unsigned, tamperable content in the install +// preview. Do not add an unsigned field here without also adding it to the +// signed envelope on both the signer (kilocode-landing) and the verifier. +const installPayloadSchema = z.object({ + slug: z.string().min(1).max(200), + title: z.string().max(500), + description: z.string().max(2000), + // Cap matches `MESSAGE_TEXT_MAX_CHARS` in @kilocode/kilo-chat so a valid + // signed payload can't pass install verification only to fail downstream + // as `invalid_request` when kilo-chat enforces its per-text-block limit. + prompt: z.string().min(1).max(MESSAGE_TEXT_MAX_CHARS), + // Signature fields. All four are required — an unsigned payload fails + // Zod parsing before reaching the crypto verify step. + signature: z.string().min(1).max(200), // base64 Ed25519 sig (~88 chars) + signatureKeyId: z.string().min(1).max(64), + signedAt: z.string().datetime(), + signatureVersion: z.number().int().positive(), +}); + +export type InstallPayload = z.infer; + +function getPublicKey(): crypto.KeyObject | null { + const raw = process.env.CLAWBYTE_SIGNING_PUBLIC_KEY; + if (!raw) return null; + const pem = raw.replace(/\\n/g, '\n').trim(); + try { + return crypto.createPublicKey({ key: pem, format: 'pem' }); + } catch { + return null; + } +} + +function deriveKeyId(publicKey: crypto.KeyObject): string { + const der = publicKey.export({ type: 'spki', format: 'der' }); + return crypto.createHash('sha256').update(der).digest('base64url').slice(0, 16); +} + +function canonicalEnvelopeString(payload: InstallPayload): string { + // MUST match the signer's exact key order. Append-only if the envelope + // evolves; bump SUPPORTED_SIGNATURE_VERSION alongside the change. + return JSON.stringify({ + v: payload.signatureVersion, + kid: payload.signatureKeyId, + slug: payload.slug, + title: payload.title, + description: payload.description, + prompt: payload.prompt, + signedAt: payload.signedAt, + }); +} + +type VerifyOk = { ok: true }; +type VerifyErr = { ok: false; reason: string }; + +function verifySignedPayload(payload: InstallPayload): VerifyOk | VerifyErr { + if (payload.signatureVersion !== SUPPORTED_SIGNATURE_VERSION) { + return { + ok: false, + reason: `unsupported signature version ${payload.signatureVersion} (expected ${SUPPORTED_SIGNATURE_VERSION})`, + }; + } + + const ageMs = Date.now() - Date.parse(payload.signedAt); + if (!Number.isFinite(ageMs)) { + return { ok: false, reason: 'signedAt is not a valid date' }; + } + if (ageMs > MAX_SIGNATURE_AGE_MS) { + return { ok: false, reason: `signature too old (signedAt=${payload.signedAt})` }; + } + if (ageMs < -5 * 60 * 1000) { + // Allow ~5 min of clock skew either way; anything further in the future + // is suspicious. + return { ok: false, reason: `signedAt is in the future (signedAt=${payload.signedAt})` }; + } + + const publicKey = getPublicKey(); + if (!publicKey) { + return { + ok: false, + reason: + 'CLAWBYTE_SIGNING_PUBLIC_KEY is not configured or unparseable — verification unavailable', + }; + } + const expectedKid = deriveKeyId(publicKey); + if (payload.signatureKeyId !== expectedKid) { + return { + ok: false, + reason: `signature key id mismatch (payload=${payload.signatureKeyId}, pinned=${expectedKid})`, + }; + } + + const canonical = canonicalEnvelopeString(payload); + const sigBytes = Buffer.from(payload.signature, 'base64'); + const valid = crypto.verify(null, Buffer.from(canonical, 'utf8'), publicKey, sigBytes); + if (!valid) { + return { ok: false, reason: 'Ed25519 signature did not verify against pinned public key' }; + } + + return { ok: true }; +} + +/** + * Read a response body as text, but bail out if it exceeds `maxBytes`. + * Returns null on overflow (caller logs and rejects). + */ +async function readBoundedText(res: Response, maxBytes: number): Promise { + if (!res.body) { + // No streamable body (some Response shims / edge stubs). Still enforce the + // cap: buffer the whole body, then reject if it overflows. + const buf = await res.arrayBuffer(); + if (buf.byteLength > maxBytes) return null; + return new TextDecoder('utf-8').decode(buf); + } + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) { + total += value.byteLength; + if (total > maxBytes) { + // Free the buffer; we're throwing this body away. + chunks.length = 0; + try { + await reader.cancel(); + } catch { + // Cancel may reject if the stream is already terminating; safe to ignore. + } + return null; + } + chunks.push(value); + } + } + // Concatenate and decode as UTF-8. + const merged = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + merged.set(c, offset); + offset += c.byteLength; + } + return new TextDecoder('utf-8').decode(merged); +} + +export async function fetchInstallPayload( + source: InstallSource, + slug: string, + opts: { bypassCache?: boolean } = {} +): Promise { + const url = INSTALL_SOURCES[source].urlTemplate.replace('{slug}', encodeURIComponent(slug)); + // `redirect: 'error'` is SSRF defense in depth: the host comes from the + // registry (not user input) and the slug is encoded into a single path + // segment, so a request can't target an off-registry origin directly. The + // one residual path would be the trusted origin itself answering 3xx to an + // attacker host; refusing to follow redirects closes that before the + // signature check even runs. A redirect now rejects (caught below). + // + // Caching: the preview render uses a short revalidate window so repeated + // page loads are cheap. The CONFIRM-TIME dispatch passes `bypassCache` for an + // uncached read, so a byte that was changed, revoked, or deleted upstream + // takes effect immediately (a stale cached payload would otherwise still + // match the reviewed signature and dispatch within the revalidate window). + const fetchInit: RequestInit = opts.bypassCache + ? { cache: 'no-store', redirect: 'error' } + : { next: { revalidate: 300 }, redirect: 'error' }; + let res: Response; + try { + res = await fetch(url, fetchInit); + } catch (err) { + throw new Error( + `fetchInstallPayload(${source}, ${slug}): request failed (redirects are not followed): ${err instanceof Error ? err.message : String(err)}` + ); + } + + if (res.status === 404) return null; + if (!res.ok) { + throw new Error(`fetchInstallPayload(${source}, ${slug}): ${res.status} ${res.statusText}`); + } + + // Fast-path reject when the server tells us the body is too big. Not all + // upstreams send a reliable Content-Length, so we still bound the body + // read below. + const declaredLength = Number(res.headers.get('content-length') ?? ''); + if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { + console.error( + `[install] upstream Content-Length ${declaredLength} exceeds limit ${MAX_RESPONSE_BYTES} for ${source}/${slug}` + ); + return null; + } + + // Read as text with an explicit size cap so we never buffer a runaway + // body. (`res.text()` would buffer the whole stream first.) Parse JSON + // ourselves only after the size check passes. + const text = await readBoundedText(res, MAX_RESPONSE_BYTES); + if (text === null) { + console.error( + `[install] upstream response exceeded ${MAX_RESPONSE_BYTES} bytes for ${source}/${slug}` + ); + return null; + } + let json: unknown; + try { + json = JSON.parse(text); + } catch (err) { + throw new Error( + `fetchInstallPayload(${source}, ${slug}): upstream returned invalid JSON: ${err instanceof Error ? err.message : String(err)}` + ); + } + // safeParse rather than parse: an unsigned, malformed, or + // rollout-mismatched upstream payload must not throw a 500 — collapse + // it into the same null-return path as "not found" so the page surfaces + // a controlled `notFound()`. + const parsed = installPayloadSchema.safeParse(json); + if (!parsed.success) { + console.error( + `[install] invalid upstream payload for ${source}/${slug}: ${parsed.error.message}` + ); + return null; + } + const payload = parsed.data; + + const verify = verifySignedPayload(payload); + if (!verify.ok) { + // Treat verification failure as a hard reject — the caller surfaces + // this as an install-not-allowed error to the user. Logging the reason + // server-side so on-call can distinguish "byte deleted upstream" (404) + // from "byte tampered or key rotated" (verify failure). + console.error( + `[install] signature verification failed for ${source}/${slug}: ${verify.reason}` + ); + return null; + } + + // The signature covers `payload.slug`, but we also need to bind that slug + // to the slug the user actually requested. Otherwise a CDN/cache/object- + // path swap (or a malicious intermediary serving a different validly- + // signed byte for the requested URL) would let one byte's install + // dispatch under another byte's name. + if (payload.slug !== slug) { + console.error( + `[install] slug mismatch for ${source}/${slug}: signed payload is for "${payload.slug}"` + ); + return null; + } + + return payload; +} diff --git a/apps/web/src/lib/kiloclaw/kilo-chat-internal-client.test.ts b/apps/web/src/lib/kiloclaw/kilo-chat-internal-client.test.ts new file mode 100644 index 0000000000..aee4101901 --- /dev/null +++ b/apps/web/src/lib/kiloclaw/kilo-chat-internal-client.test.ts @@ -0,0 +1,170 @@ +// Env must be set BEFORE importing the module under test so its constants +// resolve to the test values. +process.env.NEXT_PUBLIC_KILO_CHAT_URL = 'https://chat.kiloapps.io'; + +jest.mock('@/lib/config.server', () => ({ + INTERNAL_API_SECRET: 'test-internal-secret', +})); + +import { postMessageAsUser } from './kilo-chat-internal-client'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +const VALID_PARAMS = { + userId: 'user-123', + sandboxId: 'sandbox-456', + message: 'Hello from the install flow', + source: 'install', +}; + +describe('postMessageAsUser (cloud → kilo-chat internal HTTP)', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('POSTs to the internal route with the api key header and body', async () => { + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue( + jsonResponse({ + ok: true, + conversationId: 'conv-1', + messageId: 'msg-1', + conversationCreated: false, + }) + ); + + const result = await postMessageAsUser(VALID_PARAMS); + + expect(result).toEqual({ + ok: true, + conversationId: 'conv-1', + messageId: 'msg-1', + conversationCreated: false, + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe('https://chat.kiloapps.io/internal/v1/post-message-as-user'); + expect(init?.method).toBe('POST'); + const headers = init?.headers as Record; + expect(headers['x-internal-api-key']).toBe('test-internal-secret'); + expect(headers['content-type']).toBe('application/json'); + expect(init?.body).toBe(JSON.stringify(VALID_PARAMS)); + expect(init?.cache).toBe('no-store'); + }); + + it('returns a typed error result on 400 invalid_request', async () => { + jest + .spyOn(global, 'fetch') + .mockResolvedValue( + jsonResponse({ ok: false, code: 'invalid_request', error: 'message empty' }, 400) + ); + + const result = await postMessageAsUser(VALID_PARAMS); + + expect(result).toEqual({ + ok: false, + code: 'invalid_request', + error: 'message empty', + }); + }); + + it('returns a typed error result on 404 no_conversation', async () => { + jest + .spyOn(global, 'fetch') + .mockResolvedValue( + jsonResponse({ ok: false, code: 'no_conversation', error: 'user has no conversation' }, 404) + ); + + const result = await postMessageAsUser(VALID_PARAMS); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe('no_conversation'); + }); + + it('returns forbidden when the middleware rejects (envelope-less 403)', async () => { + // `internalApiMiddleware` short-circuits before the route handler when + // the api-key header is missing/wrong, returning `{ error: 'Forbidden' }` + // rather than the discriminated-union shape. Client maps that to a + // typed `forbidden` result so callers don't need to know. + jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse({ error: 'Forbidden' }, 403)); + + const result = await postMessageAsUser(VALID_PARAMS); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe('forbidden'); + }); + + it('throws on a non-JSON response', async () => { + jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response('plain text broken', { status: 502 })); + + await expect(postMessageAsUser(VALID_PARAMS)).rejects.toThrow(/non-JSON response/); + }); + + it('throws on an unexpected JSON shape (non-403)', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse({ totally: 'unexpected' }, 500)); + + await expect(postMessageAsUser(VALID_PARAMS)).rejects.toThrow(/unexpected payload/); + }); + + it('throws when NEXT_PUBLIC_KILO_CHAT_URL is missing', async () => { + const saved = process.env.NEXT_PUBLIC_KILO_CHAT_URL; + delete process.env.NEXT_PUBLIC_KILO_CHAT_URL; + try { + await expect(postMessageAsUser(VALID_PARAMS)).rejects.toThrow( + /NEXT_PUBLIC_KILO_CHAT_URL is not configured/ + ); + } finally { + process.env.NEXT_PUBLIC_KILO_CHAT_URL = saved; + } + }); + + it('refuses to send the key to an off-allowlist origin (never fetches)', async () => { + const saved = process.env.NEXT_PUBLIC_KILO_CHAT_URL; + process.env.NEXT_PUBLIC_KILO_CHAT_URL = 'https://evil.example.com'; + const fetchSpy = jest.spyOn(global, 'fetch'); + try { + await expect(postMessageAsUser(VALID_PARAMS)).rejects.toThrow( + /not an allowed kilo-chat origin/ + ); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + process.env.NEXT_PUBLIC_KILO_CHAT_URL = saved; + } + }); + + it('returns a typed internal error when the request times out', async () => { + // Simulate AbortSignal.timeout firing by having fetch reject with a + // TimeoutError-named exception (which is what the browser/node fetch + // surface when AbortSignal.timeout aborts a request). + const timeoutErr = new Error('The operation was aborted due to timeout'); + timeoutErr.name = 'TimeoutError'; + jest.spyOn(global, 'fetch').mockRejectedValue(timeoutErr); + + const result = await postMessageAsUser(VALID_PARAMS); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe('internal'); + expect(result.error).toMatch(/timed out/); + } + }); + + it('returns a typed internal error on a generic network failure', async () => { + jest.spyOn(global, 'fetch').mockRejectedValue(new Error('network down')); + + const result = await postMessageAsUser(VALID_PARAMS); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe('internal'); + expect(result.error).toMatch(/network down|fetch failed/); + } + }); +}); diff --git a/apps/web/src/lib/kiloclaw/kilo-chat-internal-client.ts b/apps/web/src/lib/kiloclaw/kilo-chat-internal-client.ts new file mode 100644 index 0000000000..fd41c8ffd3 --- /dev/null +++ b/apps/web/src/lib/kiloclaw/kilo-chat-internal-client.ts @@ -0,0 +1,138 @@ +import 'server-only'; +import { + postMessageAsUserResultSchema, + type PostMessageAsUserParams, + type PostMessageAsUserResult, +} from '@kilocode/kilo-chat'; +import { INTERNAL_API_SECRET } from '@/lib/config.server'; + +// 5s is well above kilo-chat's expected p99 for postMessageAsUser +// (~ a single DO RPC + a sendMessage) and well below Vercel's outer +// serverless function timeout, so a stuck request fails fast with a +// typed `internal` result instead of cascading into the wider request. +const POST_MESSAGE_AS_USER_TIMEOUT_MS = 5_000; + +/** + * Server-side HTTP client for kilo-chat's `/internal/v1/*` routes. + * + * The cloud Next.js app runs on Vercel (not Cloudflare), so it can't reach + * kilo-chat's `WorkerEntrypoint` RPC via service binding the way other + * Workers do. This client POSTs over plain HTTPS instead, gated by an + * `x-internal-api-key` header that kilo-chat's `internalApiMiddleware` + * timing-safe compares against `INTERNAL_API_SECRET`. + * + * Both env vars are required at runtime: + * - `NEXT_PUBLIC_KILO_CHAT_URL` — already used by the existing public + * client-side kilo-chat token flow; we reuse it for the internal path. + * - `INTERNAL_API_SECRET` — shared secret with kilo-chat's Secrets Store + * binding. Already used by other cloud → service integrations. + */ + +// Origins the internal API key may be sent to. The destination comes from +// NEXT_PUBLIC_KILO_CHAT_URL (deploy config), so this is defense in depth: a +// misconfigured or tampered value must not be able to forward the key (and the +// prompt) to an unexpected host. `chat.kiloapps.io` is the single deployed +// kilo-chat origin (services/kilo-chat/wrangler.jsonc). Loopback covers local +// dev on any port (KILO_PORT_OFFSET can shift it). Add new deployed origins +// here if kilo-chat ever gains a staging domain. +function isAllowedKiloChatOrigin(url: URL): boolean { + if (url.protocol === 'https:' && url.hostname === 'chat.kiloapps.io') return true; + if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return true; + return false; +} + +function getKiloChatBaseUrl(): string { + // Read process.env directly rather than importing KILO_CHAT_URL from + // `@/lib/constants`: that constant is marked required at import time, which + // crashes test setups if the var is unset. This server-only client should + // fail loudly only when it is actually called. + const raw = process.env.NEXT_PUBLIC_KILO_CHAT_URL; + if (!raw) { + throw new Error( + 'NEXT_PUBLIC_KILO_CHAT_URL is not configured, cannot reach kilo-chat internal routes' + ); + } + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error(`NEXT_PUBLIC_KILO_CHAT_URL is not a valid URL: ${raw}`); + } + if (!isAllowedKiloChatOrigin(parsed)) { + throw new Error( + `Refusing to send the internal API key: ${parsed.origin} is not an allowed kilo-chat origin` + ); + } + return raw.replace(/\/$/, ''); +} + +export async function postMessageAsUser( + params: PostMessageAsUserParams +): Promise { + if (!INTERNAL_API_SECRET) { + throw new Error( + 'INTERNAL_API_SECRET is not configured — cannot authenticate to kilo-chat internal routes' + ); + } + + const url = `${getKiloChatBaseUrl()}/internal/v1/post-message-as-user`; + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': INTERNAL_API_SECRET, + }, + body: JSON.stringify(params), + // Internal-only call between services; no caching. + cache: 'no-store', + // Never follow redirects on a request that carries the internal API key: + // a misconfigured or redirecting destination must not be able to forward + // the secret (and the prompt) to another origin. A redirect fails here + // and surfaces as a typed `internal` result below. + redirect: 'error', + signal: AbortSignal.timeout(POST_MESSAGE_AS_USER_TIMEOUT_MS), + }); + } catch (err) { + // AbortSignal.timeout fires with a TimeoutError DOMException. Map to a + // typed `internal` result so callers don't have to know about fetch's + // abort/network failure modes; same shape regardless of cause. + const isTimeout = err instanceof Error && err.name === 'TimeoutError'; + return { + ok: false, + code: 'internal', + error: isTimeout + ? `kilo-chat /internal/v1/post-message-as-user timed out after ${POST_MESSAGE_AS_USER_TIMEOUT_MS}ms` + : `kilo-chat /internal/v1/post-message-as-user fetch failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + // kilo-chat's internal route always returns a JSON body whether the + // outcome is ok:true (200) or ok:false (400/403/404/500). Parse first, + // then validate against the discriminated union so callers get a typed + // result regardless of HTTP status. + let body: unknown; + try { + body = await res.json(); + } catch (err) { + throw new Error( + `kilo-chat /internal/v1/post-message-as-user returned non-JSON response (HTTP ${res.status}): ${err instanceof Error ? err.message : String(err)}` + ); + } + + const parsed = postMessageAsUserResultSchema.safeParse(body); + if (!parsed.success) { + // Most likely: 403 from `internalApiMiddleware` before reaching the + // route handler, which returns `{ error: 'Forbidden' }`. Surface that + // as `forbidden` so callers don't need to know about middleware shapes. + if (res.status === 403) { + return { ok: false, code: 'forbidden', error: 'kilo-chat rejected the internal-api-key' }; + } + throw new Error( + `kilo-chat /internal/v1/post-message-as-user returned unexpected payload (HTTP ${res.status}): ${parsed.error.message}` + ); + } + + return parsed.data; +} diff --git a/apps/web/src/routers/kiloclaw-router.test.ts b/apps/web/src/routers/kiloclaw-router.test.ts index ab3ba03295..edf82c692b 100644 --- a/apps/web/src/routers/kiloclaw-router.test.ts +++ b/apps/web/src/routers/kiloclaw-router.test.ts @@ -140,6 +140,14 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => { }; }); +// Mock the install dispatch lib so installFromSource tests exercise the +// procedure (auth gate + input validation + wiring) without the real +// fetch/verify/kilo-chat path (covered by install-dispatch.test.ts). +jest.mock('@/lib/kiloclaw/install-dispatch', () => { + const dispatchInstallFromSource = jest.fn(); + return { dispatchInstallFromSource, __dispatchInstallFromSource: dispatchInstallFromSource }; +}); + let createCaller: (ctx: { user: Awaited> }) => { getStatus: () => Promise; latestVersion: (input?: { currentImageTag?: string }) => Promise; @@ -211,6 +219,17 @@ let createCaller: (ctx: { user: Awaited> }) => pendingRewardCount: number; }; }>; + // Method syntax (bivariant params) so the real caller's narrower + // `source: 'byte'` input stays assignable while tests can pass an arbitrary + // string for the input-validation case. + installFromSource(input: { + source: string; + slug: string; + signature: string; + }): Promise< + | { ok: true; conversationId: string; messageId: string; conversationCreated: boolean } + | { ok: false; code: 'no_instance' } + >; }; const kiloclawClientMock = jest.requireMock( '@/lib/kiloclaw/kiloclaw-internal-client' @@ -1255,3 +1274,110 @@ describe('kiloclawRouter destroy', () => { ); }); }); + +describe('kiloclawRouter installFromSource', () => { + const installDispatchMock = jest.requireMock<{ __dispatchInstallFromSource: AnyMock }>( + '@/lib/kiloclaw/install-dispatch' + ); + + beforeEach(async () => { + await cleanupDbForTest(); + installDispatchMock.__dispatchInstallFromSource.mockReset(); + }); + + // Grant active KiloClaw access (a trialing subscription) so the + // clawAccessProcedure gate passes. Mirrors the `start` tests' fixture. + async function grantClawAccess(userId: string): Promise { + const instanceId = crypto.randomUUID(); + await db.insert(kiloclaw_instances).values({ + id: instanceId, + user_id: userId, + sandbox_id: `ki_${instanceId.replace(/-/g, '')}`, + }); + await db.insert(kiloclaw_subscriptions).values({ + user_id: userId, + instance_id: instanceId, + plan: 'trial', + status: 'trialing', + trial_ends_at: '2026-12-31T23:59:59.000Z', + }); + } + + it('rejects a caller without active KiloClaw access (FORBIDDEN) and never dispatches', async () => { + const user = await insertTestUser({ + google_user_email: `install-noaccess-${Math.random()}@example.com`, + }); + const caller = createCaller({ user }); + + await expect( + caller.installFromSource({ source: 'byte', slug: 'deep-research', signature: 'sig' }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(installDispatchMock.__dispatchInstallFromSource).not.toHaveBeenCalled(); + }); + + it('dispatches for an entitled caller and returns the dispatch result', async () => { + const user = await insertTestUser({ + google_user_email: `install-access-${Math.random()}@example.com`, + }); + await grantClawAccess(user.id); + installDispatchMock.__dispatchInstallFromSource.mockResolvedValue({ + ok: true, + conversationId: 'conv_1', + messageId: 'msg_1', + conversationCreated: true, + }); + const caller = createCaller({ user }); + + const result = await caller.installFromSource({ + source: 'byte', + slug: 'deep-research', + signature: 'sig', + }); + + expect(result).toEqual({ + ok: true, + conversationId: 'conv_1', + messageId: 'msg_1', + conversationCreated: true, + }); + expect(installDispatchMock.__dispatchInstallFromSource).toHaveBeenCalledWith({ + userId: user.id, + source: 'byte', + slug: 'deep-research', + expectedSignature: 'sig', + }); + }); + + it('passes through the no_instance outcome', async () => { + const user = await insertTestUser({ + google_user_email: `install-noinstance-${Math.random()}@example.com`, + }); + await grantClawAccess(user.id); + installDispatchMock.__dispatchInstallFromSource.mockResolvedValue({ + ok: false, + code: 'no_instance', + }); + const caller = createCaller({ user }); + + const result = await caller.installFromSource({ + source: 'byte', + slug: 'deep-research', + signature: 'sig', + }); + + expect(result).toEqual({ ok: false, code: 'no_instance' }); + }); + + it('rejects an unregistered source via input validation, without dispatching', async () => { + const user = await insertTestUser({ + google_user_email: `install-badsource-${Math.random()}@example.com`, + }); + await grantClawAccess(user.id); + const caller = createCaller({ user }); + + await expect( + caller.installFromSource({ source: 'hacker', slug: 'deep-research', signature: 'sig' }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(installDispatchMock.__dispatchInstallFromSource).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index c16f8239be..64edb5f5d4 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -95,6 +95,8 @@ import { enqueueAffiliateEventForUser, } from '@/lib/impact/affiliate-events'; import { clawAccessProcedure } from '@/lib/kiloclaw/access-gate'; +import { dispatchInstallFromSource } from '@/lib/kiloclaw/install-dispatch'; +import { INSTALL_SOURCE_KEYS } from '@/lib/kiloclaw/install-sources'; import { cancelCliRun, createCliRun, getCliRunStatus } from '@/lib/kiloclaw/cli-runs'; import { KILOCLAW_EARLYBIRD_EXPIRY_DATE } from '@/lib/kiloclaw/constants'; import { @@ -3023,6 +3025,25 @@ export const kiloclawRouter = createTRPCRouter({ return instance ? { instanceId: instance.id } : null; }), + installFromSource: clawAccessProcedure + .input( + z.object({ + source: z.enum(INSTALL_SOURCE_KEYS), + slug: z.string().min(1).max(200), + // Signature of the payload the user reviewed; the dispatch re-verifies + // and requires the re-fetched payload to still match this. + signature: z.string().min(1).max(200), + }) + ) + .mutation(async ({ ctx, input }) => { + return await dispatchInstallFromSource({ + userId: ctx.user.id, + source: input.source, + slug: input.slug, + expectedSignature: input.signature, + }); + }), + getMorningBriefingStatus: clawAccessProcedure.query(async ({ ctx }) => { const instance = await getActiveInstance(ctx.user.id); const client = new KiloClawInternalClient(); diff --git a/packages/kilo-chat/src/index.ts b/packages/kilo-chat/src/index.ts index a55faa4199..f28b7466d8 100644 --- a/packages/kilo-chat/src/index.ts +++ b/packages/kilo-chat/src/index.ts @@ -21,5 +21,12 @@ export type { KiloChatEvent, KiloChatEventName, KiloChatEventOf } from './events export * from './schemas'; export * from './webhook-schemas'; export type * from './rpc-types'; +export { + postMessageAsUserOkSchema, + postMessageAsUserErrSchema, + postMessageAsUserResultSchema, + postMessageAsUserParamsSchema, + postMessageAsUserCorrelationSchema, +} from './rpc-types'; export * from './events'; export * from './route-helpers'; diff --git a/packages/kilo-chat/src/rpc-types.ts b/packages/kilo-chat/src/rpc-types.ts index 67c361c664..135e365d7d 100644 --- a/packages/kilo-chat/src/rpc-types.ts +++ b/packages/kilo-chat/src/rpc-types.ts @@ -1,3 +1,6 @@ +import { z } from 'zod'; +import { messageTextSchema } from './schemas'; + // Cross-service RPC contracts exposed by the kilo-chat WorkerEntrypoint. // // Producer: services/kilo-chat/src/index.ts (KiloChatService) @@ -14,38 +17,62 @@ // build. // ── postMessageAsUser ────────────────────────────────────────────── +// +// The Zod schemas are the single source of truth; the exported TS types are +// derived from them via `z.infer`. Worker RPC callers import the types (which +// compile away — `z.infer` is type-only, no runtime cost), while HTTP callers +// and the HTTP route reuse the schemas for validation. Add a field or error +// code in one place and both call paths stay in sync by construction. The +// bounds (`min(1)`, `max(64)` on source, etc.) are HTTP-boundary safety and +// apply uniformly to both call paths. -export type PostMessageAsUserCorrelation = { - triggerId?: string; - webhookRequestId?: string; - reason?: string; -}; +export const postMessageAsUserCorrelationSchema = z.object({ + triggerId: z.string().max(200).optional(), + webhookRequestId: z.string().max(200).optional(), + reason: z.string().max(200).optional(), +}); -export type PostMessageAsUserParams = { - userId: string; - sandboxId: string; - message: string; +export const postMessageAsUserParamsSchema = z.object({ + userId: z.string().min(1).max(200), + sandboxId: z.string().min(1).max(200), + // Shared with `textBlockSchema` (the message-creation boundary) so the HTTP + // boundary and the service can't drift: trimmed, non-empty, ≤ 8000 chars. + message: messageTextSchema, // Origin identifier for diagnostics (e.g. "webhook", "onboarding-warmup"). // Logged so structured-log queries can attribute new conversations to a // specific source. - source: string; + source: z.string().min(1).max(64), // Default true. Pass false to fail the call if the user has never opened // a chat with this bot. - autoCreateConversation?: boolean; - correlation?: PostMessageAsUserCorrelation; -}; - -export type PostMessageAsUserOk = { - ok: true; - conversationId: string; - messageId: string; - conversationCreated: boolean; -}; - -export type PostMessageAsUserErr = { - ok: false; - code: 'invalid_request' | 'no_conversation' | 'forbidden' | 'internal'; - error: string; -}; - -export type PostMessageAsUserResult = PostMessageAsUserOk | PostMessageAsUserErr; + autoCreateConversation: z.boolean().optional(), + // Default false. When true, always start a NEW conversation instead of + // reusing the user's most-recent one. The install flow sets this so each + // install lands in its own dedicated chat; webhook-style callers omit it to + // keep appending to the ongoing conversation. + forceNewConversation: z.boolean().optional(), + correlation: postMessageAsUserCorrelationSchema.optional(), +}); + +export const postMessageAsUserOkSchema = z.object({ + ok: z.literal(true), + conversationId: z.string(), + messageId: z.string(), + conversationCreated: z.boolean(), +}); + +export const postMessageAsUserErrSchema = z.object({ + ok: z.literal(false), + code: z.enum(['invalid_request', 'no_conversation', 'forbidden', 'internal']), + error: z.string(), +}); + +export const postMessageAsUserResultSchema = z.discriminatedUnion('ok', [ + postMessageAsUserOkSchema, + postMessageAsUserErrSchema, +]); + +export type PostMessageAsUserCorrelation = z.infer; +export type PostMessageAsUserParams = z.infer; +export type PostMessageAsUserOk = z.infer; +export type PostMessageAsUserErr = z.infer; +export type PostMessageAsUserResult = z.infer; diff --git a/packages/kilo-chat/src/schemas.ts b/packages/kilo-chat/src/schemas.ts index 35d7cb0c18..bb4036bae5 100644 --- a/packages/kilo-chat/src/schemas.ts +++ b/packages/kilo-chat/src/schemas.ts @@ -39,6 +39,15 @@ const trimmedNonEmptyString = (max: number) => export const conversationTitleSchema = trimmedNonEmptyString(CONVERSATION_TITLE_MAX_CHARS); +/** + * Validation for a single message text body. Shared source of truth so every + * boundary that accepts message text (the `textBlockSchema` used at message + * creation, and the `postMessageAsUserParamsSchema` HTTP boundary) enforces + * the SAME rule (trimmed, non-empty, max MESSAGE_TEXT_MAX_CHARS), so they + * cannot drift apart. + */ +export const messageTextSchema = trimmedNonEmptyString(MESSAGE_TEXT_MAX_CHARS); + // 1-64 bytes UTF-8, no C0 (0x00-0x1F) or C1 (0x7F-0x9F) control chars. export const emojiSchema = z .string() @@ -90,7 +99,7 @@ export const actionsBlockSchema = z export const textBlockSchema = z.object({ type: z.literal('text'), - text: trimmedNonEmptyString(MESSAGE_TEXT_MAX_CHARS), + text: messageTextSchema, }); const attachmentMetadataShape = { diff --git a/services/kilo-chat/src/__tests__/post-message-as-user.test.ts b/services/kilo-chat/src/__tests__/post-message-as-user.test.ts index 9155f3855e..a58edb3d31 100644 --- a/services/kilo-chat/src/__tests__/post-message-as-user.test.ts +++ b/services/kilo-chat/src/__tests__/post-message-as-user.test.ts @@ -96,6 +96,35 @@ describe('postMessageAsUser', () => { expect(second.conversationId).toBe(first.conversationId); }); + it('forceNewConversation always creates a fresh conversation even when one exists', async () => { + const userId = `user-${crypto.randomUUID()}`; + const sandboxId = `sandbox-${crypto.randomUUID()}`; + grantSandbox(userId, sandboxId); + const testEnv = makeEnv(); + + const first = await runPost(testEnv, { + userId, + sandboxId, + message: 'first', + source: 'webhook', + }); + expect(first.ok).toBe(true); + if (!first.ok) return; + + const installed = await runPost(testEnv, { + userId, + sandboxId, + message: 'installed byte prompt', + source: 'install', + forceNewConversation: true, + }); + expect(installed.ok).toBe(true); + if (!installed.ok) return; + // A brand-new conversation, not the pre-existing one. + expect(installed.conversationCreated).toBe(true); + expect(installed.conversationId).not.toBe(first.conversationId); + }); + it('returns no_conversation when autoCreateConversation is false and none exists', async () => { const userId = `user-${crypto.randomUUID()}`; const sandboxId = `sandbox-${crypto.randomUUID()}`; diff --git a/services/kilo-chat/src/auth-internal.ts b/services/kilo-chat/src/auth-internal.ts new file mode 100644 index 0000000000..6f846aa2cf --- /dev/null +++ b/services/kilo-chat/src/auth-internal.ts @@ -0,0 +1,40 @@ +import { createMiddleware } from 'hono/factory'; +import { timingSafeEqual } from '@kilocode/encryption'; +import { logger } from './util/logger'; +import type { AuthContext } from './auth'; + +/** + * Internal API auth — verifies the `x-internal-api-key` header against the + * `INTERNAL_API_SECRET` env binding. Mirrors the pattern in + * `services/kiloclaw/src/auth/middleware.ts`. + * + * Applied to routes under `/internal/*` that are called server-to-server + * by trusted callers (e.g. the cloud Next.js web app's tRPC mutations). + * The caller passes `userId` etc. in the request body — there is no JWT. + */ +export const internalApiMiddleware = createMiddleware<{ + Bindings: Env; + Variables: AuthContext; +}>(async (c, next) => { + // Reject missing-header probes immediately, before hitting Secrets Store. + // Unauthenticated traffic shouldn't generate backend secret reads. + const apiKey = c.req.header('x-internal-api-key'); + if (!apiKey) return c.json({ error: 'Forbidden' }, 403); + + let secret: string; + try { + secret = await c.env.INTERNAL_API_SECRET.get(); + } catch (err) { + logger.error('Failed to read INTERNAL_API_SECRET', { err: String(err) }); + return c.json({ error: 'Server configuration error' }, 500); + } + if (!secret) { + logger.error('INTERNAL_API_SECRET not configured'); + return c.json({ error: 'Server configuration error' }, 500); + } + + if (!timingSafeEqual(apiKey, secret)) return c.json({ error: 'Forbidden' }, 403); + + logger.setTags({ source: 'internal-api' }); + return next(); +}); diff --git a/services/kilo-chat/src/index.ts b/services/kilo-chat/src/index.ts index d3fbdff85e..2d0ebe8f5b 100644 --- a/services/kilo-chat/src/index.ts +++ b/services/kilo-chat/src/index.ts @@ -9,6 +9,7 @@ import { logger, withLogTags } from './util/logger'; import { formatError } from '@kilocode/worker-utils'; import { authMiddleware } from './auth'; import { botAuthMiddleware } from './auth-bot'; +import { internalApiMiddleware } from './auth-internal'; import type { AuthContext } from './auth'; import { decodeConversationCursor, type ConversationCursor } from '@kilocode/kilo-chat'; import { registerConversationRoutes } from './routes/conversations'; @@ -26,6 +27,7 @@ import { handleStopTyping, } from './routes/handler'; import { registerBotRoutes } from './routes/bot-messages'; +import { registerInternalRoutes } from './routes/internal'; import { registerSandboxReadRoutes } from './routes/sandbox-reads'; import { postMessageAsUser, @@ -105,6 +107,11 @@ app.get('/v1/attachments/:id/url', handleAttachmentGetUrl); app.use('/bot/v1/sandboxes/:sandboxId/*', botAuthMiddleware); registerBotRoutes(app); +// Internal HTTP routes — `x-internal-api-key` shared-secret auth, called +// server-to-server by trusted callers (e.g. the Next.js cloud web app). +app.use('/internal/*', internalApiMiddleware); +registerInternalRoutes(app); + export class KiloChatService extends WorkerEntrypoint { async fetch(request: Request): Promise { return app.fetch(request, this.env, this.ctx); diff --git a/services/kilo-chat/src/routes/internal.ts b/services/kilo-chat/src/routes/internal.ts new file mode 100644 index 0000000000..160efec984 --- /dev/null +++ b/services/kilo-chat/src/routes/internal.ts @@ -0,0 +1,41 @@ +import type { Hono } from 'hono'; +import type { ContentfulStatusCode } from 'hono/utils/http-status'; +import { postMessageAsUserParamsSchema } from '@kilocode/kilo-chat'; +import type { AuthContext } from '../auth'; +import { logger } from '../util/logger'; +import { postMessageAsUser } from '../services/post-message-as-user'; + +/** + * HTTP wrapper around the `postMessageAsUser` RPC primitive, for callers + * that don't run on Cloudflare Workers (e.g. the Next.js cloud web app). + * Mounted under `/internal/v1/*` behind `internalApiMiddleware`. + */ +export function registerInternalRoutes(app: Hono<{ Bindings: Env; Variables: AuthContext }>) { + app.post('/internal/v1/post-message-as-user', async c => { + const raw: unknown = await c.req.json().catch(() => null); + const parsed = postMessageAsUserParamsSchema.safeParse(raw); + if (!parsed.success) { + return c.json({ ok: false, code: 'invalid_request', error: parsed.error.message }, 400); + } + + const result = await postMessageAsUser( + c.env, + { waitUntil: p => c.executionCtx.waitUntil(p) }, + parsed.data + ); + + if (result.ok) return c.json(result, 200); + + const statusFromCode: Record = { + invalid_request: 400, + forbidden: 403, + no_conversation: 404, + internal: 500, + }; + logger.warn('internal post-message-as-user failed', { + code: result.code, + source: parsed.data.source, + }); + return c.json(result, statusFromCode[result.code]); + }); +} diff --git a/services/kilo-chat/src/services/post-message-as-user.ts b/services/kilo-chat/src/services/post-message-as-user.ts index 70e51a4863..2bc2719a18 100644 --- a/services/kilo-chat/src/services/post-message-as-user.ts +++ b/services/kilo-chat/src/services/post-message-as-user.ts @@ -32,7 +32,15 @@ export async function postMessageAsUser( ctx: DeferCtx, params: PostMessageAsUserParams ): Promise { - const { userId, sandboxId, message, source, autoCreateConversation = true, correlation } = params; + const { + userId, + sandboxId, + message, + source, + autoCreateConversation = true, + forceNewConversation = false, + correlation, + } = params; logger.setTags({ sandboxId, callerId: userId }); @@ -88,13 +96,17 @@ export async function postMessageAsUser( // rare: webhook triggers fire serially per trigger, and a user with // multiple triggers pointing at the same bot would only race on the very // first delivery across all of them. - const existingConversationId = await findUserBotConversation(env, userId, sandboxId); + // `forceNewConversation` skips the reuse lookup so the call always starts a + // fresh conversation (the install flow wants a dedicated chat per install). + const existingConversationId = forceNewConversation + ? null + : await findUserBotConversation(env, userId, sandboxId); let conversationId: string; let conversationCreated = false; if (existingConversationId) { conversationId = existingConversationId; - } else if (autoCreateConversation) { + } else if (autoCreateConversation || forceNewConversation) { const created = await createConversationFor(env, userId, { sandboxId }); if (!created.ok) { logger.warn('postMessageAsUser: failed to create conversation', { diff --git a/services/kilo-chat/worker-configuration.d.ts b/services/kilo-chat/worker-configuration.d.ts index db185eca07..2fdef3745e 100644 --- a/services/kilo-chat/worker-configuration.d.ts +++ b/services/kilo-chat/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 8515390d8c3a2362c468146961683341) +// Generated by Wrangler by running `wrangler types` (hash: 9a65316f3ede92753a3993b9ca864eae) // Runtime types generated with workerd@1.20260508.1 2026-04-25 nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -13,6 +13,7 @@ declare namespace Cloudflare { GATEWAY_TOKEN_SECRET: SecretsStoreSecret; R2_ACCESS_KEY_ID: SecretsStoreSecret; R2_SECRET_ACCESS_KEY: SecretsStoreSecret; + INTERNAL_API_SECRET: SecretsStoreSecret; R2_ACCOUNT_ID: "e115e769bcdd4c3d66af59d3332cb394"; R2_BUCKET_NAME: "kilo-chat-media"; KEY_PREFIX: ""; diff --git a/services/kilo-chat/wrangler.jsonc b/services/kilo-chat/wrangler.jsonc index e2493232bc..2e2638c652 100644 --- a/services/kilo-chat/wrangler.jsonc +++ b/services/kilo-chat/wrangler.jsonc @@ -78,5 +78,10 @@ "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", "secret_name": "R2_SECRET_ACCESS_KEY_KILOCHAT_MEDIA", }, + { + "binding": "INTERNAL_API_SECRET", + "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", + "secret_name": "INTERNAL_API_SECRET_PROD", + }, ], } diff --git a/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts b/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts index 35d7cb0c18..bb4036bae5 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts @@ -39,6 +39,15 @@ const trimmedNonEmptyString = (max: number) => export const conversationTitleSchema = trimmedNonEmptyString(CONVERSATION_TITLE_MAX_CHARS); +/** + * Validation for a single message text body. Shared source of truth so every + * boundary that accepts message text (the `textBlockSchema` used at message + * creation, and the `postMessageAsUserParamsSchema` HTTP boundary) enforces + * the SAME rule (trimmed, non-empty, max MESSAGE_TEXT_MAX_CHARS), so they + * cannot drift apart. + */ +export const messageTextSchema = trimmedNonEmptyString(MESSAGE_TEXT_MAX_CHARS); + // 1-64 bytes UTF-8, no C0 (0x00-0x1F) or C1 (0x7F-0x9F) control chars. export const emojiSchema = z .string() @@ -90,7 +99,7 @@ export const actionsBlockSchema = z export const textBlockSchema = z.object({ type: z.literal('text'), - text: trimmedNonEmptyString(MESSAGE_TEXT_MAX_CHARS), + text: messageTextSchema, }); const attachmentMetadataShape = {