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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions apps/web/src/components/cloud-agent-next/MessageBubble.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { AssistantMessage } from '@/types/opencode.gen';
import type { StoredMessage } from './types';

jest.mock('./PartRenderer', () => ({ PartRenderer: () => null }));
jest.mock('@/components/shared/TimeAgo', () => ({ TimeAgo: () => null }));

import { MessageBubble } from './MessageBubble';

describe('MessageBubble', () => {
it('renders a sanitized string assistant error', () => {
const info: AssistantMessage = {
id: 'msg-1',
sessionID: 'ses-1',
role: 'assistant',
time: { created: 1, completed: 2 },
parentID: 'msg-parent',
modelID: 'test-model',
providerID: 'test-provider',
mode: 'code',
agent: 'test-agent',
path: { cwd: '/', root: '/' },
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
};
Object.defineProperty(info, 'error', {
value: 'Assistant request was rate limited',
enumerable: true,
});
const message: StoredMessage = {
info,
parts: [],
};

const html = renderToStaticMarkup(React.createElement(MessageBubble, { message }));

expect(html).toContain('Assistant request was rate limited');
expect(html).toContain('Failed');
});
});
6 changes: 4 additions & 2 deletions apps/web/src/components/cloud-agent-next/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useCallback } from 'react';
import React, { useCallback } from 'react';
import { Scissors, Image, FileText, AlertCircle, Clock } from 'lucide-react';
import { TimeAgo } from '@/components/shared/TimeAgo';
import type { AssistantMessage } from '@/types/opencode.gen';
Expand Down Expand Up @@ -140,7 +140,9 @@ function getAssistantTextContent(parts: Part[]): string {
/**
* Extract a human-readable error message from an AssistantMessage error field.
*/
function getAssistantErrorMessage(error: NonNullable<AssistantMessage['error']>): string {
function getAssistantErrorMessage(error: NonNullable<AssistantMessage['error']> | string): string {
if (typeof error === 'string') return error;

if ('data' in error && 'message' in error.data && typeof error.data.message === 'string') {
return error.data.message;
}
Expand Down
4 changes: 4 additions & 0 deletions services/cloud-agent-next/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ R2_SECRET_ACCESS_KEY=""
# @url cloudflare-session-ingest
KILO_SESSION_INGEST_URL=http://host.docker.internal:8800

# Local debugging only. Set to 1 to log full URLs rejected during Kilo capability
# redemption. Those URLs can contain tokens; never enable this in a deployed Worker.
LOG_REJECTED_KILO_URLS=0

# Local dev worker origins allowed to connect to /stream
# @url nextjs,cloud-agent-next
WS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8794
122 changes: 121 additions & 1 deletion services/cloud-agent-next/src/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import jwt from 'jsonwebtoken';
import { validateStreamTicket } from './auth.js';
import {
mintWrapperDispatchTicket,
validateStreamTicket,
validateWrapperDispatchTicket,
type WrapperDispatchTicketClaims,
} from './auth.js';

const secret = 'test-secret';

Expand Down Expand Up @@ -50,3 +55,118 @@ describe('validateStreamTicket', () => {
});
});
});

const wrapperDispatchTicketClaims: WrapperDispatchTicketClaims = {
type: 'wrapper_dispatch_ticket',
userId: 'user-1',
cloudAgentSessionId: 'session-1',
kiloSessionId: 'kilo-session-1',
wrapperRunId: 'run-1',
wrapperGeneration: 2,
wrapperConnectionId: 'connection-1',
};

describe('validateWrapperDispatchTicket', () => {
it('returns a configuration error when NEXTAUTH_SECRET is missing', async () => {
await expect(validateWrapperDispatchTicket('Bearer ticket', null)).resolves.toEqual({
success: false,
error: 'NEXTAUTH_SECRET is not configured on the worker',
});
});

it('returns an error when the Authorization header is missing or malformed', async () => {
await expect(validateWrapperDispatchTicket(null, secret)).resolves.toEqual({
success: false,
error: 'Missing or malformed Authorization header',
});
await expect(validateWrapperDispatchTicket('NotBearer ticket', secret)).resolves.toEqual({
success: false,
error: 'Missing or malformed Authorization header',
});
});

it('round-trips a minted ticket, preserving claims exactly', async () => {
const ticket = mintWrapperDispatchTicket(wrapperDispatchTicketClaims, secret);

await expect(validateWrapperDispatchTicket(`Bearer ${ticket}`, secret)).resolves.toEqual({
success: true,
claims: wrapperDispatchTicketClaims,
});
});

it('rejects a raw Kilo JWT / stream ticket by its type discriminant', async () => {
const streamTicket = jwt.sign(
{ type: 'stream_ticket', userId: 'user-1', cloudAgentSessionId: 'session-1' },
secret,
{ algorithm: 'HS256', expiresIn: '1 minute' }
);
const kiloJwt = jwt.sign({ kiloUserId: 'user-1' }, secret, {
algorithm: 'HS256',
expiresIn: '1 minute',
});

await expect(validateWrapperDispatchTicket(`Bearer ${streamTicket}`, secret)).resolves.toEqual({
success: false,
error: 'Invalid ticket type',
});
await expect(validateWrapperDispatchTicket(`Bearer ${kiloJwt}`, secret)).resolves.toEqual({
success: false,
error: 'Invalid ticket type',
});
});

it('accepts a legacy raw Kilo JWT so wrapper processes bound before ticket support shipped keep working', async () => {
const legacyToken = jwt.sign({ version: 3, kiloUserId: 'user-1' }, secret, {
algorithm: 'HS256',
expiresIn: '1 minute',
});

await expect(validateWrapperDispatchTicket(`Bearer ${legacyToken}`, secret)).resolves.toEqual({
success: true,
claims: { type: 'legacy_kilo_token', userId: 'user-1' },
});
});

it('rejects an audience-scoped Kilo JWT on the legacy path', async () => {
const audienceScopedToken = jwt.sign({ version: 3, kiloUserId: 'user-1' }, secret, {
algorithm: 'HS256',
expiresIn: '1 minute',
audience: 'git-token-service',
});

await expect(
validateWrapperDispatchTicket(`Bearer ${audienceScopedToken}`, secret)
).resolves.toEqual({
success: false,
error: 'Invalid ticket type',
});
});

it('rejects an expired ticket', async () => {
const ticket = jwt.sign(wrapperDispatchTicketClaims, secret, {
algorithm: 'HS256',
expiresIn: -1,
});

await expect(validateWrapperDispatchTicket(`Bearer ${ticket}`, secret)).resolves.toEqual({
success: false,
error: 'Ticket expired',
});
});

it('rejects a malformed ticket', async () => {
await expect(validateWrapperDispatchTicket('Bearer not-a-jwt', secret)).resolves.toEqual({
success: false,
error: 'Invalid ticket signature',
});
});

it('rejects a ticket signed with the wrong secret', async () => {
const ticket = mintWrapperDispatchTicket(wrapperDispatchTicketClaims, 'other-secret');

await expect(validateWrapperDispatchTicket(`Bearer ${ticket}`, secret)).resolves.toEqual({
success: false,
error: 'Invalid ticket signature',
});
});
});
116 changes: 116 additions & 0 deletions services/cloud-agent-next/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,32 @@ type StreamTicketPayload = {
nonce?: string;
};

