From 50300ff80ea5609015e8173f2cbf051d2edf033d Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Thu, 28 May 2026 15:14:59 -0700 Subject: [PATCH 01/20] =?UTF-8?q?wip:=20clawbyte=20install=20flow=20?= =?UTF-8?q?=E2=80=94=20signed-payload=20verification=20+=20kilo-chat=20HTT?= =?UTF-8?q?P?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the foundation for app.kilo.ai 1-click install of ClawBytes from kilo.ai. Not yet wired end-to-end (no tRPC mutation, no client dispatch, no cookie-through-provisioning); this stages the verification, registry, and per-user chat-injection HTTP surface. apps/web (cloud Next.js): - lib/kiloclaw/install-sources.ts: generalized source registry. v1 ships a single `byte` source mapped to kilo.ai's data.json endpoint; KILO_AI_BASE_URL overrides for local dev. Adding a new source = appending one map entry. - lib/kiloclaw/install.ts: fetchInstallPayload(source, slug) does Zod parse + Ed25519 signature verify against pinned CLAWBYTE_SIGNING_PUBLIC_KEY. Rejects on missing key, kid mismatch, expired/future signedAt, version drift, or tampered envelope. Returns null on hard rejects so callers can't fall back to unsigned content. - lib/kiloclaw/install.test.ts: 11 cases covering happy ph, upstream 4xx/5xx, kid mismatch, tampered prompt/title, version drift, age window, unsigned payloads, and unconfigured pubkey. - app/(app)/claw/install/[source]/[slug]/page.tsx + InstallClient.tsx: server route fetches the payload behind the existing claw-layout auth gate; client shell renders a preview with title/description and a placeholder for the dispatch CTA. services/kilo-chat (Cloudflare Worker): - routes/internal.ts + auth-internal.ts: new /internal/v1/post- message-as-user HTTP endpoint, gated by an x-internal-api-key header verified against INTERNAL_API_SECRET. Thin wrapper around the existing postMessageAsUser RPC so non-Worker callers (the cloud Next.js app) can deliver an as-user chat turn without needing a service binding. - wrangler.jsonc: adds the INTERNAL_API_SECRET secrets-store binding (shared store, prod secret). - worker-configuration.d.ts: regenerated to include the new binding. --- .../install/[source]/[slug]/InstallClient.tsx | 35 ++++ .../claw/install/[source]/[slug]/page.tsx | 21 ++ apps/web/src/lib/kiloclaw/install-sources.ts | 16 ++ apps/web/src/lib/kiloclaw/install.test.ts | 198 ++++++++++++++++++ apps/web/src/lib/kiloclaw/install.ts | 140 +++++++++++++ services/kilo-chat/src/auth-internal.ts | 37 ++++ services/kilo-chat/src/index.ts | 7 + services/kilo-chat/src/routes/internal.ts | 56 +++++ services/kilo-chat/worker-configuration.d.ts | 3 +- services/kilo-chat/wrangler.jsonc | 5 + 10 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx create mode 100644 apps/web/src/app/(app)/claw/install/[source]/[slug]/page.tsx create mode 100644 apps/web/src/lib/kiloclaw/install-sources.ts create mode 100644 apps/web/src/lib/kiloclaw/install.test.ts create mode 100644 apps/web/src/lib/kiloclaw/install.ts create mode 100644 services/kilo-chat/src/auth-internal.ts create mode 100644 services/kilo-chat/src/routes/internal.ts 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..3aeaeafa22 --- /dev/null +++ b/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx @@ -0,0 +1,35 @@ +'use client'; + +import Link from 'next/link'; +import { Loader2 } from 'lucide-react'; +import type { InstallPayload } from '@/lib/kiloclaw/install'; +import type { InstallSource } from '@/lib/kiloclaw/install-sources'; + +type InstallClientProps = { + source: InstallSource; + sourceLabel: string; + payload: InstallPayload; +}; + +export function InstallClient({ source, sourceLabel, payload }: InstallClientProps) { + return ( +
+ +

+ Installing {sourceLabel} +

+

{payload.title}

+ {payload.tagline ? ( +

{payload.tagline}

+ ) : null} +

{payload.description}

+

+ Install dispatch wiring is pending — this page currently renders the preview only. Source:{' '} + {source} · Slug: {payload.slug} +

+ + Cancel + +
+ ); +} 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..b3febb0bad --- /dev/null +++ b/apps/web/src/app/(app)/claw/install/[source]/[slug]/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from 'next/navigation'; +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 }>; +}; + +export default async function InstallPage({ params }: InstallPageProps) { + const { source, slug } = await params; + + if (!isInstallSource(source)) notFound(); + + const payload = await fetchInstallPayload(source, slug); + if (!payload) notFound(); + + return ( + + ); +} 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..8887ad3b41 --- /dev/null +++ b/apps/web/src/lib/kiloclaw/install-sources.ts @@ -0,0 +1,16 @@ +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; + +export const INSTALL_SOURCE_KEYS = Object.keys(INSTALL_SOURCES) as InstallSource[]; + +export function isInstallSource(value: string): value is InstallSource { + return value in INSTALL_SOURCES; +} 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..55690c6314 --- /dev/null +++ b/apps/web/src/lib/kiloclaw/install.test.ts @@ -0,0 +1,198 @@ +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 PRIVATE_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; +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; + tagline?: string; + category?: string; + tags?: 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('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 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('rejects an unsigned payload via Zod parse', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse(VALID_BASE)); + await expect(fetchInstallPayload('byte', 'deep-research')).rejects.toThrow(); + }); + + it('throws when CLAWBYTE_SIGNING_PUBLIC_KEY is unset', async () => { + 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)); + jest.spyOn(console, 'error').mockImplementation(() => {}); + + // getPublicKey throws inside verifySignedPayload; the throw propagates + // out of fetchInstallPayload since we don't catch it there. + await expect(fetchInstallPayload('byte', 'deep-research')).rejects.toThrow( + /CLAWBYTE_SIGNING_PUBLIC_KEY is not configured/ + ); + } finally { + process.env.CLAWBYTE_SIGNING_PUBLIC_KEY = saved; + } + }); +}); diff --git a/apps/web/src/lib/kiloclaw/install.ts b/apps/web/src/lib/kiloclaw/install.ts new file mode 100644 index 0000000000..094237156d --- /dev/null +++ b/apps/web/src/lib/kiloclaw/install.ts @@ -0,0 +1,140 @@ +import crypto from 'node:crypto'; +import { z } from 'zod'; +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; + +const installPayloadSchema = z.object({ + slug: z.string().min(1).max(200), + title: z.string().max(500), + description: z.string().max(2000), + prompt: z.string().min(1).max(32000), + tagline: z.string().max(500).optional(), + category: z.string().max(100).optional(), + tags: z.array(z.string().max(100)).max(50).optional(), + // 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 { + const raw = process.env.CLAWBYTE_SIGNING_PUBLIC_KEY; + if (!raw) { + throw new Error( + 'CLAWBYTE_SIGNING_PUBLIC_KEY is not configured — install payloads cannot be verified' + ); + } + const pem = raw.replace(/\\n/g, '\n').trim(); + return crypto.createPublicKey({ key: pem, format: 'pem' }); +} + +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(); + 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 }; +} + +export async function fetchInstallPayload( + source: InstallSource, + slug: string +): Promise { + const url = INSTALL_SOURCES[source].urlTemplate.replace('{slug}', encodeURIComponent(slug)); + const res = await fetch(url, { next: { revalidate: 300 } }); + + if (res.status === 404) return null; + if (!res.ok) { + throw new Error(`fetchInstallPayload(${source}, ${slug}): ${res.status} ${res.statusText}`); + } + + const json = await res.json(); + const payload = installPayloadSchema.parse(json); + + 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; + } + + return payload; +} diff --git a/services/kilo-chat/src/auth-internal.ts b/services/kilo-chat/src/auth-internal.ts new file mode 100644 index 0000000000..6602151a7e --- /dev/null +++ b/services/kilo-chat/src/auth-internal.ts @@ -0,0 +1,37 @@ +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) => { + 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); + } + + const apiKey = c.req.header('x-internal-api-key'); + if (!apiKey) return c.json({ error: 'Forbidden' }, 403); + 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..4d94498033 --- /dev/null +++ b/services/kilo-chat/src/routes/internal.ts @@ -0,0 +1,56 @@ +import type { Hono } from 'hono'; +import type { ContentfulStatusCode } from 'hono/utils/http-status'; +import { z } from 'zod'; +import type { AuthContext } from '../auth'; +import { logger } from '../util/logger'; +import { postMessageAsUser } from '../services/post-message-as-user'; + +const postMessageAsUserBodySchema = z.object({ + userId: z.string().min(1), + sandboxId: z.string().min(1), + message: z.string().min(1), + source: z.string().min(1).max(64), + autoCreateConversation: z.boolean().optional(), + correlation: z + .object({ + triggerId: z.string().optional(), + webhookRequestId: z.string().optional(), + reason: z.string().optional(), + }) + .optional(), +}); + +/** + * 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 = await c.req.json().catch(() => null); + const parsed = postMessageAsUserBodySchema.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/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", + }, ], } From 523dc79379349f2a9663e06816d145aba2fbb4d8 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Thu, 28 May 2026 15:25:37 -0700 Subject: [PATCH 02/20] fix(install): address local review findings --- .../src/lib/kiloclaw/install-sources.test.ts | 21 +++++++++++++++++++ apps/web/src/lib/kiloclaw/install-sources.ts | 6 +++--- apps/web/src/lib/kiloclaw/install.test.ts | 13 ++++++++++++ apps/web/src/lib/kiloclaw/install.ts | 12 +++++++++++ services/kilo-chat/src/auth-internal.ts | 7 +++++-- 5 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/lib/kiloclaw/install-sources.test.ts 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 index 8887ad3b41..08b50af35d 100644 --- a/apps/web/src/lib/kiloclaw/install-sources.ts +++ b/apps/web/src/lib/kiloclaw/install-sources.ts @@ -9,8 +9,8 @@ export const INSTALL_SOURCES = { export type InstallSource = keyof typeof INSTALL_SOURCES; -export const INSTALL_SOURCE_KEYS = Object.keys(INSTALL_SOURCES) as InstallSource[]; - export function isInstallSource(value: string): value is InstallSource { - return value in INSTALL_SOURCES; + // 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 index 55690c6314..f7d3e68933 100644 --- a/apps/web/src/lib/kiloclaw/install.test.ts +++ b/apps/web/src/lib/kiloclaw/install.test.ts @@ -178,6 +178,19 @@ describe('fetchInstallPayload', () => { await expect(fetchInstallPayload('byte', 'deep-research')).rejects.toThrow(); }); + 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('throws when CLAWBYTE_SIGNING_PUBLIC_KEY is unset', async () => { const saved = process.env.CLAWBYTE_SIGNING_PUBLIC_KEY; delete process.env.CLAWBYTE_SIGNING_PUBLIC_KEY; diff --git a/apps/web/src/lib/kiloclaw/install.ts b/apps/web/src/lib/kiloclaw/install.ts index 094237156d..00efa8b06b 100644 --- a/apps/web/src/lib/kiloclaw/install.ts +++ b/apps/web/src/lib/kiloclaw/install.ts @@ -136,5 +136,17 @@ export async function fetchInstallPayload( 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/services/kilo-chat/src/auth-internal.ts b/services/kilo-chat/src/auth-internal.ts index 6602151a7e..6f846aa2cf 100644 --- a/services/kilo-chat/src/auth-internal.ts +++ b/services/kilo-chat/src/auth-internal.ts @@ -16,6 +16,11 @@ 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(); @@ -28,8 +33,6 @@ export const internalApiMiddleware = createMiddleware<{ return c.json({ error: 'Server configuration error' }, 500); } - const apiKey = c.req.header('x-internal-api-key'); - if (!apiKey) return c.json({ error: 'Forbidden' }, 403); if (!timingSafeEqual(apiKey, secret)) return c.json({ error: 'Forbidden' }, 403); logger.setTags({ source: 'internal-api' }); From 35c2c23d6c5897900d68f6e18c89d949f2de03ec Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Thu, 28 May 2026 15:35:18 -0700 Subject: [PATCH 03/20] =?UTF-8?q?wip:=20cloud=20=E2=86=92=20kilo-chat=20HT?= =?UTF-8?q?TP=20client=20for=20internal=20post-message-as-user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kilo-chat-internal-client.test.ts | 127 ++++++++++++++++++ .../lib/kiloclaw/kilo-chat-internal-client.ts | 100 ++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 apps/web/src/lib/kiloclaw/kilo-chat-internal-client.test.ts create mode 100644 apps/web/src/lib/kiloclaw/kilo-chat-internal-client.ts 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..7b3a68a726 --- /dev/null +++ b/apps/web/src/lib/kiloclaw/kilo-chat-internal-client.test.ts @@ -0,0 +1,127 @@ +// 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://kilo-chat.test.example.com'; + +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://kilo-chat.test.example.com/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; + } + }); +}); 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..232fd6adaf --- /dev/null +++ b/apps/web/src/lib/kiloclaw/kilo-chat-internal-client.ts @@ -0,0 +1,100 @@ +import 'server-only'; +import { z } from 'zod'; +import type { PostMessageAsUserParams, PostMessageAsUserResult } from '@kilocode/kilo-chat'; +import { INTERNAL_API_SECRET } from '@/lib/config.server'; + +/** + * 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. + */ + +const okSchema = z.object({ + ok: z.literal(true), + conversationId: z.string(), + messageId: z.string(), + conversationCreated: z.boolean(), +}); + +const errSchema = z.object({ + ok: z.literal(false), + code: z.enum(['invalid_request', 'no_conversation', 'forbidden', 'internal']), + error: z.string(), +}); + +const responseSchema = z.discriminatedUnion('ok', [okSchema, errSchema]); + +function getKiloChatBaseUrl(): string { + // We deliberately read process.env directly here rather than importing + // KILO_CHAT_URL from `@/lib/constants` — the constants file marks it as a + // *required* env var at import time, which causes test setups to crash if + // the var isn't set. This server-only client should fail loudly only when + // it's 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' + ); + } + 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`; + const 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', + }); + + // 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 = responseSchema.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; +} From 946301a0875f12af4eea8f1fb334c1fb4eab8ced Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Thu, 28 May 2026 15:45:26 -0700 Subject: [PATCH 04/20] fix(install): address local-review findings on the WIP install flow --- .../install/[source]/[slug]/InstallClient.tsx | 27 ++++-- apps/web/src/lib/kiloclaw/install.test.ts | 31 ++++-- apps/web/src/lib/kiloclaw/install.ts | 96 +++++++++++++++++-- .../kilo-chat-internal-client.test.ts | 29 ++++++ .../lib/kiloclaw/kilo-chat-internal-client.ts | 66 +++++++------ packages/kilo-chat/src/index.ts | 5 + packages/kilo-chat/src/rpc-types.ts | 24 +++++ 7 files changed, 228 insertions(+), 50 deletions(-) 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 index 3aeaeafa22..904ffeb42b 100644 --- a/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx +++ b/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx @@ -1,7 +1,6 @@ 'use client'; import Link from 'next/link'; -import { Loader2 } from 'lucide-react'; import type { InstallPayload } from '@/lib/kiloclaw/install'; import type { InstallSource } from '@/lib/kiloclaw/install-sources'; @@ -11,21 +10,33 @@ type InstallClientProps = { payload: InstallPayload; }; +/** + * Preview-only shell. The signed payload has been fetched and verified + * server-side; this page shows the user what's about to be installed. + * Dispatch is intentionally not wired yet — the install mutation lands + * in the next slice on this branch and will replace the disabled CTA + * below with a real click-to-install button. Until then, the route + * deliberately does no install work; visiting it is safe. + */ export function InstallClient({ source, sourceLabel, payload }: InstallClientProps) { return (
- -

- Installing {sourceLabel} -

+

{sourceLabel} preview

{payload.title}

{payload.tagline ? (

{payload.tagline}

) : null}

{payload.description}

-

- Install dispatch wiring is pending — this page currently renders the preview only. Source:{' '} - {source} · Slug: {payload.slug} + +

+ Source: {source} · Slug: {payload.slug}

Cancel diff --git a/apps/web/src/lib/kiloclaw/install.test.ts b/apps/web/src/lib/kiloclaw/install.test.ts index f7d3e68933..5b47b689aa 100644 --- a/apps/web/src/lib/kiloclaw/install.test.ts +++ b/apps/web/src/lib/kiloclaw/install.test.ts @@ -191,21 +191,40 @@ describe('fetchInstallPayload', () => { expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('slug mismatch')); }); - it('throws when CLAWBYTE_SIGNING_PUBLIC_KEY is unset', async () => { + 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)); - jest.spyOn(console, 'error').mockImplementation(() => {}); + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - // getPublicKey throws inside verifySignedPayload; the throw propagates - // out of fetchInstallPayload since we don't catch it there. - await expect(fetchInstallPayload('byte', 'deep-research')).rejects.toThrow( - /CLAWBYTE_SIGNING_PUBLIC_KEY is not configured/ + 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 index 00efa8b06b..593d6b6d8e 100644 --- a/apps/web/src/lib/kiloclaw/install.ts +++ b/apps/web/src/lib/kiloclaw/install.ts @@ -18,6 +18,14 @@ const SUPPORTED_SIGNATURE_VERSION = 1; // 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; + const installPayloadSchema = z.object({ slug: z.string().min(1).max(200), title: z.string().max(500), @@ -36,15 +44,15 @@ const installPayloadSchema = z.object({ export type InstallPayload = z.infer; -function getPublicKey(): crypto.KeyObject { +function getPublicKey(): crypto.KeyObject | null { const raw = process.env.CLAWBYTE_SIGNING_PUBLIC_KEY; - if (!raw) { - throw new Error( - 'CLAWBYTE_SIGNING_PUBLIC_KEY is not configured — install payloads cannot be verified' - ); - } + if (!raw) return null; const pem = raw.replace(/\\n/g, '\n').trim(); - return crypto.createPublicKey({ key: pem, format: 'pem' }); + try { + return crypto.createPublicKey({ key: pem, format: 'pem' }); + } catch { + return null; + } } function deriveKeyId(publicKey: crypto.KeyObject): string { @@ -91,6 +99,13 @@ function verifySignedPayload(payload: InstallPayload): VerifyOk | VerifyErr { } 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 { @@ -109,6 +124,43 @@ function verifySignedPayload(payload: InstallPayload): VerifyOk | VerifyErr { 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) return await res.text(); + 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 @@ -121,7 +173,35 @@ export async function fetchInstallPayload( throw new Error(`fetchInstallPayload(${source}, ${slug}): ${res.status} ${res.statusText}`); } - const json = await res.json(); + // 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)}` + ); + } const payload = installPayloadSchema.parse(json); const verify = verifySignedPayload(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 index 7b3a68a726..583eb5d1aa 100644 --- a/apps/web/src/lib/kiloclaw/kilo-chat-internal-client.test.ts +++ b/apps/web/src/lib/kiloclaw/kilo-chat-internal-client.test.ts @@ -124,4 +124,33 @@ describe('postMessageAsUser (cloud → kilo-chat internal HTTP)', () => { 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 index 232fd6adaf..54d7f1b98f 100644 --- a/apps/web/src/lib/kiloclaw/kilo-chat-internal-client.ts +++ b/apps/web/src/lib/kiloclaw/kilo-chat-internal-client.ts @@ -1,8 +1,17 @@ import 'server-only'; -import { z } from 'zod'; -import type { PostMessageAsUserParams, PostMessageAsUserResult } from '@kilocode/kilo-chat'; +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. * @@ -19,21 +28,6 @@ import { INTERNAL_API_SECRET } from '@/lib/config.server'; * binding. Already used by other cloud → service integrations. */ -const okSchema = z.object({ - ok: z.literal(true), - conversationId: z.string(), - messageId: z.string(), - conversationCreated: z.boolean(), -}); - -const errSchema = z.object({ - ok: z.literal(false), - code: z.enum(['invalid_request', 'no_conversation', 'forbidden', 'internal']), - error: z.string(), -}); - -const responseSchema = z.discriminatedUnion('ok', [okSchema, errSchema]); - function getKiloChatBaseUrl(): string { // We deliberately read process.env directly here rather than importing // KILO_CHAT_URL from `@/lib/constants` — the constants file marks it as a @@ -59,16 +53,32 @@ export async function postMessageAsUser( } const url = `${getKiloChatBaseUrl()}/internal/v1/post-message-as-user`; - const 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', - }); + 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', + 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, @@ -83,7 +93,7 @@ export async function postMessageAsUser( ); } - const parsed = responseSchema.safeParse(body); + const parsed = postMessageAsUserResultSchema.safeParse(body); if (!parsed.success) { // Most likely: 403 from `internalApiMiddleware` before reaching the // route handler, which returns `{ error: 'Forbidden' }`. Surface that diff --git a/packages/kilo-chat/src/index.ts b/packages/kilo-chat/src/index.ts index a55faa4199..c73e8819ba 100644 --- a/packages/kilo-chat/src/index.ts +++ b/packages/kilo-chat/src/index.ts @@ -21,5 +21,10 @@ export type { KiloChatEvent, KiloChatEventName, KiloChatEventOf } from './events export * from './schemas'; export * from './webhook-schemas'; export type * from './rpc-types'; +export { + postMessageAsUserOkSchema, + postMessageAsUserErrSchema, + postMessageAsUserResultSchema, +} 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..397ca2eb44 100644 --- a/packages/kilo-chat/src/rpc-types.ts +++ b/packages/kilo-chat/src/rpc-types.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + // Cross-service RPC contracts exposed by the kilo-chat WorkerEntrypoint. // // Producer: services/kilo-chat/src/index.ts (KiloChatService) @@ -49,3 +51,25 @@ export type PostMessageAsUserErr = { }; export type PostMessageAsUserResult = PostMessageAsUserOk | PostMessageAsUserErr; + +// Runtime schemas for the same shapes, so HTTP callers (e.g. cloud's +// Next.js app) can Zod-validate responses against one source of truth +// shared with the producer. Keep `z.infer` aligned with the types above — +// if you add a field or error code here, mirror it in the type union. +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, +]); From b1ea1324a4da2bb4f775a8dc928d34d50a8f33e0 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Thu, 28 May 2026 16:00:02 -0700 Subject: [PATCH 05/20] wip: installFromSource tRPC mutation + dispatch logic --- .../src/lib/kiloclaw/install-dispatch.test.ts | 206 ++++++++++++++++++ apps/web/src/lib/kiloclaw/install-dispatch.ts | 145 ++++++++++++ apps/web/src/lib/kiloclaw/install-sources.ts | 9 + apps/web/src/routers/kiloclaw-router.ts | 17 ++ 4 files changed, 377 insertions(+) create mode 100644 apps/web/src/lib/kiloclaw/install-dispatch.test.ts create mode 100644 apps/web/src/lib/kiloclaw/install-dispatch.ts 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..d857514ece --- /dev/null +++ b/apps/web/src/lib/kiloclaw/install-dispatch.test.ts @@ -0,0 +1,206 @@ +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>; + +function makeDeps( + overrides: Partial<{ + fetchInstallPayload: DispatchInstallFromSourceDeps['fetchInstallPayload']; + getActiveInstance: DispatchInstallFromSourceDeps['getActiveInstance']; + postMessageAsUser: DispatchInstallFromSourceDeps['postMessageAsUser']; + }> = {} +): DispatchInstallFromSourceDeps { + return { + fetchInstallPayload: overrides.fetchInstallPayload ?? (async () => VALID_PAYLOAD), + getActiveInstance: overrides.getActiveInstance ?? (async () => ACTIVE_INSTANCE), + 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' }; + +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, + }); + + expect(fetchSpy).toHaveBeenCalledWith('byte', 'deep-research'); + expect(instanceSpy).toHaveBeenCalledWith('user-1'); + expect(dispatchSpy).toHaveBeenCalledWith({ + userId: 'user-1', + sandboxId: 'sb-1', + message: 'Research [topic] for me.', + source: 'install', + autoCreateConversation: true, + 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('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('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..791d840c3c --- /dev/null +++ b/apps/web/src/lib/kiloclaw/install-dispatch.ts @@ -0,0 +1,145 @@ +import 'server-only'; +import { TRPCError } from '@trpc/server'; +import { fetchInstallPayload } from './install'; +import type { InstallSource } from './install-sources'; +import { getActiveInstance } from './instance-registry'; +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 should drop a `pending_install` cookie and + * redirect to `/claw/new`; the chat page consumes the cookie after + * provisioning completes. + * + * 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; +}; + +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; + postMessageAsUser: typeof postMessageAsUser; +}; + +const defaultDeps: DispatchInstallFromSourceDeps = { + fetchInstallPayload, + getActiveInstance, + postMessageAsUser, +}; + +export async function dispatchInstallFromSource( + args: DispatchInstallFromSourceArgs, + deps: DispatchInstallFromSourceDeps = defaultDeps +): Promise { + const { userId, source, slug } = args; + + const payload = await deps.fetchInstallPayload(source, slug); + if (!payload) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'This install link is not available.', + }); + } + + const instance = await deps.getActiveInstance(userId); + if (!instance) { + // Don't dispatch yet — the user has no instance to deliver into. The + // client surfaces this as the "drop pending_install cookie + redirect + // to /claw/new" flow. Provisioning happens off the cookie; on chat- + // page load, the cookie triggers a re-call of this mutation. + return { ok: false, code: 'no_instance' }; + } + + const dispatchedAt = new Date().toISOString(); + const result = await deps.postMessageAsUser({ + userId, + sandboxId: instance.sandboxId, + message: payload.prompt, + source: 'install', + autoCreateConversation: true, + correlation: { reason: `clawbyte:${slug}` }, + }); + + 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.ts b/apps/web/src/lib/kiloclaw/install-sources.ts index 08b50af35d..2dc2368ec1 100644 --- a/apps/web/src/lib/kiloclaw/install-sources.ts +++ b/apps/web/src/lib/kiloclaw/install-sources.ts @@ -9,6 +9,15 @@ export const INSTALL_SOURCES = { 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. diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index 24daca1c8b..4374218530 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 { @@ -3030,6 +3032,21 @@ 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), + }) + ) + .mutation(async ({ ctx, input }) => { + return await dispatchInstallFromSource({ + userId: ctx.user.id, + source: input.source, + slug: input.slug, + }); + }), + getMorningBriefingStatus: clawAccessProcedure.query(async ({ ctx }) => { const instance = await getActiveInstance(ctx.user.id); const client = new KiloClawInternalClient(); From 7ba4ada83e56941589a7187f0b496ef3c6fc2a89 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Thu, 28 May 2026 16:38:31 -0700 Subject: [PATCH 06/20] fix(install): runtime sandbox id + shared params schema --- .../src/lib/kiloclaw/install-dispatch.test.ts | 66 +++++++++++++++++-- apps/web/src/lib/kiloclaw/install-dispatch.ts | 45 ++++++++++++- packages/kilo-chat/src/index.ts | 2 + packages/kilo-chat/src/rpc-types.ts | 19 ++++++ services/kilo-chat/src/routes/internal.ts | 19 +----- 5 files changed, 126 insertions(+), 25 deletions(-) diff --git a/apps/web/src/lib/kiloclaw/install-dispatch.test.ts b/apps/web/src/lib/kiloclaw/install-dispatch.test.ts index d857514ece..36640a27a6 100644 --- a/apps/web/src/lib/kiloclaw/install-dispatch.test.ts +++ b/apps/web/src/lib/kiloclaw/install-dispatch.test.ts @@ -21,16 +21,15 @@ const ACTIVE_INSTANCE = { sandboxId: 'sb-1', } as unknown as Awaited>; +const RUNTIME_SANDBOX_ID = 'ki_runtime_sandbox'; + function makeDeps( - overrides: Partial<{ - fetchInstallPayload: DispatchInstallFromSourceDeps['fetchInstallPayload']; - getActiveInstance: DispatchInstallFromSourceDeps['getActiveInstance']; - postMessageAsUser: DispatchInstallFromSourceDeps['postMessageAsUser']; - }> = {} + overrides: Partial = {} ): DispatchInstallFromSourceDeps { return { fetchInstallPayload: overrides.fetchInstallPayload ?? (async () => VALID_PAYLOAD), getActiveInstance: overrides.getActiveInstance ?? (async () => ACTIVE_INSTANCE), + resolveRuntimeSandboxId: overrides.resolveRuntimeSandboxId ?? (async () => RUNTIME_SANDBOX_ID), postMessageAsUser: overrides.postMessageAsUser ?? (async () => @@ -84,7 +83,7 @@ describe('dispatchInstallFromSource', () => { expect(instanceSpy).toHaveBeenCalledWith('user-1'); expect(dispatchSpy).toHaveBeenCalledWith({ userId: 'user-1', - sandboxId: 'sb-1', + sandboxId: RUNTIME_SANDBOX_ID, // NOT the registry row's sandboxId message: 'Research [topic] for me.', source: 'install', autoCreateConversation: true, @@ -128,6 +127,61 @@ describe('dispatchInstallFromSource', () => { 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( diff --git a/apps/web/src/lib/kiloclaw/install-dispatch.ts b/apps/web/src/lib/kiloclaw/install-dispatch.ts index 791d840c3c..38043dfbfb 100644 --- a/apps/web/src/lib/kiloclaw/install-dispatch.ts +++ b/apps/web/src/lib/kiloclaw/install-dispatch.ts @@ -2,7 +2,12 @@ import 'server-only'; import { TRPCError } from '@trpc/server'; import { fetchInstallPayload } from './install'; import type { InstallSource } from './install-sources'; -import { getActiveInstance } from './instance-registry'; +import { + getActiveInstance, + workerInstanceId, + type ActiveKiloClawInstance, +} from './instance-registry'; +import { KiloClawInternalClient } from './kiloclaw-internal-client'; import { postMessageAsUser } from './kilo-chat-internal-client'; /** @@ -48,12 +53,37 @@ export type DispatchInstallFromSourceResult = export type DispatchInstallFromSourceDeps = { fetchInstallPayload: typeof fetchInstallPayload; getActiveInstance: typeof getActiveInstance; + resolveRuntimeSandboxId: ( + userId: string, + instance: ActiveKiloClawInstance + ) => Promise; 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, postMessageAsUser, }; @@ -80,10 +110,21 @@ export async function dispatchInstallFromSource( return { ok: false, code: 'no_instance' }; } + // 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(); const result = await deps.postMessageAsUser({ userId, - sandboxId: instance.sandboxId, + sandboxId: runtimeSandboxId, message: payload.prompt, source: 'install', autoCreateConversation: true, diff --git a/packages/kilo-chat/src/index.ts b/packages/kilo-chat/src/index.ts index c73e8819ba..f28b7466d8 100644 --- a/packages/kilo-chat/src/index.ts +++ b/packages/kilo-chat/src/index.ts @@ -25,6 +25,8 @@ 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 397ca2eb44..6f015b59af 100644 --- a/packages/kilo-chat/src/rpc-types.ts +++ b/packages/kilo-chat/src/rpc-types.ts @@ -52,6 +52,25 @@ export type PostMessageAsUserErr = { export type PostMessageAsUserResult = PostMessageAsUserOk | PostMessageAsUserErr; +// Runtime schema for `PostMessageAsUserParams` so HTTP callers and the +// HTTP route share one source of truth (Worker RPC callers rely on the +// declared types). The bounds (`min(1)`, `max(64)` on source, etc.) are +// HTTP-boundary safety and apply uniformly to both call paths. +export const postMessageAsUserCorrelationSchema = z.object({ + triggerId: z.string().max(200).optional(), + webhookRequestId: z.string().max(200).optional(), + reason: z.string().max(200).optional(), +}); + +export const postMessageAsUserParamsSchema = z.object({ + userId: z.string().min(1).max(200), + sandboxId: z.string().min(1).max(200), + message: z.string().min(1), + source: z.string().min(1).max(64), + autoCreateConversation: z.boolean().optional(), + correlation: postMessageAsUserCorrelationSchema.optional(), +}); + // Runtime schemas for the same shapes, so HTTP callers (e.g. cloud's // Next.js app) can Zod-validate responses against one source of truth // shared with the producer. Keep `z.infer` aligned with the types above — diff --git a/services/kilo-chat/src/routes/internal.ts b/services/kilo-chat/src/routes/internal.ts index 4d94498033..b2f4c06a3e 100644 --- a/services/kilo-chat/src/routes/internal.ts +++ b/services/kilo-chat/src/routes/internal.ts @@ -1,25 +1,10 @@ import type { Hono } from 'hono'; import type { ContentfulStatusCode } from 'hono/utils/http-status'; -import { z } from 'zod'; +import { postMessageAsUserParamsSchema } from '@kilocode/kilo-chat'; import type { AuthContext } from '../auth'; import { logger } from '../util/logger'; import { postMessageAsUser } from '../services/post-message-as-user'; -const postMessageAsUserBodySchema = z.object({ - userId: z.string().min(1), - sandboxId: z.string().min(1), - message: z.string().min(1), - source: z.string().min(1).max(64), - autoCreateConversation: z.boolean().optional(), - correlation: z - .object({ - triggerId: z.string().optional(), - webhookRequestId: z.string().optional(), - reason: z.string().optional(), - }) - .optional(), -}); - /** * HTTP wrapper around the `postMessageAsUser` RPC primitive, for callers * that don't run on Cloudflare Workers (e.g. the Next.js cloud web app). @@ -28,7 +13,7 @@ const postMessageAsUserBodySchema = z.object({ export function registerInternalRoutes(app: Hono<{ Bindings: Env; Variables: AuthContext }>) { app.post('/internal/v1/post-message-as-user', async c => { const raw = await c.req.json().catch(() => null); - const parsed = postMessageAsUserBodySchema.safeParse(raw); + const parsed = postMessageAsUserParamsSchema.safeParse(raw); if (!parsed.success) { return c.json({ ok: false, code: 'invalid_request', error: parsed.error.message }, 400); } From 800b86225ea164f2072a97b96726a20c90a284b3 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Thu, 28 May 2026 16:48:30 -0700 Subject: [PATCH 07/20] =?UTF-8?q?refactor(install):=20one-click=20install?= =?UTF-8?q?=20=E2=80=94=20server-component=20dispatch=20only=20the=20plan?= =?UTF-8?q?=20wandered,=20bring=20it=20back=20inline=20with=20version=201?= =?UTF-8?q?=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../install/[source]/[slug]/InstallClient.tsx | 46 ------------ .../claw/install/[source]/[slug]/page.tsx | 72 ++++++++++++++++--- 2 files changed, 62 insertions(+), 56 deletions(-) delete mode 100644 apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx 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 deleted file mode 100644 index 904ffeb42b..0000000000 --- a/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import type { InstallPayload } from '@/lib/kiloclaw/install'; -import type { InstallSource } from '@/lib/kiloclaw/install-sources'; - -type InstallClientProps = { - source: InstallSource; - sourceLabel: string; - payload: InstallPayload; -}; - -/** - * Preview-only shell. The signed payload has been fetched and verified - * server-side; this page shows the user what's about to be installed. - * Dispatch is intentionally not wired yet — the install mutation lands - * in the next slice on this branch and will replace the disabled CTA - * below with a real click-to-install button. Until then, the route - * deliberately does no install work; visiting it is safe. - */ -export function InstallClient({ source, sourceLabel, payload }: InstallClientProps) { - return ( -
-

{sourceLabel} preview

-

{payload.title}

- {payload.tagline ? ( -

{payload.tagline}

- ) : null} -

{payload.description}

- -

- Source: {source} · Slug: {payload.slug} -

- - Cancel - -
- ); -} 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 index b3febb0bad..8154b6aa95 100644 --- a/apps/web/src/app/(app)/claw/install/[source]/[slug]/page.tsx +++ b/apps/web/src/app/(app)/claw/install/[source]/[slug]/page.tsx @@ -1,21 +1,73 @@ -import { notFound } from 'next/navigation'; -import { fetchInstallPayload } from '@/lib/kiloclaw/install'; -import { INSTALL_SOURCES, isInstallSource } from '@/lib/kiloclaw/install-sources'; -import { InstallClient } from './InstallClient'; +import { cookies } from 'next/headers'; +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 { dispatchInstallFromSource } from '@/lib/kiloclaw/install-dispatch'; +import { isInstallSource } from '@/lib/kiloclaw/install-sources'; type InstallPageProps = { params: Promise<{ source: string; slug: string }>; }; +/** + * One-click install for a signed source payload (ClawByte today, more + * sources later). The user clicks "Try in KiloClaw" on kilo.ai, lands + * here, and the server dispatches the byte's prompt into their kiloclaw + * chat as a user-turn before redirecting to /claw/chat. + * + * No client component, no button, no second click — the install URL IS + * the click. If we ever want a "review the prompt before running it" + * gate, that lands on the chat/agent side later, not here. + * + * Auth + access: + * - Parent claw layout's getUserFromAuthOrRedirect bounces unauth users + * to sign-in (pathname preserved by callbackPath, so they return + * here after signing in). + * - requireKiloClawAccess throws TRPCError FORBIDDEN if the user has no + * active KiloClaw subscription/trial. + * + * Outcomes: + * - Verified payload + active instance → as-user dispatch + redirect to + * /claw/chat. + * - Verified payload + no instance yet → set pending_install cookie, + * redirect to /claw/new so provisioning can run; the chat page + * consumes the cookie once chat is ready. + * - Unknown source / unsigned byte / failed verification / slug mismatch + * → notFound() (404). All cases logged in detail by the fetcher. + */ export default async function InstallPage({ params }: InstallPageProps) { const { source, slug } = await params; - if (!isInstallSource(source)) notFound(); - const payload = await fetchInstallPayload(source, slug); - if (!payload) notFound(); + const user = await getUserFromAuthOrRedirect(); + await requireKiloClawAccess(user.id); + + let result: Awaited>; + try { + result = await dispatchInstallFromSource({ userId: user.id, source, slug }); + } catch (err) { + if (err instanceof TRPCError && err.code === 'NOT_FOUND') notFound(); + // Let everything else bubble to Next.js's error boundary so we don't + // mask a real misconfiguration as a missing byte. + throw err; + } + + if (result.ok) { + redirect('/claw/chat'); + } - return ( - - ); + // result.ok === false, code === 'no_instance' — the user is entitled to + // install but hasn't provisioned a kiloclaw yet (or the runtime sandbox + // isn't reporting yet). Stash the install intent in a cookie and send + // them through provisioning; the chat page picks it up afterwards. + const cookieStore = await cookies(); + cookieStore.set('pending_install', JSON.stringify({ source, slug }), { + path: '/claw', + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60, // 1h — generous enough for provisioning, short + // enough that a stale cookie isn't redispatched days later. + }); + redirect('/claw/new'); } From 98398959d876d3670cb1252ae3bba36320bb7c3f Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Mon, 1 Jun 2026 10:55:20 -0700 Subject: [PATCH 08/20] wip(install): preview + explicit-POST install flow, SSRF hardening --- .../install/[source]/[slug]/InstallClient.tsx | 91 +++++++++++++++++++ .../claw/install/[source]/[slug]/page.tsx | 81 +++++++---------- apps/web/src/lib/kiloclaw/install-dispatch.ts | 8 +- apps/web/src/lib/kiloclaw/install.test.ts | 25 ++++- apps/web/src/lib/kiloclaw/install.ts | 34 ++++++- 5 files changed, 185 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx 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..b9f9dbbfa1 --- /dev/null +++ b/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +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 type { InstallPayload } from '@/lib/kiloclaw/install'; +import type { InstallSource } from '@/lib/kiloclaw/install-sources'; + +type InstallClientProps = { + source: InstallSource; + sourceLabel: string; + payload: InstallPayload; +}; + +/** + * Install preview + explicit dispatch. The signed payload was fetched and + * verified server-side (and paid-access gated) in `page.tsx`; this shows the + * user what's about to be installed. + * + * Dispatch happens ONLY on an explicit Install 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 button 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 }); + setNavigating(true); + if (result.ok) { + router.push('/claw/chat'); + return; + } + // No active instance yet — remember the install intent and route through + // provisioning. The chat page consumes `pending_install` once the + // instance is ready (separate slice). Cookie is intentionally not + // httpOnly so the client-side consumer can read + clear it. + setPendingInstallCookie(source, payload.slug); + router.push('/claw/new'); + } catch (err) { + const message = + err instanceof TRPCClientError && err.data?.code === 'NOT_FOUND' + ? 'This install link is no longer available.' + : 'Could not install this byte. Please try again.'; + toast.error(message); + } + } + + const busy = install.isPending || navigating; + + return ( +
+

{sourceLabel}

+

{payload.title}

+ {payload.tagline ? ( +

{payload.tagline}

+ ) : null} +

{payload.description}

+ + + Cancel + +
+ ); +} + +/** + * Short-lived intent cookie read by the chat page after provisioning. Mirrors + * the attributes the old route handler used (path=/claw, SameSite=Lax, 1h). + */ +function setPendingInstallCookie(source: string, slug: string) { + const value = encodeURIComponent(JSON.stringify({ source, slug })); + const secure = window.location.protocol === 'https:' ? '; secure' : ''; + document.cookie = `pending_install=${value}; path=/claw; max-age=3600; samesite=lax${secure}`; +} 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 index 8154b6aa95..937b5f8035 100644 --- a/apps/web/src/app/(app)/claw/install/[source]/[slug]/page.tsx +++ b/apps/web/src/app/(app)/claw/install/[source]/[slug]/page.tsx @@ -1,73 +1,60 @@ -import { cookies } from 'next/headers'; 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 { dispatchInstallFromSource } from '@/lib/kiloclaw/install-dispatch'; -import { isInstallSource } from '@/lib/kiloclaw/install-sources'; +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 for a signed source payload (ClawByte today, more - * sources later). The user clicks "Try in KiloClaw" on kilo.ai, lands - * here, and the server dispatches the byte's prompt into their kiloclaw - * chat as a user-turn before redirecting to /claw/chat. + * 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). * - * No client component, no button, no second click — the install URL IS - * the click. If we ever want a "review the prompt before running it" - * gate, that lands on the chat/agent side later, not here. + * 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. * - * Auth + access: - * - Parent claw layout's getUserFromAuthOrRedirect bounces unauth users - * to sign-in (pathname preserved by callbackPath, so they return - * here after signing in). - * - requireKiloClawAccess throws TRPCError FORBIDDEN if the user has no - * active KiloClaw subscription/trial. - * - * Outcomes: - * - Verified payload + active instance → as-user dispatch + redirect to - * /claw/chat. - * - Verified payload + no instance yet → set pending_install cookie, - * redirect to /claw/new so provisioning can run; the chat page - * consumes the cookie once chat is ready. - * - Unknown source / unsigned byte / failed verification / slug mismatch - * → notFound() (404). All cases logged in detail by the fetcher. + * 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(); - await requireKiloClawAccess(user.id); - let result: Awaited>; try { - result = await dispatchInstallFromSource({ userId: user.id, source, slug }); + await requireKiloClawAccess(user.id); } catch (err) { - if (err instanceof TRPCError && err.code === 'NOT_FOUND') notFound(); - // Let everything else bubble to Next.js's error boundary so we don't - // mask a real misconfiguration as a missing byte. + // No active subscription/trial → don't pull the byte. Send them into the + // provisioning/subscribe flow. (Preserving the install intent across that + // flow lands with the `pending_install` cookie consumer slice.) + if (err instanceof TRPCError && err.code === 'FORBIDDEN') { + redirect('/claw/new'); + } throw err; } - if (result.ok) { - redirect('/claw/chat'); - } + const payload = await fetchInstallPayload(source, slug); + if (!payload) notFound(); - // result.ok === false, code === 'no_instance' — the user is entitled to - // install but hasn't provisioned a kiloclaw yet (or the runtime sandbox - // isn't reporting yet). Stash the install intent in a cookie and send - // them through provisioning; the chat page picks it up afterwards. - const cookieStore = await cookies(); - cookieStore.set('pending_install', JSON.stringify({ source, slug }), { - path: '/claw', - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - maxAge: 60 * 60, // 1h — generous enough for provisioning, short - // enough that a stale cookie isn't redispatched days later. - }); - redirect('/claw/new'); + return ( + + ); } diff --git a/apps/web/src/lib/kiloclaw/install-dispatch.ts b/apps/web/src/lib/kiloclaw/install-dispatch.ts index 38043dfbfb..bfaab614a0 100644 --- a/apps/web/src/lib/kiloclaw/install-dispatch.ts +++ b/apps/web/src/lib/kiloclaw/install-dispatch.ts @@ -122,13 +122,19 @@ export async function dispatchInstallFromSource( } 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', autoCreateConversation: true, - correlation: { reason: `clawbyte:${slug}` }, + correlation: { reason }, }); if (result.ok) { diff --git a/apps/web/src/lib/kiloclaw/install.test.ts b/apps/web/src/lib/kiloclaw/install.test.ts index 5b47b689aa..6e838804f2 100644 --- a/apps/web/src/lib/kiloclaw/install.test.ts +++ b/apps/web/src/lib/kiloclaw/install.test.ts @@ -3,7 +3,6 @@ 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 PRIVATE_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; const PUBLIC_PEM = publicKey.export({ type: 'spki', format: 'pem' }) as string; // Derive the matching kid the way the signer / verifier do. @@ -98,6 +97,18 @@ describe('fetchInstallPayload', () => { 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'); @@ -173,9 +184,17 @@ describe('fetchInstallPayload', () => { expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('in the future')); }); - it('rejects an unsigned payload via Zod parse', async () => { + 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)); - await expect(fetchInstallPayload('byte', 'deep-research')).rejects.toThrow(); + 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 () => { diff --git a/apps/web/src/lib/kiloclaw/install.ts b/apps/web/src/lib/kiloclaw/install.ts index 593d6b6d8e..5b3fa6bccc 100644 --- a/apps/web/src/lib/kiloclaw/install.ts +++ b/apps/web/src/lib/kiloclaw/install.ts @@ -1,5 +1,6 @@ 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'; /** @@ -30,7 +31,10 @@ const installPayloadSchema = z.object({ slug: z.string().min(1).max(200), title: z.string().max(500), description: z.string().max(2000), - prompt: z.string().min(1).max(32000), + // 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), tagline: z.string().max(500).optional(), category: z.string().max(100).optional(), tags: z.array(z.string().max(100)).max(50).optional(), @@ -166,7 +170,20 @@ export async function fetchInstallPayload( slug: string ): Promise { const url = INSTALL_SOURCES[source].urlTemplate.replace('{slug}', encodeURIComponent(slug)); - const res = await fetch(url, { next: { revalidate: 300 } }); + // `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 → + // attacker host; refusing to follow redirects closes that before the + // signature check even runs. A redirect now rejects (caught below). + let res: Response; + try { + res = await fetch(url, { next: { revalidate: 300 }, redirect: 'error' }); + } 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) { @@ -202,7 +219,18 @@ export async function fetchInstallPayload( `fetchInstallPayload(${source}, ${slug}): upstream returned invalid JSON: ${err instanceof Error ? err.message : String(err)}` ); } - const payload = installPayloadSchema.parse(json); + // 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) { From 8ed73a2be700b16f928f16744784767b39f45b23 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Mon, 1 Jun 2026 11:19:55 -0700 Subject: [PATCH 09/20] =?UTF-8?q?fix(install):=20address=20local=20review?= =?UTF-8?q?=20=E2=80=94=20sign-only=20payload=20+=20schema=20source-of-tru?= =?UTF-8?q?th?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../install/[source]/[slug]/InstallClient.tsx | 3 - apps/web/src/lib/kiloclaw/install.test.ts | 3 - apps/web/src/lib/kiloclaw/install.ts | 11 +++- packages/kilo-chat/src/rpc-types.ts | 62 ++++++------------- 4 files changed, 27 insertions(+), 52 deletions(-) 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 index b9f9dbbfa1..ca50bec16f 100644 --- a/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx +++ b/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx @@ -65,9 +65,6 @@ export function InstallClient({ source, sourceLabel, payload }: InstallClientPro

{sourceLabel}

{payload.title}

- {payload.tagline ? ( -

{payload.tagline}

- ) : null}

{payload.description}

); } - -/** - * Short-lived intent cookie read by the chat page after provisioning. Mirrors - * the attributes the old route handler used (path=/claw, SameSite=Lax, 1h). - */ -function setPendingInstallCookie(source: string, slug: string) { - const value = encodeURIComponent(JSON.stringify({ source, slug })); - const secure = window.location.protocol === 'https:' ? '; secure' : ''; - document.cookie = `pending_install=${value}; path=/claw; max-age=3600; samesite=lax${secure}`; -} 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 index 937b5f8035..58c8bdb34a 100644 --- a/apps/web/src/app/(app)/claw/install/[source]/[slug]/page.tsx +++ b/apps/web/src/app/(app)/claw/install/[source]/[slug]/page.tsx @@ -42,9 +42,10 @@ export default async function InstallPage({ params }: InstallPageProps) { try { await requireKiloClawAccess(user.id); } catch (err) { - // No active subscription/trial → don't pull the byte. Send them into the - // provisioning/subscribe flow. (Preserving the install intent across that - // flow lands with the `pending_install` cookie consumer slice.) + // 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'); } diff --git a/apps/web/src/lib/kiloclaw/install-dispatch.ts b/apps/web/src/lib/kiloclaw/install-dispatch.ts index bfaab614a0..c92a4c6c93 100644 --- a/apps/web/src/lib/kiloclaw/install-dispatch.ts +++ b/apps/web/src/lib/kiloclaw/install-dispatch.ts @@ -20,9 +20,9 @@ import { postMessageAsUser } from './kilo-chat-internal-client'; * - `{ 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 should drop a `pending_install` cookie and - * redirect to `/claw/new`; the chat page consumes the cookie after - * provisioning completes. + * 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, @@ -104,9 +104,8 @@ export async function dispatchInstallFromSource( const instance = await deps.getActiveInstance(userId); if (!instance) { // Don't dispatch yet — the user has no instance to deliver into. The - // client surfaces this as the "drop pending_install cookie + redirect - // to /claw/new" flow. Provisioning happens off the cookie; on chat- - // page load, the cookie triggers a re-call of this mutation. + // 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' }; } From cd0515f4b38e479e0d0bd4747b687b3d4f68f343 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Mon, 1 Jun 2026 12:06:18 -0700 Subject: [PATCH 11/20] add tests --- apps/web/src/routers/kiloclaw-router.test.ts | 116 +++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/apps/web/src/routers/kiloclaw-router.test.ts b/apps/web/src/routers/kiloclaw-router.test.ts index 8883efec56..0ea3537a71 100644 --- a/apps/web/src/routers/kiloclaw-router.test.ts +++ b/apps/web/src/routers/kiloclaw-router.test.ts @@ -132,6 +132,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; getNavState: () => Promise<{ hasActiveInstance: boolean }>; @@ -202,6 +210,16 @@ 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; + }): Promise< + | { ok: true; conversationId: string; messageId: string; conversationCreated: boolean } + | { ok: false; code: 'no_instance' } + >; }; const kiloclawClientMock = jest.requireMock( '@/lib/kiloclaw/kiloclaw-internal-client' @@ -1197,3 +1215,101 @@ 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' }) + ).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' }); + + 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', + }); + }); + + 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' }); + + 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' }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(installDispatchMock.__dispatchInstallFromSource).not.toHaveBeenCalled(); + }); +}); From d1a478c4e5832be2ca7c4e8004a1c0a2b157335e Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Mon, 1 Jun 2026 13:15:07 -0700 Subject: [PATCH 12/20] address possible message validation drift --- packages/kilo-chat/src/rpc-types.ts | 5 ++++- packages/kilo-chat/src/schemas.ts | 11 ++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/kilo-chat/src/rpc-types.ts b/packages/kilo-chat/src/rpc-types.ts index 17fa6f3ffb..d4f9a3d75a 100644 --- a/packages/kilo-chat/src/rpc-types.ts +++ b/packages/kilo-chat/src/rpc-types.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { messageTextSchema } from './schemas'; // Cross-service RPC contracts exposed by the kilo-chat WorkerEntrypoint. // @@ -34,7 +35,9 @@ export const postMessageAsUserCorrelationSchema = z.object({ export const postMessageAsUserParamsSchema = z.object({ userId: z.string().min(1).max(200), sandboxId: z.string().min(1).max(200), - message: z.string().min(1), + // 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. diff --git a/packages/kilo-chat/src/schemas.ts b/packages/kilo-chat/src/schemas.ts index 35d7cb0c18..1efa357d83 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, ≤ MESSAGE_TEXT_MAX_CHARS — and 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 = { From fa5c81ff29ec0eb84d3b7db219350828cf9382dd Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Mon, 1 Jun 2026 14:09:38 -0700 Subject: [PATCH 13/20] feat(install): confirmation-card UX + dedicated install conversation --- .../install/[source]/[slug]/InstallClient.tsx | 77 ++-- .../components/icons/KiloClawbsterIcon.tsx | 342 ++++++++++++++++++ .../src/lib/kiloclaw/install-dispatch.test.ts | 1 + apps/web/src/lib/kiloclaw/install-dispatch.ts | 3 + packages/kilo-chat/src/rpc-types.ts | 5 + .../__tests__/post-message-as-user.test.ts | 29 ++ .../src/services/post-message-as-user.ts | 18 +- 7 files changed, 451 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/components/icons/KiloClawbsterIcon.tsx 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 index 036029f07d..5f03551af6 100644 --- a/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx +++ b/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState } from 'react'; -import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useMutation } from '@tanstack/react-query'; import { Loader2 } from 'lucide-react'; @@ -9,6 +8,16 @@ 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'; @@ -19,19 +28,20 @@ type InstallClientProps = { }; /** - * Install preview + explicit dispatch. The signed payload was fetched and - * verified server-side (and paid-access gated) in `page.tsx`; this shows the - * user what's about to be installed. + * 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 Install click, which fires the - * `installFromSource` POST mutation — never on GET render. A cross-site POST + * 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 button disabled through the post-success redirect + // `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()); @@ -41,13 +51,14 @@ export function InstallClient({ source, sourceLabel, payload }: InstallClientPro const result = await install.mutateAsync({ source, slug: payload.slug }); setNavigating(true); if (result.ok) { - router.push('/claw/chat'); + // 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 — send them to set one up. We intentionally do + // 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. - // Re-running the install link is cheap and predictable. router.push('/claw/new'); } catch (err) { const message = @@ -61,17 +72,41 @@ export function InstallClient({ source, sourceLabel, payload }: InstallClientPro const busy = install.isPending || navigating; return ( -
-

{sourceLabel}

-

{payload.title}

-

{payload.description}

- - - Cancel - +
+ + + + + {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/components/icons/KiloClawbsterIcon.tsx b/apps/web/src/components/icons/KiloClawbsterIcon.tsx new file mode 100644 index 0000000000..9dee829dc8 --- /dev/null +++ b/apps/web/src/components/icons/KiloClawbsterIcon.tsx @@ -0,0 +1,342 @@ +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 index 36640a27a6..f49ab9e656 100644 --- a/apps/web/src/lib/kiloclaw/install-dispatch.test.ts +++ b/apps/web/src/lib/kiloclaw/install-dispatch.test.ts @@ -87,6 +87,7 @@ describe('dispatchInstallFromSource', () => { message: 'Research [topic] for me.', source: 'install', autoCreateConversation: true, + forceNewConversation: true, // each install gets its own conversation correlation: { reason: 'clawbyte:deep-research' }, }); diff --git a/apps/web/src/lib/kiloclaw/install-dispatch.ts b/apps/web/src/lib/kiloclaw/install-dispatch.ts index c92a4c6c93..637175cafa 100644 --- a/apps/web/src/lib/kiloclaw/install-dispatch.ts +++ b/apps/web/src/lib/kiloclaw/install-dispatch.ts @@ -133,6 +133,9 @@ export async function dispatchInstallFromSource( message: payload.prompt, source: 'install', autoCreateConversation: true, + // Each install gets its own dedicated conversation rather than appending + // to whatever the user last chatted in. + forceNewConversation: true, correlation: { reason }, }); diff --git a/packages/kilo-chat/src/rpc-types.ts b/packages/kilo-chat/src/rpc-types.ts index d4f9a3d75a..135e365d7d 100644 --- a/packages/kilo-chat/src/rpc-types.ts +++ b/packages/kilo-chat/src/rpc-types.ts @@ -45,6 +45,11 @@ export const postMessageAsUserParamsSchema = z.object({ // Default true. Pass false to fail the call if the user has never opened // a chat with this bot. 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(), }); 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/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', { From 30d0f99ee1c3a2c65f919c3fb16fe0e5d638fd07 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Mon, 1 Jun 2026 14:49:57 -0700 Subject: [PATCH 14/20] fix(install): bind dispatch to the reviewed payload signature --- .../install/[source]/[slug]/InstallClient.tsx | 20 ++++++++++++---- .../src/lib/kiloclaw/install-dispatch.test.ts | 24 ++++++++++++++++++- apps/web/src/lib/kiloclaw/install-dispatch.ts | 19 ++++++++++++++- apps/web/src/routers/kiloclaw-router.test.ts | 18 ++++++++++---- apps/web/src/routers/kiloclaw-router.ts | 4 ++++ 5 files changed, 74 insertions(+), 11 deletions(-) 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 index 5f03551af6..e558686ed6 100644 --- a/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx +++ b/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx @@ -48,7 +48,13 @@ export function InstallClient({ source, sourceLabel, payload }: InstallClientPro async function onInstall() { try { - const result = await install.mutateAsync({ source, slug: payload.slug }); + 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 @@ -61,10 +67,14 @@ export function InstallClient({ source, sourceLabel, payload }: InstallClientPro // flow; the user finishes setup, then installs again from the byte page. router.push('/claw/new'); } catch (err) { - const message = - err instanceof TRPCClientError && err.data?.code === 'NOT_FOUND' - ? 'This install link is no longer available.' - : 'Could not install this byte. Please try again.'; + 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); } } diff --git a/apps/web/src/lib/kiloclaw/install-dispatch.test.ts b/apps/web/src/lib/kiloclaw/install-dispatch.test.ts index f49ab9e656..a67efc37e8 100644 --- a/apps/web/src/lib/kiloclaw/install-dispatch.test.ts +++ b/apps/web/src/lib/kiloclaw/install-dispatch.test.ts @@ -42,7 +42,12 @@ function makeDeps( }; } -const ARGS = { userId: 'user-1', source: 'byte' as const, slug: 'deep-research' }; +const ARGS = { + userId: 'user-1', + source: 'byte' as const, + slug: 'deep-research', + expectedSignature: VALID_PAYLOAD.signature, +}; describe('dispatchInstallFromSource', () => { afterEach(() => { @@ -114,6 +119,23 @@ describe('dispatchInstallFromSource', () => { ).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( diff --git a/apps/web/src/lib/kiloclaw/install-dispatch.ts b/apps/web/src/lib/kiloclaw/install-dispatch.ts index 637175cafa..e232ad746c 100644 --- a/apps/web/src/lib/kiloclaw/install-dispatch.ts +++ b/apps/web/src/lib/kiloclaw/install-dispatch.ts @@ -37,6 +37,12 @@ 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 = @@ -91,7 +97,7 @@ export async function dispatchInstallFromSource( args: DispatchInstallFromSourceArgs, deps: DispatchInstallFromSourceDeps = defaultDeps ): Promise { - const { userId, source, slug } = args; + const { userId, source, slug, expectedSignature } = args; const payload = await deps.fetchInstallPayload(source, slug); if (!payload) { @@ -101,6 +107,17 @@ export async function dispatchInstallFromSource( }); } + // 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 diff --git a/apps/web/src/routers/kiloclaw-router.test.ts b/apps/web/src/routers/kiloclaw-router.test.ts index 0ea3537a71..7fbf4bc310 100644 --- a/apps/web/src/routers/kiloclaw-router.test.ts +++ b/apps/web/src/routers/kiloclaw-router.test.ts @@ -216,6 +216,7 @@ let createCaller: (ctx: { user: Awaited> }) => installFromSource(input: { source: string; slug: string; + signature: string; }): Promise< | { ok: true; conversationId: string; messageId: string; conversationCreated: boolean } | { ok: false; code: 'no_instance' } @@ -1251,7 +1252,7 @@ describe('kiloclawRouter installFromSource', () => { const caller = createCaller({ user }); await expect( - caller.installFromSource({ source: 'byte', slug: 'deep-research' }) + caller.installFromSource({ source: 'byte', slug: 'deep-research', signature: 'sig' }) ).rejects.toMatchObject({ code: 'FORBIDDEN' }); expect(installDispatchMock.__dispatchInstallFromSource).not.toHaveBeenCalled(); }); @@ -1269,7 +1270,11 @@ describe('kiloclawRouter installFromSource', () => { }); const caller = createCaller({ user }); - const result = await caller.installFromSource({ source: 'byte', slug: 'deep-research' }); + const result = await caller.installFromSource({ + source: 'byte', + slug: 'deep-research', + signature: 'sig', + }); expect(result).toEqual({ ok: true, @@ -1281,6 +1286,7 @@ describe('kiloclawRouter installFromSource', () => { userId: user.id, source: 'byte', slug: 'deep-research', + expectedSignature: 'sig', }); }); @@ -1295,7 +1301,11 @@ describe('kiloclawRouter installFromSource', () => { }); const caller = createCaller({ user }); - const result = await caller.installFromSource({ source: 'byte', slug: 'deep-research' }); + const result = await caller.installFromSource({ + source: 'byte', + slug: 'deep-research', + signature: 'sig', + }); expect(result).toEqual({ ok: false, code: 'no_instance' }); }); @@ -1308,7 +1318,7 @@ describe('kiloclawRouter installFromSource', () => { const caller = createCaller({ user }); await expect( - caller.installFromSource({ source: 'hacker', slug: 'deep-research' }) + 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 ae3b5587c2..fb9d536751 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -3044,6 +3044,9 @@ export const kiloclawRouter = createTRPCRouter({ 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 }) => { @@ -3051,6 +3054,7 @@ export const kiloclawRouter = createTRPCRouter({ userId: ctx.user.id, source: input.source, slug: input.slug, + expectedSignature: input.signature, }); }), From da361d359d9c01d1f139673aa26ee5464cec1818 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Mon, 1 Jun 2026 14:52:25 -0700 Subject: [PATCH 15/20] fix lint --- services/kilo-chat/src/routes/internal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/kilo-chat/src/routes/internal.ts b/services/kilo-chat/src/routes/internal.ts index b2f4c06a3e..160efec984 100644 --- a/services/kilo-chat/src/routes/internal.ts +++ b/services/kilo-chat/src/routes/internal.ts @@ -12,7 +12,7 @@ import { postMessageAsUser } from '../services/post-message-as-user'; */ export function registerInternalRoutes(app: Hono<{ Bindings: Env; Variables: AuthContext }>) { app.post('/internal/v1/post-message-as-user', async c => { - const raw = await c.req.json().catch(() => null); + 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); From bff9cabd613bb29d3c7937cc343517c03cf4061f Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Mon, 1 Jun 2026 15:06:59 -0700 Subject: [PATCH 16/20] fix kilobot findings --- apps/web/src/lib/kiloclaw/install-dispatch.test.ts | 1 - apps/web/src/lib/kiloclaw/install-dispatch.ts | 4 ++-- apps/web/src/lib/kiloclaw/install.ts | 8 +++++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/kiloclaw/install-dispatch.test.ts b/apps/web/src/lib/kiloclaw/install-dispatch.test.ts index a67efc37e8..c07c8934b7 100644 --- a/apps/web/src/lib/kiloclaw/install-dispatch.test.ts +++ b/apps/web/src/lib/kiloclaw/install-dispatch.test.ts @@ -91,7 +91,6 @@ describe('dispatchInstallFromSource', () => { sandboxId: RUNTIME_SANDBOX_ID, // NOT the registry row's sandboxId message: 'Research [topic] for me.', source: 'install', - autoCreateConversation: true, forceNewConversation: true, // each install gets its own conversation correlation: { reason: 'clawbyte:deep-research' }, }); diff --git a/apps/web/src/lib/kiloclaw/install-dispatch.ts b/apps/web/src/lib/kiloclaw/install-dispatch.ts index e232ad746c..c017d50b00 100644 --- a/apps/web/src/lib/kiloclaw/install-dispatch.ts +++ b/apps/web/src/lib/kiloclaw/install-dispatch.ts @@ -149,9 +149,9 @@ export async function dispatchInstallFromSource( sandboxId: runtimeSandboxId, message: payload.prompt, source: 'install', - autoCreateConversation: true, // Each install gets its own dedicated conversation rather than appending - // to whatever the user last chatted in. + // to whatever the user last chatted in. (`forceNewConversation` already + // implies creation, so `autoCreateConversation` is omitted as redundant.) forceNewConversation: true, correlation: { reason }, }); diff --git a/apps/web/src/lib/kiloclaw/install.ts b/apps/web/src/lib/kiloclaw/install.ts index 77e9d79d8d..2821535126 100644 --- a/apps/web/src/lib/kiloclaw/install.ts +++ b/apps/web/src/lib/kiloclaw/install.ts @@ -138,7 +138,13 @@ function verifySignedPayload(payload: InstallPayload): VerifyOk | VerifyErr { * Returns null on overflow (caller logs and rejects). */ async function readBoundedText(res: Response, maxBytes: number): Promise { - if (!res.body) return await res.text(); + 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; From 0f298486714a7b8a7a19819505cd9bce8af6ce17 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Mon, 1 Jun 2026 15:15:20 -0700 Subject: [PATCH 17/20] fix(kilo-chat): re-sync plugin shared schemas after messageTextSchema --- packages/kilo-chat/src/schemas.ts | 4 ++-- .../kiloclaw/plugins/kilo-chat/src/synced/schemas.ts | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/kilo-chat/src/schemas.ts b/packages/kilo-chat/src/schemas.ts index 1efa357d83..bb4036bae5 100644 --- a/packages/kilo-chat/src/schemas.ts +++ b/packages/kilo-chat/src/schemas.ts @@ -43,8 +43,8 @@ export const conversationTitleSchema = trimmedNonEmptyString(CONVERSATION_TITLE_ * 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, ≤ MESSAGE_TEXT_MAX_CHARS — and cannot - * drift apart. + * the SAME rule (trimmed, non-empty, max MESSAGE_TEXT_MAX_CHARS), so they + * cannot drift apart. */ export const messageTextSchema = trimmedNonEmptyString(MESSAGE_TEXT_MAX_CHARS); 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 = { From 7c77b0d6ae2e0f9e7e02d74266044359d5f7ceae Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Tue, 2 Jun 2026 06:45:23 -0700 Subject: [PATCH 18/20] fixes --- .../install/[source]/[slug]/InstallClient.tsx | 6 +++-- .../components/icons/KiloClawbsterIcon.tsx | 9 +++++++- .../src/lib/kiloclaw/install-dispatch.test.ts | 23 +++++++++++++++++++ apps/web/src/lib/kiloclaw/install-dispatch.ts | 12 ++++++++++ .../lib/kiloclaw/kilo-chat-internal-client.ts | 5 ++++ 5 files changed, 52 insertions(+), 3 deletions(-) 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 index e558686ed6..9024b0d6e2 100644 --- a/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx +++ b/apps/web/src/app/(app)/claw/install/[source]/[slug]/InstallClient.tsx @@ -89,7 +89,7 @@ export function InstallClient({ source, sourceLabel, payload }: InstallClientPro {sourceLabel} - {payload.title} + {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 @@ -100,7 +100,9 @@ export function InstallClient({ source, sourceLabel, payload }: InstallClientPro

This {sourceLabel} installs a skill to:

-

{payload.description}

+

+ {payload.description} +