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 = {