export type WrapperDispatchTicketClaims = {
type: 'wrapper_dispatch_ticket';
userId: string;
cloudAgentSessionId: string;
kiloSessionId: string;
wrapperRunId: string;
wrapperGeneration: number;
wrapperConnectionId: string;
};

/**
* A pre-migration raw Kilo user JWT presented as a workerAuthToken. Accepted
* only so wrapper processes already bound before wrapper dispatch tickets
* shipped can keep talking to the Worker until their next session/ready
* dispatch mints a real ticket. Carries no fence claims — callers must treat
* it as exempt from ticketClaimsMismatchRequestFence.
*/
export type LegacyKiloTokenClaims = {
type: 'legacy_kilo_token';
userId: string;
};

export type WrapperAuthClaims = WrapperDispatchTicketClaims | LegacyKiloTokenClaims;

const WRAPPER_DISPATCH_TICKET_MAX_LIFETIME_SECONDS = 4 * 60 * 60;

export type SecretBinding = string | { get(): Promise<string> };

export async function resolveSecret(
Expand Down Expand Up @@ -88,3 +114,93 @@ export function validateStreamTicket(
return { success: false, error: 'Ticket validation failed' };
}
}

export function mintWrapperDispatchTicket(
claims: WrapperDispatchTicketClaims,
secret: string
): string {
return jwt.sign(claims, secret, {
algorithm: 'HS256',
expiresIn: WRAPPER_DISPATCH_TICKET_MAX_LIFETIME_SECONDS,
});
}

function isWrapperDispatchTicketClaims(payload: unknown): payload is WrapperDispatchTicketClaims {
if (typeof payload !== 'object' || payload === null) return false;
const claims = payload as Record<string, unknown>;
return (
claims.type === 'wrapper_dispatch_ticket' &&
typeof claims.userId === 'string' &&
typeof claims.cloudAgentSessionId === 'string' &&
typeof claims.kiloSessionId === 'string' &&
typeof claims.wrapperRunId === 'string' &&
typeof claims.wrapperGeneration === 'number' &&
typeof claims.wrapperConnectionId === 'string'
);
}

/**
* Re-verifies the token through the canonical verifyKiloToken so the legacy
* path enforces the same version/audience/shape guards as validateKiloToken —
* in particular, audience-scoped tokens (e.g. internal service tokens) must
* not be accepted as wrapper auth.
*/
async function legacyKiloTokenClaims(
ticket: string,
secret: string
): Promise<LegacyKiloTokenClaims | undefined> {
try {
const payload = await verifyKiloToken(ticket, secret);
return { type: 'legacy_kilo_token', userId: payload.kiloUserId };
} catch {
return undefined;
}
}

export async function validateWrapperDispatchTicket(
authHeader: string | null,
secret: string | null | undefined
): Promise<{ success: true; claims: WrapperAuthClaims } | { success: false; error: string }> {
if (!secret) {
return { success: false, error: 'NEXTAUTH_SECRET is not configured on the worker' };
}

const ticket = extractBearerToken(authHeader);
if (!ticket) {
return { success: false, error: 'Missing or malformed Authorization header' };
}

let payload: string | jwt.JwtPayload;
try {
payload = jwt.verify(ticket, secret, { algorithms: ['HS256'] });
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
return { success: false, error: 'Ticket expired' };
}
if (error instanceof jwt.JsonWebTokenError) {
return { success: false, error: 'Invalid ticket signature' };
}
return { success: false, error: 'Ticket validation failed' };
}

if (!isWrapperDispatchTicketClaims(payload)) {
const legacyClaims = await legacyKiloTokenClaims(ticket, secret);
if (legacyClaims) {
return { success: true, claims: legacyClaims };
}
return { success: false, error: 'Invalid ticket type' };
}

return {
success: true,
claims: {
type: payload.type,
userId: payload.userId,
cloudAgentSessionId: payload.cloudAgentSessionId,
kiloSessionId: payload.kiloSessionId,
wrapperRunId: payload.wrapperRunId,
wrapperGeneration: payload.wrapperGeneration,
wrapperConnectionId: payload.wrapperConnectionId,
},
};
}
Loading
Loading