diff --git a/packages/ai-bot/lib/matrix/response-publisher.ts b/packages/ai-bot/lib/matrix/response-publisher.ts index 8fc3d161043..429c99de561 100644 --- a/packages/ai-bot/lib/matrix/response-publisher.ts +++ b/packages/ai-bot/lib/matrix/response-publisher.ts @@ -1,5 +1,10 @@ import type { ChatCompletionMessageFunctionToolCall } from 'openai/resources/chat/completions'; import type { CommandRequest } from '@cardstack/runtime-common/commands'; +import { AI_BOT_EXECUTOR } from '@cardstack/runtime-common/commands'; +import { + READ_REALM_FILE_TOOL_NAME, + fileLabelFromUrl, +} from '../read-realm-file.ts'; import { thinkingMessage } from '../../constants.ts'; import type ResponseState from '../response-state.ts'; import { @@ -35,6 +40,18 @@ function toCommandRequest( result['arguments'] = {}; } } + // readRealmFile is a tool ai-bot fulfills itself: tag it so the host records + // it in the timeline but never runs it, and give it a human label the + // timeline indicator can show ("Read file: ") since the raw arguments + // carry no description of their own. + if (result.name === READ_REALM_FILE_TOOL_NAME) { + result.executedBy = AI_BOT_EXECUTOR; + let label = fileLabelFromUrl(result.arguments?.url); + result.arguments = { + ...(result.arguments ?? {}), + description: label ? `Read file: ${label}` : 'Read file', + }; + } return result; } diff --git a/packages/ai-bot/lib/read-realm-file-fulfillment.ts b/packages/ai-bot/lib/read-realm-file-fulfillment.ts new file mode 100644 index 00000000000..762ceb9add3 --- /dev/null +++ b/packages/ai-bot/lib/read-realm-file-fulfillment.ts @@ -0,0 +1,222 @@ +import { createHash } from 'crypto'; +import { logger } from '@cardstack/runtime-common'; +import { sendMatrixEvent } from '@cardstack/runtime-common/ai'; +import { + APP_BOXEL_COMMAND_RESULT_EVENT_TYPE, + APP_BOXEL_COMMAND_RESULT_REL_TYPE, + APP_BOXEL_COMMAND_RESULT_WITH_NO_OUTPUT_MSGTYPE, + APP_BOXEL_COMMAND_RESULT_WITH_OUTPUT_MSGTYPE, +} from '@cardstack/runtime-common/matrix-constants'; +import type { MatrixClient } from 'matrix-js-sdk'; +import type { ChatCompletionMessageToolCall } from 'openai/resources'; +import { + executeReadRealmFile, + fileLabelFromUrl, + type ReadRealmFileArgs, +} from './read-realm-file.ts'; +import type { DelegatedUserRealmSessionManager } from './user-delegated-realm-server-session.ts'; + +let log = logger('ai-bot:read-realm-file'); + +// The contentType the fetched file is attached under. It must be text-based so +// the prompt builder downloads and inlines the content (rather than treating it +// as opaque media); 'text/plain' satisfies that for any source we read. +const READ_FILE_CONTENT_TYPE = 'text/plain'; + +// Maps a fetched file's content hash to the Matrix media URL it was uploaded +// under, so identical bytes (the same skill read across rooms or turns) are +// uploaded once and re-referenced. Keyed on a SHA-256 of the content, so a +// changed file misses the cache and re-uploads — dedup without staleness. +// Matrix media is not content-addressable (each upload gets a fresh id), so +// this app-level cache is what keeps us from re-storing the same bytes. +const uploadedContentUrlByHash = new Map(); + +export interface ReadRealmFileFulfillmentDeps { + client: MatrixClient; + roomId: string; + // The bot message that carried the readRealmFile command requests. Result + // events relate back to it; pairing is ultimately by commandRequestId, so + // this only needs to be the anchoring bot message. + requestEventId: string; + agentId: string | undefined; + onBehalfOf: string; + delegatedUserRealmSessions: Pick< + DelegatedUserRealmSessionManager, + 'getToken' | 'invalidate' + >; + fetch?: typeof globalThis.fetch; + // Injectable for tests; defaults to uploading to the Matrix media repo. + uploadText?: (content: string, contentType: string) => Promise; +} + +export interface ReadRealmFileFulfillmentOutcome { + commandRequestId: string; + ok: boolean; + error?: string; +} + +// Upload bytes to the Matrix media repo and return an http download URL that +// both the bot and the host can fetch. Dedupes identical content by hash. +async function uploadTextToMatrix( + client: MatrixClient, + content: string, + contentType: string, +): Promise { + let hash = createHash('sha256').update(content).digest('hex'); + let cached = uploadedContentUrlByHash.get(hash); + if (cached) { + return cached; + } + let uploaded = await client.uploadContent(content, { type: contentType }); + let url = client.mxcUrlToHttp( + uploaded.content_uri, + undefined, + undefined, + undefined, + undefined, + undefined, + true, + ); + if (!url) { + throw new Error('could not derive a download URL for the uploaded file'); + } + uploadedContentUrlByHash.set(hash, url); + return url; +} + +// Runs each readRealmFile tool call ai-bot owns and publishes its outcome as a +// command-result event — the same shape a host command result takes, so the +// existing prompt reconstruction pairs it with the request and feeds it back on +// the next turn. On success the fetched file is uploaded to Matrix and attached +// to the result event (data.attachedFiles); the model receives its content via +// the same attachment-download path host-read files use. On failure the result +// event carries the reason and resolves the request as invalid, so a failed +// read reads as failed rather than as a clean read. Returns one outcome per +// call; never throws (a publish failure is logged so the turn still settles). +export async function fulfillReadRealmFileCalls( + botToolCalls: ChatCompletionMessageToolCall[], + deps: ReadRealmFileFulfillmentDeps, +): Promise { + let upload = + deps.uploadText ?? + ((content: string, contentType: string) => + uploadTextToMatrix(deps.client, content, contentType)); + let outcomes: ReadRealmFileFulfillmentOutcome[] = []; + for (let call of botToolCalls) { + if (call.type !== 'function') { + continue; + } + outcomes.push(await fulfillOne(call, deps, upload)); + } + return outcomes; +} + +async function fulfillOne( + call: ChatCompletionMessageToolCall & { type: 'function' }, + deps: ReadRealmFileFulfillmentDeps, + upload: (content: string, contentType: string) => Promise, +): Promise { + let args: ReadRealmFileArgs | undefined; + try { + args = JSON.parse(call.function.arguments) as ReadRealmFileArgs; + } catch { + args = undefined; + } + if (!args || !args.realm || !args.url) { + return await publishFailure( + call.id, + 'readRealmFile needs a realm and a url.', + deps, + ); + } + + let result = await executeReadRealmFile(args, { + onBehalfOf: deps.onBehalfOf, + delegatedUserRealmSessions: deps.delegatedUserRealmSessions, + fetch: deps.fetch, + }); + if (!result.ok) { + return await publishFailure(call.id, result.error, deps); + } + + let label = fileLabelFromUrl(args.url) ?? args.url; + let fileUrl: string; + try { + fileUrl = await upload(result.content, READ_FILE_CONTENT_TYPE); + } catch (e: any) { + log.error( + `readRealmFile: upload failed for ${args.url}: ${e?.message ?? e}`, + ); + return await publishFailure( + call.id, + `could not store ${args.url} for reading`, + deps, + ); + } + + await publish(deps, { + msgtype: APP_BOXEL_COMMAND_RESULT_WITH_OUTPUT_MSGTYPE, + commandRequestId: call.id, + 'm.relates_to': { + rel_type: APP_BOXEL_COMMAND_RESULT_REL_TYPE, + key: 'applied', + event_id: deps.requestEventId, + }, + data: { + context: { agentId: deps.agentId }, + attachedFiles: [ + { + sourceUrl: args.url, + url: fileUrl, + name: label, + contentType: READ_FILE_CONTENT_TYPE, + contentSize: Buffer.byteLength(result.content), + }, + ], + }, + }); + return { commandRequestId: call.id, ok: true }; +} + +async function publishFailure( + commandRequestId: string, + error: string, + deps: ReadRealmFileFulfillmentDeps, +): Promise { + await publish(deps, { + msgtype: APP_BOXEL_COMMAND_RESULT_WITH_NO_OUTPUT_MSGTYPE, + commandRequestId, + failureReason: error, + 'm.relates_to': { + rel_type: APP_BOXEL_COMMAND_RESULT_REL_TYPE, + key: 'invalid', + event_id: deps.requestEventId, + }, + data: { context: { agentId: deps.agentId } }, + }); + return { commandRequestId, ok: false, error }; +} + +async function publish( + deps: ReadRealmFileFulfillmentDeps, + content: Record, +): Promise { + try { + // eventIdToReplace must stay undefined: sendMatrixEvent overwrites + // m.relates_to with an m.replace relation when it's set, which would clobber + // the command-result relation we build here. + await sendMatrixEvent( + deps.client, + deps.roomId, + APP_BOXEL_COMMAND_RESULT_EVENT_TYPE, + content, + undefined, + ); + } catch (e: any) { + log.error( + `readRealmFile: failed to publish result for ${content.commandRequestId}: ${ + e?.message ?? e + }`, + ); + } +} diff --git a/packages/ai-bot/lib/read-realm-file.ts b/packages/ai-bot/lib/read-realm-file.ts new file mode 100644 index 00000000000..7667e5ddc9c --- /dev/null +++ b/packages/ai-bot/lib/read-realm-file.ts @@ -0,0 +1,205 @@ +import { logger, SupportedMimeType } from '@cardstack/runtime-common'; +import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; +import { DelegatedUserRealmSessionError } from '@cardstack/runtime-common/user-delegated-realm-server-session'; +import type { Tool } from 'https://cardstack.com/base/matrix-event'; +import type { + ChatCompletion, + ChatCompletionMessageToolCall, +} from 'openai/resources'; +import type { DelegatedUserRealmSessionManager } from './user-delegated-realm-server-session.ts'; + +let log = logger('ai-bot:read-realm-file'); + +export const READ_REALM_FILE_TOOL_NAME = 'readRealmFile'; + +// On-demand file reading. The model calls `readRealmFile` to pull a file's +// contents only when it needs them — most often a skill's SKILL.md or a file it +// references, but it works for any file in the realm. ai-bot executes it +// in-process: it mints a delegated, user-scoped realm token and fetches the +// file over HTTP, so the bot can only read what the requesting human can +// already read, and the content is always live (no Matrix snapshots, no host +// round-trip). +export const readRealmFileTool: Tool = { + type: 'function', + function: { + name: READ_REALM_FILE_TOOL_NAME, + description: + "Read a file from a realm on demand — e.g. a skill's SKILL.md, or a " + + "file it references. Use this to get a skill's full instructions, or a " + + 'reference it cites, when you only have it listed.', + parameters: { + type: 'object', + properties: { + realm: { + type: 'string', + description: + 'Realm URL the file lives in, e.g. https://app.boxel.ai/user/jane/. ' + + 'Scopes the read to that realm; the file must be inside it.', + }, + url: { + type: 'string', + description: + 'Full URL of the file to read. The realm and these URLs are given ' + + 'to you together.', + }, + }, + required: ['realm', 'url'], + }, + }, +}; + +export interface ReadRealmFileArgs { + // Realm root the read is scoped to (what the delegated token is minted for). + realm: string; + // Full URL of the file to read; must be inside `realm`. + url: string; +} + +export type ReadRealmFileResult = + | { ok: true; url: string; content: string } + | { ok: false; error: string }; + +// Executes a readRealmFile tool call inside the bot process: mints a delegated, +// read-only token for `onBehalfOf` scoped to `realm`, then GETs the file as raw +// source. Never throws — returns a result the caller hands back to the model as +// the tool result, so a missing file or a permission failure becomes +// information the model can act on rather than a crashed turn. +export async function executeReadRealmFile( + args: ReadRealmFileArgs, + { + onBehalfOf, + delegatedUserRealmSessions, + fetch = globalThis.fetch, + }: { + onBehalfOf: string; + delegatedUserRealmSessions: Pick< + DelegatedUserRealmSessionManager, + 'getToken' | 'invalidate' + >; + fetch?: typeof globalThis.fetch; + }, +): Promise { + let url = args.url; + + // The delegated token is scoped to `realm`; a file outside it would be + // rejected by the realm server anyway, so fail clearly up front. + if (!url.startsWith(ensureTrailingSlash(args.realm))) { + return { ok: false, error: `${url} is not inside realm ${args.realm}` }; + } + + // One mint+fetch attempt. `redirect: 'manual'` keeps a stray redirect from + // being silently followed (it surfaces as a non-2xx instead). + let attempt = async (): Promise<{ response?: Response; error?: string }> => { + let token: string; + try { + token = await delegatedUserRealmSessions.getToken({ + onBehalfOf, + realm: args.realm, + }); + } catch (e: any) { + if (e instanceof DelegatedUserRealmSessionError) { + if (e.kind === 'disabled') { + return { + error: + 'reading realm files is unavailable (delegation is not configured)', + }; + } + if (e.kind === 'forbidden') { + return { error: `no read access to ${args.realm}` }; + } + } + log.error( + `readRealmFile: could not obtain a delegated token for ${args.realm}: ${ + e?.message ?? e + }`, + ); + return { error: `could not obtain realm access for ${args.realm}` }; + } + try { + return { + response: await fetch(url, { + redirect: 'manual', + headers: { + Accept: SupportedMimeType.CardSource, + Authorization: `Bearer ${token}`, + }, + }), + }; + } catch (e: any) { + log.error(`readRealmFile: fetch failed for ${url}: ${e?.message ?? e}`); + return { error: `could not fetch ${url}` }; + } + }; + + let { response, error } = await attempt(); + if (error) { + return { ok: false, error }; + } + // A cached token whose access was revoked inside its staleness window gets a + // 401/403. Drop it and try once with a freshly minted token before failing. + if (response && (response.status === 401 || response.status === 403)) { + delegatedUserRealmSessions.invalidate({ onBehalfOf, realm: args.realm }); + ({ response, error } = await attempt()); + if (error) { + return { ok: false, error }; + } + } + + if (!response || !response.ok) { + return { + ok: false, + error: `could not load ${url} (HTTP ${response?.status ?? 'unknown'})`, + }; + } + + return { ok: true, url, content: await response.text() }; +} + +export interface ClassifiedToolCalls { + // Tool calls ai-bot runs itself (readRealmFile). + botToolCalls: ChatCompletionMessageToolCall[]; + // Tool calls the host runs (everything else). + hostToolCalls: ChatCompletionMessageToolCall[]; +} + +// Split a completion's tool calls into the ones ai-bot fulfills itself +// (readRealmFile) and the ones the host fulfills. Both kinds now resolve the +// same way — as command requests answered by a command-result event on a later +// turn — so a single response may freely contain both; the caller fulfills the +// bot ones and leaves the rest to the host. +export function classifyToolCalls( + assistantMessage: ChatCompletion.Choice['message'], +): ClassifiedToolCalls { + let botToolCalls: ChatCompletionMessageToolCall[] = []; + let hostToolCalls: ChatCompletionMessageToolCall[] = []; + for (let call of assistantMessage.tool_calls ?? []) { + if ( + call.type === 'function' && + call.function.name === READ_REALM_FILE_TOOL_NAME + ) { + botToolCalls.push(call); + } else { + hostToolCalls.push(call); + } + } + return { botToolCalls, hostToolCalls }; +} + +// A short human label for a file being read, derived from its URL: +// `…/skills//SKILL.md` → `/SKILL.md`, otherwise the file name. +// Shown in the command-result indicator so the user sees which file was read. +export function fileLabelFromUrl(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + try { + let segments = new URL(url).pathname.split('/').filter(Boolean); + let last = segments[segments.length - 1]; + if (last === 'SKILL.md' && segments.length >= 2) { + return `${segments[segments.length - 2]}/SKILL.md`; + } + return last ?? url; + } catch { + return url; + } +} diff --git a/packages/ai-bot/lib/responder.ts b/packages/ai-bot/lib/responder.ts index 111c59cea8a..af2a287d29f 100644 --- a/packages/ai-bot/lib/responder.ts +++ b/packages/ai-bot/lib/responder.ts @@ -66,6 +66,13 @@ export class Responder { needsMessageSend = false; + // The event id of the bot message this turn streamed into. ai-bot relates the + // command-result events for its own readRealmFile calls back to it, so they + // pair with the requests carried on that message. + get responseEventId(): string | undefined { + return this.matrixResponsePublisher.originalResponseEventId; + } + async ensureThinkingMessageSent() { await this.matrixResponsePublisher.ensureThinkingMessageSent(); } diff --git a/packages/ai-bot/lib/user-delegated-realm-server-session.ts b/packages/ai-bot/lib/user-delegated-realm-server-session.ts index 8e509b1ada4..c58ff187c4d 100644 Binary files a/packages/ai-bot/lib/user-delegated-realm-server-session.ts and b/packages/ai-bot/lib/user-delegated-realm-server-session.ts differ diff --git a/packages/ai-bot/main.ts b/packages/ai-bot/main.ts index cd48cd089ec..0cca488b276 100644 --- a/packages/ai-bot/main.ts +++ b/packages/ai-bot/main.ts @@ -31,10 +31,17 @@ import { INITIAL_SLIDING_SYNC_LIST_TIMELINE_LIMIT, SLIDING_SYNC_TIMEOUT, APP_BOXEL_CODE_PATCH_CORRECTNESS_MSGTYPE, + APP_BOXEL_COMMAND_RESULT_EVENT_TYPE, } from '@cardstack/runtime-common/matrix-constants'; import { handleDebugCommands } from './lib/debug.ts'; -import { DelegatedRealmSessionManager } from './lib/user-delegated-realm-server-session.ts'; +import { DelegatedUserRealmSessionManager } from './lib/user-delegated-realm-server-session.ts'; +import { + readRealmFileTool, + classifyToolCalls, + READ_REALM_FILE_TOOL_NAME, +} from './lib/read-realm-file.ts'; +import { fulfillReadRealmFileCalls } from './lib/read-realm-file-fulfillment.ts'; import { Responder } from './lib/responder.ts'; import { shouldSetRoomTitle, @@ -86,9 +93,9 @@ class Assistant { id: string; aiBotInstanceId: string; // Mints user-scoped, read-only realm tokens on demand. Inert unless - // AI_BOT_DELEGATION_SECRET is configured; the pull-model skill loader is its + // AI_BOT_DELEGATION_SECRET is configured; the readRealmFile tool is its // consumer. - delegatedRealmSessions: DelegatedRealmSessionManager; + delegatedUserRealmSessions: DelegatedUserRealmSessionManager; constructor(client: MatrixClient, id: string, aiBotInstanceId: string) { this.openai = new OpenAI({ @@ -99,12 +106,19 @@ class Assistant { this.client = client; this.pgAdapter = new PgAdapter(); this.aiBotInstanceId = aiBotInstanceId; - this.delegatedRealmSessions = new DelegatedRealmSessionManager( + this.delegatedUserRealmSessions = new DelegatedUserRealmSessionManager( process.env.AI_BOT_DELEGATION_SECRET, ); } - getResponse(prompt: PromptParts, senderMatrixUserId?: string) { + getResponse( + prompt: PromptParts, + senderMatrixUserId?: string, + // Whether to offer the bot-fulfilled readRealmFile tool. The caller decides + // (delegation configured + a single-human room); the bot never advertises + // a tool it won't run. + offerRealmFileRead = false, + ) { if (!prompt.model) { throw new Error('Model is required'); } @@ -127,6 +141,12 @@ class Assistant { request.tool_choice = prompt.toolChoice; } + // Offer the bot-executed readRealmFile tool when the caller allows it, even + // in rooms that carry no other tools. + if (prompt.toolsSupported === true && offerRealmFileRead) { + request.tools = [...(request.tools ?? []), readRealmFileTool]; + } + if (senderMatrixUserId) { request.user = senderMatrixUserId; } @@ -254,6 +274,18 @@ Common issues are: return; } + // Pull-model skills are read on behalf of the single human in the room + // (the message sender). In a room with more than one human, "the user" + // is ambiguous, so the bot must not read any realm on someone's behalf: + // disable realm file reading entirely there. + let humanRoomMembers = room + .getJoinedMembers() + .filter((member) => member.userId !== aiBotUserId); + let humanRoomMemberCount = humanRoomMembers.length; + let realmFileReadingAllowed = + assistant.delegatedUserRealmSessions.enabled && + humanRoomMemberCount === 1; + if (event.event.origin_server_ts! < startTime) { return; } @@ -261,6 +293,23 @@ Common issues are: return; // don't print paginated results } + // A continuation the bot triggered with its own readRealmFile result + // event arrives with sender = the bot. Re-attribute it to the single + // human in the room so the guard below lets it through and all per-user + // logic (billing, the delegated read's onBehalfOf, request.user) acts on + // the user's behalf rather than the bot's. Only single-human rooms + // fulfill reads, so the human is unambiguous; every other bot-sent event + // keeps sender = bot and is ignored by the guard. getShouldRespond still + // decides whether we actually generate, so this can't loop on its own + // answer. + if ( + senderMatrixUserId === aiBotUserId && + event.getType() === APP_BOXEL_COMMAND_RESULT_EVENT_TYPE && + humanRoomMemberCount === 1 + ) { + senderMatrixUserId = humanRoomMembers[0].userId; + } + if (senderMatrixUserId === aiBotUserId) { return; } @@ -329,6 +378,17 @@ Common issues are: resolveGenerationCompletion = resolve; }); + // readRealmFile reads are fulfilled after the room lock is released + // (see the finally below). Fulfilling posts a command-result event that + // re-triggers the bot for the continuation turn, and that re-trigger + // has to acquire the room lock this handler holds — so fulfillment must + // wait until the lock is free. + let pendingFulfillBotToolCalls: ReturnType< + typeof classifyToolCalls + >['botToolCalls'] = []; + let pendingFulfillRequestEventId: string | undefined; + let pendingFulfillAgentId: string | undefined; + try { if (!Responder.eventMayTriggerResponse(event)) { return; // early exit for events that will not trigger a response @@ -364,6 +424,35 @@ Common issues are: return; } + // The bot drives its own continuation by posting a readRealmFile + // result event, and the handler runs on that event's *local echo* — + // before the homeserver has indexed it into /messages. getRoomEvents + // is a server fetch, so it misses the just-posted result, which would + // leave the read looking unresolved (shouldRespond=false) and stall + // the continuation. Splice the in-hand event into the history when the + // fetch didn't include it. Host command results arrive via sync + // (already server-side), so they're never missing and this is inert. + let triggerCommandRequestId = (event.getContent() as any) + ?.commandRequestId; + if ( + event.getType() === APP_BOXEL_COMMAND_RESULT_EVENT_TYPE && + triggerCommandRequestId && + !eventList.some( + (e: any) => + e.type === APP_BOXEL_COMMAND_RESULT_EVENT_TYPE && + e.content?.commandRequestId === triggerCommandRequestId, + ) + ) { + eventList.push({ + type: event.getType(), + sender: event.getSender()!, + content: event.getContent(), + event_id: event.getId()!, + origin_server_ts: event.event.origin_server_ts!, + room_id: room.roomId, + } as unknown as DiscreteMatrixEvent); + } + // Return early here if it's a debug event if (isRecognisedDebugCommand(eventBody)) { return await assistant.handleDebugCommands( @@ -390,9 +479,13 @@ Common issues are: 'history:constructPromptParts', async () => getPromptParts(eventList, aiBotUserId, client), ); - responder.responseState.setAllowedToolNames( - promptParts.tools?.map((tool) => tool.function.name), - ); + responder.responseState.setAllowedToolNames([ + ...(promptParts.tools?.map((tool) => tool.function.name) ?? []), + // readRealmFile is offered separately (in getResponse), not via + // promptParts.tools, so allow it explicitly when this room may use + // it — otherwise the surfaced tool call would be filtered out. + ...(realmFileReadingAllowed ? [READ_REALM_FILE_TOOL_NAME] : []), + ]); if (promptParts.pendingCodePatchCorrectnessChecks) { return await publishCodePatchCorrectnessMessage( promptParts.pendingCodePatchCorrectnessChecks, @@ -485,68 +578,106 @@ Common issues are: model: promptParts.model, }); } - const runner = assistant - .getResponse(promptParts, senderMatrixUserId) - .on('chunk', async (chunk, snapshot) => { - log.info(`[${eventId}] Received chunk %s`, chunk.id); - if (profEnabled() && firstChunkAt == null) { - firstChunkAt = Date.now(); - profNote(eventId, 'llm:ttft', { - ms: firstChunkAt - requestStart, - model: promptParts.model, - }); - } - generationId = chunk.id; - if (chunk.usage && (chunk.usage as any).cost != null) { - costInUsd = (chunk.usage as any).cost; - } - let activeGeneration = activeGenerations.get(room.roomId); - if (activeGeneration) { - activeGeneration.lastGeneratedChunkId = generationId; - } + try { + let roundCostInUsd: number | undefined; + const runner = assistant + .getResponse( + promptParts, + senderMatrixUserId, + realmFileReadingAllowed, + ) + .on('chunk', async (chunk, snapshot) => { + log.info(`[${eventId}] Received chunk %s`, chunk.id); + if (profEnabled() && firstChunkAt == null) { + firstChunkAt = Date.now(); + profNote(eventId, 'llm:ttft', { + ms: firstChunkAt - requestStart, + model: promptParts.model, + }); + } + generationId = chunk.id; + if (chunk.usage && (chunk.usage as any).cost != null) { + roundCostInUsd = (chunk.usage as any).cost; + } + let activeGeneration = activeGenerations.get(room.roomId); + if (activeGeneration) { + activeGeneration.lastGeneratedChunkId = generationId; + } + + let chunkProcessingResult = await profTime( + eventId, + 'llm:chunk:onChunk', + async () => responder.onChunk(chunk, snapshot), + ); + let chunkProcessingResultError = chunkProcessingResult.find( + (promiseResult) => + promiseResult && + 'errorMessage' in promiseResult && + promiseResult.errorMessage != null, + ) as { errorMessage: string } | undefined; + + if (chunkProcessingResultError) { + chunkHandlingError = + chunkProcessingResultError.errorMessage; + + // If there was an error processing the chunk, e.g. matrix sending error (e.g. event too large), + // then we want to stop accepting more chunks by aborting the runner. This will throw an error + // where the await responder.finalize() is called (the catch block below will handle this) + runner.abort(); + } + }) + .on('error', async (error) => { + await responder.onError(error); + }); - let chunkProcessingResult = await profTime( - eventId, - 'llm:chunk:onChunk', - async () => responder.onChunk(chunk, snapshot), - ); - let chunkProcessingResultError = chunkProcessingResult.find( - (promiseResult) => - promiseResult && - 'errorMessage' in promiseResult && - promiseResult.errorMessage != null, - ) as { errorMessage: string } | undefined; - - if (chunkProcessingResultError) { - chunkHandlingError = - chunkProcessingResultError.errorMessage; - - // If there was an error processing the chunk, e.g. matrix sending error (e.g. event too large), - // then we want to stop accepting more chunks by aborting the runner. This will throw an error - // where the await responder.finalize() is called (the catch block below will handle this) - runner.abort(); - } - }) - .on('error', async (error) => { - await responder.onError(error); + activeGenerations.set(room.roomId, { + responder, + runner, + lastGeneratedChunkId: generationId, + completionPromise: generationCompletionPromise, }); - activeGenerations.set(room.roomId, { - responder, - runner, - lastGeneratedChunkId: generationId, - completionPromise: generationCompletionPromise, - }); - - try { - await profTime(eventId, 'llm:finalChatCompletion', async () => - runner.finalChatCompletion(), + let completion = await profTime( + eventId, + 'llm:finalChatCompletion', + async () => runner.finalChatCompletion(), ); + if (typeof roundCostInUsd === 'number') { + costInUsd = (costInUsd ?? 0) + roundCostInUsd; + } + log.info(`[${eventId}] Generation complete`); await profTime(eventId, 'response:finalize', async () => responder.finalize(), ); log.info(`[${eventId}] Response finalized`); + + // readRealmFile is a tool ai-bot fulfills itself. The answer has + // already streamed (with the reads surfaced as executedBy: + // 'ai-bot' command requests); now fetch each file, attach it to + // a command-result event, and let the normal command-result path + // drive the continuation on a later turn — exactly as a host + // command result would. Because reads now resolve next-turn like + // host commands, an answer may freely mix the two; the host + // fulfills its commands, we fulfill ours, and getShouldRespond + // waits for all of them before generating again. + let message = completion.choices?.[0]?.message; + let { botToolCalls } = message + ? classifyToolCalls(message) + : { botToolCalls: [] }; + if ( + realmFileReadingAllowed && + botToolCalls.length > 0 && + responder.responseEventId + ) { + // Defer fulfillment until after the room lock is released + // (see the finally below): fulfilling posts a result event + // that re-triggers the bot, and that re-trigger needs the + // room lock this handler still holds here. + pendingFulfillBotToolCalls = botToolCalls; + pendingFulfillRequestEventId = responder.responseEventId; + pendingFulfillAgentId = agentId; + } } catch (error) { // When the cancel handler aborts the runner, // finalChatCompletion() throws APIUserAbortError. @@ -657,6 +788,25 @@ Common issues are: // lock as released before attempting to acquire it. await releaseRoomLock(assistant.pgAdapter, room.roomId); resolveGenerationCompletion(); + + // Now that the lock is free, fulfill any reads. Each fulfillment + // posts a command-result event that re-triggers the bot for the + // continuation turn; that re-trigger acquires the room lock this + // handler just released. Fulfilling here (rather than inside the + // lock) is what lets the continuation proceed. + if ( + pendingFulfillRequestEventId && + pendingFulfillBotToolCalls.length > 0 + ) { + await fulfillReadRealmFileCalls(pendingFulfillBotToolCalls, { + client, + roomId: room.roomId, + requestEventId: pendingFulfillRequestEventId, + agentId: pendingFulfillAgentId, + onBehalfOf: senderMatrixUserId, + delegatedUserRealmSessions: assistant.delegatedUserRealmSessions, + }); + } } } catch (e) { log.error(e); diff --git a/packages/ai-bot/tests/index.ts b/packages/ai-bot/tests/index.ts index d078081a266..2ecd2449ab6 100644 --- a/packages/ai-bot/tests/index.ts +++ b/packages/ai-bot/tests/index.ts @@ -13,5 +13,7 @@ import './locking-test.ts'; import './interrupt-test.ts'; import './credit-tracking-test.ts'; import './user-delegated-realm-server-session-test.ts'; +import './read-realm-file-test.ts'; +import './read-realm-file-fulfillment-test.ts'; QUnit.start(); diff --git a/packages/ai-bot/tests/read-realm-file-fulfillment-test.ts b/packages/ai-bot/tests/read-realm-file-fulfillment-test.ts new file mode 100644 index 00000000000..aca3a994eb7 --- /dev/null +++ b/packages/ai-bot/tests/read-realm-file-fulfillment-test.ts @@ -0,0 +1,208 @@ +import QUnit from 'qunit'; +const { module, test, assert } = QUnit; + +import { fulfillReadRealmFileCalls } from '../lib/read-realm-file-fulfillment.ts'; +import { READ_REALM_FILE_TOOL_NAME } from '../lib/read-realm-file.ts'; +import { + APP_BOXEL_COMMAND_RESULT_EVENT_TYPE, + APP_BOXEL_COMMAND_RESULT_WITH_NO_OUTPUT_MSGTYPE, + APP_BOXEL_COMMAND_RESULT_WITH_OUTPUT_MSGTYPE, +} from '@cardstack/runtime-common/matrix-constants'; + +const ON_BEHALF_OF = '@user:localhost'; +const AGENT_ID = 'agent-1'; +const ROOM_ID = '!room:localhost'; +const REQUEST_EVENT_ID = '$request:localhost'; +const REALM = 'https://localhost:4201/user/jane/'; +const FILE_URL = + 'https://localhost:4201/user/jane/skills/trip-planner/SKILL.md'; + +function readRealmFileCall(id: string, args: object) { + return { + id, + type: 'function', + function: { + name: READ_REALM_FILE_TOOL_NAME, + arguments: JSON.stringify(args), + }, + } as any; +} + +// A fake Matrix client that records sent events and uploads. sendMatrixEvent +// JSON-stringifies content.data on the way out, so the recorded data is a +// string — tests parse it back. +function fakeClient() { + let sent: { eventType: string; content: any }[] = []; + let uploads: { content: any; opts: any }[] = []; + let client = { + sendEvent: async (_roomId: string, eventType: string, content: any) => { + sent.push({ eventType, content }); + return { event_id: `$sent-${sent.length}:localhost` }; + }, + uploadContent: async (content: any, opts: any) => { + uploads.push({ content, opts }); + return { content_uri: `mxc://localhost/upload-${uploads.length}` }; + }, + mxcUrlToHttp: (uri: string) => + `https://localhost/_matrix/media/v3/download/${uri.replace( + 'mxc://', + '', + )}`, + } as any; + return { client, sent, uploads }; +} + +function sessions() { + return { + getToken: async () => 'tok', + invalidate: () => {}, + }; +} + +function baseDeps(client: any, extra: object = {}) { + return { + client, + roomId: ROOM_ID, + requestEventId: REQUEST_EVENT_ID, + agentId: AGENT_ID, + onBehalfOf: ON_BEHALF_OF, + delegatedUserRealmSessions: sessions(), + ...extra, + }; +} + +// Reads the (stringified) data payload back off a recorded event. +function dataOf(sentEvent: { content: any }) { + return JSON.parse(sentEvent.content.data); +} + +module('fulfillReadRealmFileCalls', () => { + test('a successful read attaches the file to an applied command-result event', async () => { + let { client, sent } = fakeClient(); + let fetch = (async () => + new Response('# Trip Planner', { + status: 200, + })) as unknown as typeof globalThis.fetch; + + let outcomes = await fulfillReadRealmFileCalls( + [readRealmFileCall('c1', { realm: REALM, url: FILE_URL })], + baseDeps(client, { + fetch, + // Inject the uploader so this case doesn't touch the dedup cache. + uploadText: async () => 'https://localhost/media/trip-planner', + }), + ); + + assert.deepEqual(outcomes, [{ commandRequestId: 'c1', ok: true }]); + assert.strictEqual(sent.length, 1, 'one command-result event posted'); + let { eventType, content } = sent[0]; + assert.strictEqual(eventType, APP_BOXEL_COMMAND_RESULT_EVENT_TYPE); + assert.strictEqual( + content.msgtype, + APP_BOXEL_COMMAND_RESULT_WITH_OUTPUT_MSGTYPE, + ); + assert.strictEqual(content.commandRequestId, 'c1'); + assert.strictEqual(content['m.relates_to'].key, 'applied'); + assert.strictEqual(content['m.relates_to'].event_id, REQUEST_EVENT_ID); + let data = dataOf(sent[0]); + assert.strictEqual(data.attachedFiles.length, 1); + assert.strictEqual( + data.attachedFiles[0].sourceUrl, + FILE_URL, + 'attachment keeps the realm source url for scoping/supersession', + ); + assert.strictEqual( + data.attachedFiles[0].url, + 'https://localhost/media/trip-planner', + 'attachment points at the uploaded media url', + ); + assert.strictEqual(data.context.agentId, AGENT_ID); + }); + + test('a failed read posts an invalid result carrying the reason, no attachment', async () => { + let { client, sent } = fakeClient(); + let fetch = (async () => + new Response('nope', { + status: 404, + })) as unknown as typeof globalThis.fetch; + + let outcomes = await fulfillReadRealmFileCalls( + [readRealmFileCall('c1', { realm: REALM, url: FILE_URL })], + baseDeps(client, { fetch }), + ); + + assert.false(outcomes[0].ok, 'a 404 read is reported as failed'); + assert.strictEqual(sent.length, 1); + let { content } = sent[0]; + assert.strictEqual( + content.msgtype, + APP_BOXEL_COMMAND_RESULT_WITH_NO_OUTPUT_MSGTYPE, + ); + assert.strictEqual(content['m.relates_to'].key, 'invalid'); + assert.true( + content.failureReason.includes('404'), + 'the reason rides along so the user sees why it failed', + ); + assert.strictEqual(dataOf(sent[0]).attachedFiles, undefined); + }); + + test('malformed arguments fail without fetching', async () => { + let { client, sent } = fakeClient(); + let fetched = false; + let fetch = (async () => { + fetched = true; + return new Response('x', { status: 200 }); + }) as unknown as typeof globalThis.fetch; + + let outcomes = await fulfillReadRealmFileCalls( + [ + { + id: 'c1', + type: 'function', + function: { + name: READ_REALM_FILE_TOOL_NAME, + arguments: '{not json', + }, + } as any, + ], + baseDeps(client, { fetch }), + ); + + assert.false(outcomes[0].ok); + assert.false(fetched, 'never fetched for malformed arguments'); + assert.strictEqual(sent[0].content['m.relates_to'].key, 'invalid'); + }); + + test('identical content is uploaded once and re-referenced (dedup)', async () => { + let { client, sent, uploads } = fakeClient(); + // Content unique to this test so the module-level hash cache is exercised + // cleanly regardless of other tests (this is the only test that uploads via + // the real, caching uploader). + let body = '# Dedup Fixture — reuse-me-across-rooms'; + let fetch = (async () => + new Response(body, { + status: 200, + })) as unknown as typeof globalThis.fetch; + + await fulfillReadRealmFileCalls( + [readRealmFileCall('c1', { realm: REALM, url: FILE_URL })], + baseDeps(client, { fetch }), + ); + await fulfillReadRealmFileCalls( + [readRealmFileCall('c2', { realm: REALM, url: FILE_URL })], + baseDeps(client, { fetch }), + ); + + assert.strictEqual(sent.length, 2, 'both reads post their own result'); + assert.strictEqual( + uploads.length, + 1, + 'the same bytes are uploaded to Matrix only once', + ); + // Both results point at the same uploaded media url. + assert.strictEqual( + dataOf(sent[0]).attachedFiles[0].url, + dataOf(sent[1]).attachedFiles[0].url, + ); + }); +}); diff --git a/packages/ai-bot/tests/read-realm-file-test.ts b/packages/ai-bot/tests/read-realm-file-test.ts new file mode 100644 index 00000000000..e387328523a --- /dev/null +++ b/packages/ai-bot/tests/read-realm-file-test.ts @@ -0,0 +1,262 @@ +import QUnit from 'qunit'; +const { module, test, assert } = QUnit; + +import { SupportedMimeType } from '@cardstack/runtime-common'; +import { DelegatedUserRealmSessionError } from '@cardstack/runtime-common/user-delegated-realm-server-session'; +import { + executeReadRealmFile, + readRealmFileTool, + classifyToolCalls, + fileLabelFromUrl, + READ_REALM_FILE_TOOL_NAME, +} from '../lib/read-realm-file.ts'; + +const ON_BEHALF_OF = '@user:localhost'; +const REALM = 'https://localhost:4201/user/jane/'; +const FILE_URL = + 'https://localhost:4201/user/jane/skills/trip-planner/SKILL.md'; + +// A fake fetch that records each call and returns a scripted Response. +function recordingFetch( + handler: (url: string, init: RequestInit) => Response, +): { + fetch: typeof globalThis.fetch; + calls: { url: string; init: RequestInit }[]; +} { + let calls: { url: string; init: RequestInit }[] = []; + let fetch = (async (input: any, init: any) => { + let url = typeof input === 'string' ? input : input.url; + calls.push({ url, init }); + return handler(url, init); + }) as unknown as typeof globalThis.fetch; + return { fetch, calls }; +} + +// A stand-in for DelegatedUserRealmSessionManager that records getToken calls +// and either returns a token or throws a scripted error. +function stubSessions(result: { token: string } | { throws: unknown }): { + getToken: (args: { onBehalfOf: string; realm: string }) => Promise; + invalidate: (args: { onBehalfOf: string; realm: string }) => void; + calls: { onBehalfOf: string; realm: string }[]; + invalidated: { onBehalfOf: string; realm: string }[]; +} { + let calls: { onBehalfOf: string; realm: string }[] = []; + let invalidated: { onBehalfOf: string; realm: string }[] = []; + return { + calls, + invalidated, + getToken: async (args) => { + calls.push(args); + if ('throws' in result) { + throw result.throws; + } + return result.token; + }, + invalidate: (args) => { + invalidated.push(args); + }, + }; +} + +module('readRealmFile tool definition', () => { + test('advertises name + required args', () => { + assert.strictEqual( + readRealmFileTool.function.name, + READ_REALM_FILE_TOOL_NAME, + ); + assert.strictEqual(readRealmFileTool.type, 'function'); + let required = (readRealmFileTool.function.parameters as any) + .required as string[]; + assert.true(required.includes('realm'), 'realm is required'); + assert.true(required.includes('url'), 'url is required'); + }); +}); + +module('executeReadRealmFile', () => { + test('mints a token for the realm and returns the file source', async () => { + let sessions = stubSessions({ token: 'tok-123' }); + let { fetch, calls } = recordingFetch( + () => new Response('# Trip Planner\n\ninstructions', { status: 200 }), + ); + + let result = await executeReadRealmFile( + { realm: REALM, url: FILE_URL }, + { onBehalfOf: ON_BEHALF_OF, delegatedUserRealmSessions: sessions, fetch }, + ); + + assert.deepEqual(sessions.calls, [ + { onBehalfOf: ON_BEHALF_OF, realm: REALM }, + ]); + assert.strictEqual(calls[0].url, FILE_URL, 'fetches the given url'); + let headers = calls[0].init.headers as Record; + assert.strictEqual(headers['Authorization'], 'Bearer tok-123'); + assert.strictEqual(headers['Accept'], SupportedMimeType.CardSource); + assert.true(result.ok, 'result ok'); + assert.strictEqual( + (result as { ok: true; content: string }).content, + '# Trip Planner\n\ninstructions', + ); + }); + + test('rejects a url outside the realm without minting or fetching', async () => { + let sessions = stubSessions({ token: 'tok' }); + let { fetch, calls } = recordingFetch( + () => new Response('x', { status: 200 }), + ); + let result = await executeReadRealmFile( + { + realm: REALM, + url: 'https://localhost:4201/user/someone-else/skills/x/SKILL.md', + }, + { onBehalfOf: ON_BEHALF_OF, delegatedUserRealmSessions: sessions, fetch }, + ); + assert.false(result.ok); + assert.true( + (result as { ok: false; error: string }).error.includes( + 'not inside realm', + ), + ); + assert.strictEqual(sessions.calls.length, 0, 'no token minted'); + assert.strictEqual(calls.length, 0, 'no fetch attempted'); + }); + + test('returns an error result when the file is missing (404)', async () => { + let sessions = stubSessions({ token: 'tok' }); + let { fetch } = recordingFetch( + () => new Response('not found', { status: 404 }), + ); + let result = await executeReadRealmFile( + { realm: REALM, url: FILE_URL }, + { onBehalfOf: ON_BEHALF_OF, delegatedUserRealmSessions: sessions, fetch }, + ); + assert.false(result.ok, 'result not ok'); + assert.true( + (result as { ok: false; error: string }).error.includes('404'), + 'error mentions the status', + ); + }); + + test('reports a clear message when delegation is disabled', async () => { + let sessions = stubSessions({ + throws: new DelegatedUserRealmSessionError('disabled', 'off'), + }); + let { fetch, calls } = recordingFetch( + () => new Response('', { status: 200 }), + ); + let result = await executeReadRealmFile( + { realm: REALM, url: FILE_URL }, + { onBehalfOf: ON_BEHALF_OF, delegatedUserRealmSessions: sessions, fetch }, + ); + assert.false(result.ok); + assert.true( + (result as { ok: false; error: string }).error.includes('unavailable'), + 'error explains the feature is off', + ); + assert.strictEqual(calls.length, 0, 'never fetched without a token'); + }); + + test('reports no-access when the user lacks read on the realm', async () => { + let sessions = stubSessions({ + throws: new DelegatedUserRealmSessionError('forbidden', 'nope', 403), + }); + let { fetch } = recordingFetch(() => new Response('', { status: 200 })); + let result = await executeReadRealmFile( + { realm: REALM, url: FILE_URL }, + { onBehalfOf: ON_BEHALF_OF, delegatedUserRealmSessions: sessions, fetch }, + ); + assert.false(result.ok); + assert.true( + (result as { ok: false; error: string }).error.includes('no read access'), + 'error explains the access problem', + ); + }); + + test('invalidates and retries once when a cached token is rejected', async () => { + let sessions = stubSessions({ token: 'tok' }); + let n = 0; + let { fetch, calls } = recordingFetch(() => { + n += 1; + return n === 1 + ? new Response('stale', { status: 401 }) + : new Response('# Fresh', { status: 200 }); + }); + + let result = await executeReadRealmFile( + { realm: REALM, url: FILE_URL }, + { onBehalfOf: ON_BEHALF_OF, delegatedUserRealmSessions: sessions, fetch }, + ); + + assert.true(result.ok, 'succeeds on the retry'); + assert.strictEqual(calls.length, 2, 'fetched twice'); + assert.deepEqual( + sessions.invalidated, + [{ onBehalfOf: ON_BEHALF_OF, realm: REALM }], + 'dropped the stale token once', + ); + assert.strictEqual(sessions.calls.length, 2, 're-minted a fresh token'); + }); +}); + +function assistantMessage(toolCalls: any[]): any { + return { role: 'assistant', content: null, tool_calls: toolCalls }; +} + +function fnCall(id: string, name: string, args: object = {}) { + return { + id, + type: 'function', + function: { name, arguments: JSON.stringify(args) }, + }; +} + +module('classifyToolCalls', () => { + test('splits readRealmFile (bot) from everything else (host)', () => { + let { botToolCalls, hostToolCalls } = classifyToolCalls( + assistantMessage([ + fnCall('c1', READ_REALM_FILE_TOOL_NAME, { + realm: REALM, + url: FILE_URL, + }), + fnCall('c2', 'SomeHostCommand'), + ]), + ); + assert.deepEqual( + botToolCalls.map((c) => c.id), + ['c1'], + ); + assert.deepEqual( + hostToolCalls.map((c) => c.id), + ['c2'], + 'a host command and a read coexist — neither is dropped', + ); + }); + + test('no tool calls → both sets empty', () => { + let { botToolCalls, hostToolCalls } = classifyToolCalls( + assistantMessage([]), + ); + assert.deepEqual(botToolCalls, []); + assert.deepEqual(hostToolCalls, []); + }); +}); + +module('fileLabelFromUrl', () => { + test('keeps the skill folder for a SKILL.md', () => { + assert.strictEqual( + fileLabelFromUrl(FILE_URL), + 'trip-planner/SKILL.md', + 'a skill reads as /SKILL.md', + ); + }); + + test('falls back to the file name otherwise', () => { + assert.strictEqual( + fileLabelFromUrl('https://localhost:4201/user/jane/notes.md'), + 'notes.md', + ); + }); + + test('undefined url → undefined label', () => { + assert.strictEqual(fileLabelFromUrl(undefined), undefined); + }); +}); diff --git a/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts b/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts index 4703c4c706e..63b2118eb45 100644 --- a/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts +++ b/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts @@ -2,13 +2,13 @@ import QUnit from 'qunit'; const { module, test, assert } = QUnit; import { - requestDelegatedRealmSession, - verifyDelegatedRealmSessionRequest, - DelegatedRealmSessionError, - DELEGATED_REALM_SESSION_TIMESTAMP_HEADER, - DELEGATED_REALM_SESSION_SIGNATURE_HEADER, + requestDelegatedUserRealmSession, + verifyDelegatedUserRealmSessionRequest, + DelegatedUserRealmSessionError, + DELEGATED_USER_REALM_SESSION_TIMESTAMP_HEADER, + DELEGATED_USER_REALM_SESSION_SIGNATURE_HEADER, } from '@cardstack/runtime-common/user-delegated-realm-server-session'; -import { DelegatedRealmSessionManager } from '../lib/user-delegated-realm-server-session.ts'; +import { DelegatedUserRealmSessionManager } from '../lib/user-delegated-realm-server-session.ts'; const SECRET = 'shared-secret-under-test'; const ON_BEHALF_OF = '@example-user:boxel.ai'; @@ -62,7 +62,7 @@ module('delegated realm session client', () => { }); }); - await requestDelegatedRealmSession({ + await requestDelegatedUserRealmSession({ realmServerURL: 'https://realm.example.com', secret: SECRET, onBehalfOf: ON_BEHALF_OF, @@ -72,16 +72,16 @@ module('delegated realm session client', () => { }); let headers = captured!.headers as Record; - let result = verifyDelegatedRealmSessionRequest({ + let result = verifyDelegatedUserRealmSessionRequest({ secret: SECRET, - timestamp: headers[DELEGATED_REALM_SESSION_TIMESTAMP_HEADER], - signature: headers[DELEGATED_REALM_SESSION_SIGNATURE_HEADER], + timestamp: headers[DELEGATED_USER_REALM_SESSION_TIMESTAMP_HEADER], + signature: headers[DELEGATED_USER_REALM_SESSION_SIGNATURE_HEADER], rawBody: captured!.body as string, now, }); assert.true(result.ok, 'server verifier accepts the client signature'); assert.strictEqual( - headers[DELEGATED_REALM_SESSION_TIMESTAMP_HEADER], + headers[DELEGATED_USER_REALM_SESSION_TIMESTAMP_HEADER], String(now), 'timestamp header carries the signing time', ); @@ -95,7 +95,7 @@ module('delegated realm session client', () => { permissions: ['read'], }), ); - await requestDelegatedRealmSession({ + await requestDelegatedUserRealmSession({ realmServerURL: 'https://realm.example.com', secret: SECRET, onBehalfOf: ON_BEHALF_OF, @@ -110,7 +110,7 @@ module('delegated realm session client', () => { assert.strictEqual(calls[0].init.method, 'POST'); }); - test('maps status codes to typed DelegatedRealmSessionError kinds', async () => { + test('maps status codes to typed DelegatedUserRealmSessionError kinds', async () => { let cases: { status: number; kind: string }[] = [ { status: 503, kind: 'disabled' }, { status: 403, kind: 'forbidden' }, @@ -121,7 +121,7 @@ module('delegated realm session client', () => { for (let { status, kind } of cases) { let { fetch } = recordingFetch(() => new Response('nope', { status })); try { - await requestDelegatedRealmSession({ + await requestDelegatedUserRealmSession({ realmServerURL: 'https://realm.example.com', secret: SECRET, onBehalfOf: ON_BEHALF_OF, @@ -132,11 +132,11 @@ module('delegated realm session client', () => { assert.true(false, `expected ${status} to throw`); } catch (e) { assert.true( - e instanceof DelegatedRealmSessionError, - `${status} → DelegatedRealmSessionError`, + e instanceof DelegatedUserRealmSessionError, + `${status} → DelegatedUserRealmSessionError`, ); assert.strictEqual( - (e as DelegatedRealmSessionError).kind, + (e as DelegatedUserRealmSessionError).kind, kind, `${status} → ${kind}`, ); @@ -145,16 +145,19 @@ module('delegated realm session client', () => { }); }); -module('DelegatedRealmSessionManager', () => { +module('DelegatedUserRealmSessionManager', () => { test('is disabled and throws when no secret is configured', async () => { - let manager = new DelegatedRealmSessionManager(undefined); + let manager = new DelegatedUserRealmSessionManager(undefined); assert.false(manager.enabled, 'manager reports disabled'); try { await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); assert.true(false, 'expected getToken to throw'); } catch (e) { - assert.true(e instanceof DelegatedRealmSessionError); - assert.strictEqual((e as DelegatedRealmSessionError).kind, 'disabled'); + assert.true(e instanceof DelegatedUserRealmSessionError); + assert.strictEqual( + (e as DelegatedUserRealmSessionError).kind, + 'disabled', + ); } }); @@ -168,7 +171,7 @@ module('DelegatedRealmSessionManager', () => { permissions: ['read'], }), ); - let manager = new DelegatedRealmSessionManager(SECRET, { + let manager = new DelegatedUserRealmSessionManager(SECRET, { fetch, now: () => now, }); @@ -191,7 +194,7 @@ module('DelegatedRealmSessionManager', () => { permissions: ['read'], }), ); - let manager = new DelegatedRealmSessionManager(SECRET, { + let manager = new DelegatedUserRealmSessionManager(SECRET, { fetch, now: () => now, }); @@ -211,7 +214,7 @@ module('DelegatedRealmSessionManager', () => { permissions: ['read'], }), ); - let manager = new DelegatedRealmSessionManager(SECRET, { + let manager = new DelegatedUserRealmSessionManager(SECRET, { fetch, now: () => now, }); @@ -238,7 +241,7 @@ module('DelegatedRealmSessionManager', () => { permissions: ['read'], }), ); - let manager = new DelegatedRealmSessionManager(SECRET, { + let manager = new DelegatedUserRealmSessionManager(SECRET, { fetch, now: () => now, }); @@ -263,7 +266,7 @@ module('DelegatedRealmSessionManager', () => { permissions: ['read'], }), ); - let manager = new DelegatedRealmSessionManager(SECRET, { + let manager = new DelegatedUserRealmSessionManager(SECRET, { fetch, now: () => now, }); diff --git a/packages/host/app/components/ai-assistant/action-bar.gts b/packages/host/app/components/ai-assistant/action-bar.gts index 9fd476c1e3f..3466ecb2a3a 100644 --- a/packages/host/app/components/ai-assistant/action-bar.gts +++ b/packages/host/app/components/ai-assistant/action-bar.gts @@ -98,6 +98,7 @@ export default class AiAssistantActionBar extends Component { border-top-left-radius: var(--chat-input-area-border-radius); align-items: center; border: 1px solid #777; + border-top: none; position: relative; z-index: 1; diff --git a/packages/host/app/lib/command-auto-execute.ts b/packages/host/app/lib/command-auto-execute.ts index 5488edff1ab..242e9a8cded 100644 --- a/packages/host/app/lib/command-auto-execute.ts +++ b/packages/host/app/lib/command-auto-execute.ts @@ -1,3 +1,4 @@ +import { AI_BOT_EXECUTOR } from '@cardstack/runtime-common/commands'; import type { LLMMode } from '@cardstack/runtime-common/matrix-constants'; import type MessageCommand from './matrix-classes/message-command'; @@ -18,13 +19,18 @@ export const CHECK_CORRECTNESS_COMMAND_NAME = 'checkCorrectness'; // agents (e.g. unit tests) can pass `true` to focus on the other // conditions. export function isAutoExecutableCommand( - command: Pick, + command: Pick, activeLLMMode: LLMMode | undefined, isOwnedByCurrentAgent: boolean, ): boolean { if (!isOwnedByCurrentAgent) { return false; } + // ai-bot ran this one itself (e.g. readRealmFile); the host only records it + // in the timeline and never executes it. + if (command.executedBy === AI_BOT_EXECUTOR) { + return false; + } if (command.name === CHECK_CORRECTNESS_COMMAND_NAME) { return true; } diff --git a/packages/host/app/lib/matrix-classes/message-builder.ts b/packages/host/app/lib/matrix-classes/message-builder.ts index 97526371770..a436356b844 100644 --- a/packages/host/app/lib/matrix-classes/message-builder.ts +++ b/packages/host/app/lib/matrix-classes/message-builder.ts @@ -13,7 +13,10 @@ import { } from '@cardstack/runtime-common'; import type { CommandRequest } from '@cardstack/runtime-common/commands'; -import { decodeCommandRequest } from '@cardstack/runtime-common/commands'; +import { + AI_BOT_EXECUTOR, + decodeCommandRequest, +} from '@cardstack/runtime-common/commands'; import { APP_BOXEL_COMMAND_REQUESTS_KEY, APP_BOXEL_COMMAND_RESULT_EVENT_TYPE, @@ -331,6 +334,29 @@ export default class MessageBuilder { ); }) as CommandResultEvent | undefined); + // ai-bot ran this one itself (e.g. readRealmFile), so the host never + // resolves a command class or runs it. Skip the skill lookup below — it's + // pure async churn here (an `await store.get` per enabled skill) that would + // leave the indicator blank for a beat while it runs. Build the command + // synchronously: 'applying' (loading) until the result event lands, then + // applied (success) or invalid + reason (failure). + if (commandRequest.executedBy === AI_BOT_EXECUTOR) { + return new MessageCommand( + message, + commandRequest, + undefined, // no codeRef — never run on the host + this.builderContext.effectiveEventId, + false, // requiresApproval — never prompts or runs + 'Apply', // actionVerb — unused; the indicator shows status, not a Run button + (commandResultEvent + ? commandResultEvent.content['m.relates_to']?.key || 'applied' + : 'applying') as CommandStatus, + undefined, // no result card (server-handled results carry no output) + getOwner(this)!, + commandResultEvent?.content.failureReason, + ); + } + // Find command in skills let skillCommand: | { codeRef: ResolvedCodeRef; requiresApproval: boolean } @@ -361,6 +387,10 @@ export default class MessageBuilder { let requiresApproval = skillCommand?.requiresApproval ?? true; + let commandStatus: CommandStatus = (commandResultEvent?.content[ + 'm.relates_to' + ]?.key || 'ready') as CommandStatus; + let messageCommand = new MessageCommand( message, commandRequest, @@ -368,8 +398,7 @@ export default class MessageBuilder { this.builderContext.effectiveEventId, requiresApproval, actionVerb, - (commandResultEvent?.content['m.relates_to']?.key || - 'ready') as CommandStatus, + commandStatus, commandResultEvent?.content.msgtype === APP_BOXEL_COMMAND_RESULT_WITH_OUTPUT_MSGTYPE ? commandResultEvent.content.data.card diff --git a/packages/host/app/lib/matrix-classes/message-command.ts b/packages/host/app/lib/matrix-classes/message-command.ts index 8a64cf83678..2cbd5dfe7ff 100644 --- a/packages/host/app/lib/matrix-classes/message-command.ts +++ b/packages/host/app/lib/matrix-classes/message-command.ts @@ -54,6 +54,12 @@ export default class MessageCommand { return this.commandRequest.name; } + // The actor that already executed this tool call (e.g. 'ai-bot' for + // readRealmFile). When set, the host records it in the timeline but never runs it. + get executedBy() { + return this.commandRequest.executedBy; + } + get arguments() { return this.commandRequest.arguments; } diff --git a/packages/host/app/services/command-service.ts b/packages/host/app/services/command-service.ts index 24d53d9d642..4885df36311 100644 --- a/packages/host/app/services/command-service.ts +++ b/packages/host/app/services/command-service.ts @@ -24,6 +24,7 @@ import { type PatchData, } from '@cardstack/runtime-common'; +import { AI_BOT_EXECUTOR } from '@cardstack/runtime-common/commands'; import { basicMappings } from '@cardstack/runtime-common/helpers/ai'; import { APP_BOXEL_COMMAND_REQUESTS_KEY } from '@cardstack/runtime-common/matrix-constants'; @@ -379,6 +380,13 @@ export default class CommandService extends Service { // Collect all ready commands for this message let readyCommands: any[] = []; for (let messageCommand of message.commands) { + // ai-bot ran this one itself (e.g. readRealmFile). The host neither + // validates nor runs it — it has no command class to resolve, and the + // bot posts its own result. Must come before validate(), which would + // otherwise mark it "No command found". + if (messageCommand.executedBy === AI_BOT_EXECUTOR) { + continue; + } if ( this.currentlyExecutingCommandRequestIds.has(messageCommand.id!) ) { @@ -456,6 +464,11 @@ export default class CommandService extends Service { message.eventId, ); for (let messageCommand of message.commands) { + // ai-bot ran this one itself (e.g. readRealmFile): not the host's to run, + // so not the host's to invalidate when processing wedges. + if (messageCommand.executedBy === AI_BOT_EXECUTOR) { + continue; + } let commandRequestId = messageCommand.commandRequest.id; // Without a tool call id we can't address a command result event, so // there's nothing to invalidate. @@ -660,6 +673,11 @@ export default class CommandService extends Service { //TODO: Convert to non-EC async method after fixing CS-6987 run = task(async (command: MessageCommand) => { + // ai-bot ran this one itself (e.g. readRealmFile): nothing for the host to + // run. Guards the manual "Try Anyway" path as well as any auto-execution. + if (command.executedBy === AI_BOT_EXECUTOR) { + return; + } let { arguments: payload, id: commandRequestId } = command; // CS-11045: Source the bot-message event_id from current room state at // execute time rather than the snapshot taken when the MessageCommand was @@ -773,6 +791,11 @@ export default class CommandService extends Service { async validate(command: MessageCommand): Promise { let error: string | undefined; + // ai-bot ran this one itself (e.g. readRealmFile): the host has no command + // class to resolve, and never runs it, so there is nothing to validate. + if (command.executedBy === AI_BOT_EXECUTOR) { + return false; + } if (!command.name) { console.warn( `Command with id ${command.id} has no name, skipping validation`, diff --git a/packages/host/tests/unit/lib/command-auto-execute-test.ts b/packages/host/tests/unit/lib/command-auto-execute-test.ts index 5cca7448f8d..6869c296f05 100644 --- a/packages/host/tests/unit/lib/command-auto-execute-test.ts +++ b/packages/host/tests/unit/lib/command-auto-execute-test.ts @@ -10,8 +10,9 @@ type AutoExecCommandInput = Parameters[0]; function cmd( name: string | undefined, requiresApproval = true, + executedBy: string | undefined = undefined, ): AutoExecCommandInput { - return { name, requiresApproval }; + return { name, requiresApproval, executedBy }; } module('Unit | Lib | command-auto-execute', function () { @@ -59,6 +60,41 @@ module('Unit | Lib | command-auto-execute', function () { ); }); + test('commands already executed by a server-side actor never auto-execute', function (assert) { + // readRealmFile and friends are run by ai-bot itself; the host records them + // in the timeline but must never execute them, even in act mode or with + // requiresApproval=false. + assert.false( + isAutoExecutableCommand( + cmd('readRealmFile', false, 'ai-bot'), + 'act', + true, + ), + 'executedBy overrides requiresApproval=false', + ); + assert.false( + isAutoExecutableCommand( + cmd('readRealmFile', true, 'ai-bot'), + 'act', + true, + ), + 'executedBy overrides act mode', + ); + }); + + test('a non-ai-bot executor is not treated as bot-executed', function (assert) { + // The guard matches ai-bot's own executor explicitly, not any value — a + // command executed by the host (or any other actor) is evaluated normally. + assert.true( + isAutoExecutableCommand( + cmd('patchCardInstance', true, 'host'), + 'act', + true, + ), + "executedBy: 'host' does not short-circuit; act mode still auto-executes", + ); + }); + test('commands owned by another agent never auto-execute', function (assert) { // Mirrors the agentId gate in command-service.drainCommandProcessingQueue: // a command whose message came from a different agent must not auto-run diff --git a/packages/realm-server/handlers/handle-delegate-session.ts b/packages/realm-server/handlers/handle-delegate-session.ts index bfcc56665ba..35a18bb3ea4 100644 --- a/packages/realm-server/handlers/handle-delegate-session.ts +++ b/packages/realm-server/handlers/handle-delegate-session.ts @@ -17,9 +17,9 @@ import { setContextResponse, } from '../middleware/index.ts'; import { - DELEGATED_REALM_SESSION_SIGNATURE_HEADER, - DELEGATED_REALM_SESSION_TIMESTAMP_HEADER, - verifyDelegatedRealmSessionRequest, + DELEGATED_USER_REALM_SESSION_SIGNATURE_HEADER, + DELEGATED_USER_REALM_SESSION_TIMESTAMP_HEADER, + verifyDelegatedUserRealmSessionRequest, } from '@cardstack/runtime-common/user-delegated-realm-server-session'; // Token lifetime per the v1 security design (CS-11551): 30 minutes. Long @@ -58,10 +58,10 @@ export default function handleDelegateSession({ let request = await fetchRequestFromContext(ctxt); let rawBody = await request.text(); - let auth = verifyDelegatedRealmSessionRequest({ + let auth = verifyDelegatedUserRealmSessionRequest({ secret: aiBotDelegationSecret, - timestamp: ctxt.get(DELEGATED_REALM_SESSION_TIMESTAMP_HEADER), - signature: ctxt.get(DELEGATED_REALM_SESSION_SIGNATURE_HEADER), + timestamp: ctxt.get(DELEGATED_USER_REALM_SESSION_TIMESTAMP_HEADER), + signature: ctxt.get(DELEGATED_USER_REALM_SESSION_SIGNATURE_HEADER), rawBody, now: Date.now(), }); diff --git a/packages/realm-server/tests/server-endpoints/delegate-session-test.ts b/packages/realm-server/tests/server-endpoints/delegate-session-test.ts index 6bd3c165add..aac52023eb1 100644 --- a/packages/realm-server/tests/server-endpoints/delegate-session-test.ts +++ b/packages/realm-server/tests/server-endpoints/delegate-session-test.ts @@ -14,9 +14,9 @@ import { testRealmURL, } from '../helpers/index.ts'; import { - DELEGATED_REALM_SESSION_SIGNATURE_HEADER, - DELEGATED_REALM_SESSION_TIMESTAMP_HEADER, - delegatedRealmSessionSignature, + DELEGATED_USER_REALM_SESSION_SIGNATURE_HEADER, + DELEGATED_USER_REALM_SESSION_TIMESTAMP_HEADER, + delegatedUserRealmSessionSignature, } from '@cardstack/runtime-common/user-delegated-realm-server-session'; const onBehalfOf = '@jane:localhost'; @@ -37,7 +37,7 @@ function signedPost( let timestamp = String(opts.timestamp ?? Date.now()); let signature = opts.signature ?? - delegatedRealmSessionSignature( + delegatedUserRealmSessionSignature( opts.secret ?? aiBotDelegationSecret, timestamp, rawBody, @@ -46,10 +46,10 @@ function signedPost( .post('/_delegate-session') .set('Content-Type', 'application/json'); if (!opts.omitTimestamp) { - req = req.set(DELEGATED_REALM_SESSION_TIMESTAMP_HEADER, timestamp); + req = req.set(DELEGATED_USER_REALM_SESSION_TIMESTAMP_HEADER, timestamp); } if (!opts.omitSignature) { - req = req.set(DELEGATED_REALM_SESSION_SIGNATURE_HEADER, signature); + req = req.set(DELEGATED_USER_REALM_SESSION_SIGNATURE_HEADER, signature); } return req.send(rawBody); } diff --git a/packages/runtime-common/commands.ts b/packages/runtime-common/commands.ts index e5b86fa0e96..d3497f7d64e 100644 --- a/packages/runtime-common/commands.ts +++ b/packages/runtime-common/commands.ts @@ -12,10 +12,20 @@ import { generateJsonSchemaForCardType } from './helpers/ai.ts'; import { simpleHash } from './utils.ts'; import type { EncodedCommandRequest } from '../base/matrix-event.gts'; +// `executedBy` value for tool calls ai-bot runs itself in-process (e.g. +// readRealmFile). +export const AI_BOT_EXECUTOR = 'ai-bot'; + export interface CommandRequest { id: string; name: string; arguments: { [key: string]: any }; + // Names the actor that ran (or will run) this tool call — e.g. AI_BOT_EXECUTOR + // for tools ai-bot executes itself. It's a value (not a boolean) so it can + // identify *which* actor in a multi-bot / multi-user room, and so the field + // can later carry e.g. 'host' too. The host therefore matches its own + // executor explicitly rather than treating any value as "not mine to run". + executedBy?: string; } export const CommandContextStamp = Symbol.for('CommandContext'); @@ -170,6 +180,9 @@ export function decodeCommandRequest( // ignore malformed nested json; validation will report a clearer error later } } + if (commandRequest.executedBy != null) { + decodedCommandRequest.executedBy = commandRequest.executedBy; + } return decodedCommandRequest; } @@ -190,6 +203,9 @@ export function encodeCommandRequest( if (commandRequest.arguments) { encodedCommandRequest.arguments = JSON.stringify(commandRequest.arguments); } + if (commandRequest.executedBy != null) { + encodedCommandRequest.executedBy = commandRequest.executedBy; + } return encodedCommandRequest; } diff --git a/packages/runtime-common/user-delegated-realm-server-session.ts b/packages/runtime-common/user-delegated-realm-server-session.ts index b8e85f1b970..00ac82da092 100644 --- a/packages/runtime-common/user-delegated-realm-server-session.ts +++ b/packages/runtime-common/user-delegated-realm-server-session.ts @@ -10,8 +10,8 @@ import { ensureTrailingSlash } from './paths.ts'; // // This module is the single source of truth for the signed-payload format. // Both sides import from here so they can never drift: ai-bot calls -// `requestDelegatedRealmSession`/`delegatedRealmSessionSignature` to sign, and the realm -// server's /_delegate-session handler calls `verifyDelegatedRealmSessionRequest` to +// `requestDelegatedUserRealmSession`/`delegatedUserRealmSessionSignature` to sign, and the realm +// server's /_delegate-session handler calls `verifyDelegatedUserRealmSessionRequest` to // verify — neither keeps its own copy of the canonical `${timestamp}.${rawBody}` // construction. It is imported via the // `@cardstack/runtime-common/user-delegated-realm-server-session` subpath by @@ -21,21 +21,21 @@ import { ensureTrailingSlash } from './paths.ts'; // Wire header names are kept as the original `x-boxel-delegation-*` that the // realm-server endpoint (#5287) already ships, so this change is code-only and // never alters the on-the-wire protocol. -export const DELEGATED_REALM_SESSION_TIMESTAMP_HEADER = +export const DELEGATED_USER_REALM_SESSION_TIMESTAMP_HEADER = 'x-boxel-delegation-timestamp'; -export const DELEGATED_REALM_SESSION_SIGNATURE_HEADER = +export const DELEGATED_USER_REALM_SESSION_SIGNATURE_HEADER = 'x-boxel-delegation-signature'; // ±60s window on the request timestamp. Cheap and stateless — it bounds the // replay window for a captured request without a server-side nonce store. -export const DELEGATED_REALM_SESSION_TIMESTAMP_WINDOW_MS = 60_000; +export const DELEGATED_USER_REALM_SESSION_TIMESTAMP_WINDOW_MS = 60_000; // The canonical string both sides sign: `${timestamp}.${rawBody}`. `timestamp` // is epoch milliseconds in base-10; `rawBody` is the exact request body bytes. // HMAC-SHA256 with the shared secret, hex digest. Binding the timestamp into // the signed payload is what makes the ±60s window enforceable — a captured // request cannot have its timestamp rewritten without the secret. -export function delegatedRealmSessionSignature( +export function delegatedUserRealmSessionSignature( secret: string, timestamp: string, rawBody: string, @@ -45,11 +45,11 @@ export function delegatedRealmSessionSignature( .digest('hex'); } -export type DelegatedRealmSessionAuthResult = +export type DelegatedUserRealmSessionAuthResult = | { ok: true } | { ok: false; reason: string }; -export function verifyDelegatedRealmSessionRequest({ +export function verifyDelegatedUserRealmSessionRequest({ secret, timestamp, signature, @@ -61,7 +61,7 @@ export function verifyDelegatedRealmSessionRequest({ signature: string | undefined; rawBody: string; now: number; -}): DelegatedRealmSessionAuthResult { +}): DelegatedUserRealmSessionAuthResult { if (!timestamp || !signature) { return { ok: false, @@ -72,13 +72,13 @@ export function verifyDelegatedRealmSessionRequest({ if (!Number.isFinite(ts)) { return { ok: false, reason: 'malformed delegation timestamp' }; } - if (Math.abs(now - ts) > DELEGATED_REALM_SESSION_TIMESTAMP_WINDOW_MS) { + if (Math.abs(now - ts) > DELEGATED_USER_REALM_SESSION_TIMESTAMP_WINDOW_MS) { return { ok: false, reason: 'delegation timestamp is outside the allowed window', }; } - let expected = delegatedRealmSessionSignature(secret, timestamp, rawBody); + let expected = delegatedUserRealmSessionSignature(secret, timestamp, rawBody); let expectedBuf = Buffer.from(expected, 'utf8'); let providedBuf = Buffer.from(signature, 'utf8'); // Constant-time compare. timingSafeEqual throws on a length mismatch, so @@ -95,7 +95,7 @@ export function verifyDelegatedRealmSessionRequest({ // ─── Client ────────────────────────────────────────────────────────────── -export interface DelegatedRealmSession { +export interface DelegatedUserRealmSession { token: string; realm: string; permissions: string[]; @@ -105,31 +105,31 @@ export interface DelegatedRealmSession { // (the realm server has no secret configured, 503) is a "feature is off, carry // on" signal, whereas `forbidden` (the user has no read access, 403) and the // auth failures are genuine errors worth surfacing. -export type DelegatedRealmSessionErrorKind = +export type DelegatedUserRealmSessionErrorKind = | 'disabled' // 503: endpoint not configured on the realm server | 'forbidden' // 403: onBehalfOf lacks read on the realm | 'unauthorized' // 401: signature/timestamp rejected | 'bad-request' // 400: malformed request | 'unexpected'; // anything else -export class DelegatedRealmSessionError extends Error { - readonly kind: DelegatedRealmSessionErrorKind; +export class DelegatedUserRealmSessionError extends Error { + readonly kind: DelegatedUserRealmSessionErrorKind; readonly status?: number; constructor( - kind: DelegatedRealmSessionErrorKind, + kind: DelegatedUserRealmSessionErrorKind, message: string, status?: number, ) { super(message); - this.name = 'DelegatedRealmSessionError'; + this.name = 'DelegatedUserRealmSessionError'; this.kind = kind; this.status = status; } } -function delegatedRealmSessionErrorKindForStatus( +function delegatedUserRealmSessionErrorKindForStatus( status: number, -): DelegatedRealmSessionErrorKind { +): DelegatedUserRealmSessionErrorKind { switch (status) { case 503: return 'disabled'; @@ -151,7 +151,7 @@ function delegatedRealmSessionErrorKindForStatus( // `realmServerURL` is the origin of the realm server that fronts `realm` // (ai-bot derives it as `new URL(realm).origin`). `now` and `fetch` are // injectable for tests. -export async function requestDelegatedRealmSession({ +export async function requestDelegatedUserRealmSession({ realmServerURL, secret, onBehalfOf, @@ -165,14 +165,18 @@ export async function requestDelegatedRealmSession({ realm: string; fetch?: typeof globalThis.fetch; now?: number; -}): Promise { +}): Promise { let endpoint = new URL( '_delegate-session', ensureTrailingSlash(realmServerURL), ); let rawBody = JSON.stringify({ onBehalfOf, realm }); let timestamp = String(now); - let signature = delegatedRealmSessionSignature(secret, timestamp, rawBody); + let signature = delegatedUserRealmSessionSignature( + secret, + timestamp, + rawBody, + ); let response: Response; try { @@ -180,13 +184,13 @@ export async function requestDelegatedRealmSession({ method: 'POST', headers: { 'content-type': 'application/json', - [DELEGATED_REALM_SESSION_TIMESTAMP_HEADER]: timestamp, - [DELEGATED_REALM_SESSION_SIGNATURE_HEADER]: signature, + [DELEGATED_USER_REALM_SESSION_TIMESTAMP_HEADER]: timestamp, + [DELEGATED_USER_REALM_SESSION_SIGNATURE_HEADER]: signature, }, body: rawBody, }); } catch (e: any) { - throw new DelegatedRealmSessionError( + throw new DelegatedUserRealmSessionError( 'unexpected', `delegation request to ${endpoint.href} failed: ${e?.message ?? e}`, ); @@ -194,8 +198,8 @@ export async function requestDelegatedRealmSession({ if (!response.ok) { let detail = await response.text().catch(() => ''); - throw new DelegatedRealmSessionError( - delegatedRealmSessionErrorKindForStatus(response.status), + throw new DelegatedUserRealmSessionError( + delegatedUserRealmSessionErrorKindForStatus(response.status), `delegation request rejected (${response.status})${ detail ? `: ${detail}` : '' }`, @@ -203,18 +207,18 @@ export async function requestDelegatedRealmSession({ ); } - let session: DelegatedRealmSession; + let session: DelegatedUserRealmSession; try { - session = (await response.json()) as DelegatedRealmSession; + session = (await response.json()) as DelegatedUserRealmSession; } catch { - throw new DelegatedRealmSessionError( + throw new DelegatedUserRealmSessionError( 'unexpected', 'delegation response was not valid JSON', response.status, ); } if (!session?.token) { - throw new DelegatedRealmSessionError( + throw new DelegatedUserRealmSessionError( 'unexpected', 'delegation response did not include a token', response.status,