diff --git a/CONTEXT.md b/CONTEXT.md index b8cf0a4d6..228e202cd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -535,12 +535,13 @@ _Avoid_: mini program, app, widget **Mini App view**: The client area that renders a Mini App's page. It is either a view inside the workspace panel or -the whole content of a browser tab opened for one Mini App. +the whole content of a browser tab opened for one Mini App. Both are the client's choice: a Mini App +page cannot ask to be shown, moved or detached. _Avoid_: surface (that word names a client identity here), pane, frame **Mini App bridge**: The page-facing API a Mini App page uses to call the client hosting it: appending a draft to the -composer, running or answering an agent turn, and reading the host's window state. +composer, and running or answering an agent turn. Presentation is not part of it. _Avoid_: API, interface, plugin API **Mini App process**: @@ -548,6 +549,19 @@ The local process that serves one Mini App's page on a loopback port. The client and does not own its state. _Avoid_: runtime (that names the harness side), server, container +**Mini App proxy**: +The loopback listener the service puts between a Mini App's process and the client's view. The +Mini App serves its own page under a credential its own guard demands; the client frames the proxy's +URL instead, and the proxy injects that credential on the way out. The process's origin and the +credential are server-only, so an operation answers with the proxy and never with the process. +_Avoid_: gateway, tunnel, port forward + +**Mini App presentation lease**: +The record of which client document is currently showing one Mini App, held by the service because +two presentations of the same Mini App are two documents that share nothing but that service. It is +released when the document's connection closes. +_Avoid_: session, tab state, presence + ### Runtime ownership **Runtime owner**: diff --git a/packages/local-runtime-v2/src/application/session/lifecycle-application.ts b/packages/local-runtime-v2/src/application/session/lifecycle-application.ts index 859fbb5aa..602f3119f 100644 --- a/packages/local-runtime-v2/src/application/session/lifecycle-application.ts +++ b/packages/local-runtime-v2/src/application/session/lifecycle-application.ts @@ -550,6 +550,7 @@ const LIFECYCLE_FAILURES: Readonly< "workspace-required": { status: 400, key: "WORKSPACE_REQUIRED" }, "parent-not-found": { status: 404, key: "SESSION_PARENT_NOT_FOUND" }, "invalid-session-kind": { status: 400, key: "INVALID_SESSION_KIND" }, + "purpose-reserved": { status: 400, key: "SESSION_PURPOSE_RESERVED" }, "parent-required": { status: 400, key: "SESSION_PARENT_REQUIRED" }, "session-not-found": { status: 404, key: "SESSION_NOT_FOUND" }, "session-busy": { status: 409, key: "SESSION_BUSY" }, diff --git a/packages/local-runtime-v2/src/application/session/miniapp-actions.ts b/packages/local-runtime-v2/src/application/session/miniapp-actions.ts index 8073a2315..04939a9bd 100644 --- a/packages/local-runtime-v2/src/application/session/miniapp-actions.ts +++ b/packages/local-runtime-v2/src/application/session/miniapp-actions.ts @@ -444,6 +444,8 @@ const PREPARATION_REASON_BY_HOST_CODE: Readonly & { modalities?: { input?: readonly string[]; output?: readonly string[] }; }; @@ -46,6 +61,11 @@ export interface ProcessLocalMiniAppLaunch { readonly origin: string; readonly surfacePath: string; readonly runId: string; + /** + * The credential the Mini App's own guard demands. Server-only, like the rest of this object: the + * proxy injects it, and no renderer and no published view ever carries it. + */ + readonly credential: string; }; } @@ -132,6 +152,15 @@ export interface LocalRuntimeApplication { }; readonly miniApps?: { list(): Promise<{ readonly miniApps: readonly MiniAppSurfaceSummary[] }>; + /** + * The same rows, including a package that is installed but disabled. + * + * `list` answers what the runtime can start; this answers what is installed, + * with `enabled` on each row, so a management surface can offer to turn a + * disabled Mini App back on. Kept apart rather than folded into `list` so no + * existing consumer starts seeing packages it cannot start. + */ + listInstalled(): Promise<{ readonly miniApps: readonly MiniAppSurfaceSummary[] }>; open(input: { readonly pluginId: string; readonly signal?: AbortSignal; @@ -141,6 +170,62 @@ export interface LocalRuntimeApplication { readonly signal?: AbortSignal; }): Promise; }; + /** + * Page-path entry for the Mini App Agent API. The Host resolves `caller` from the tab that sent + * the request; the page payload never carries identity. `runId` is the launch `runId` (= process + * generation) and must match a registered arena; `hostSessionId` is the chat session the tab is + * bound to, when any. + */ + readonly miniAppAgent?: { + run(input: { + readonly caller: ProcessLocalMiniAppAgentCaller; + readonly text: string; + readonly sessionId?: string; + /** Only with a new session: the agent it is created for. */ + readonly agent?: string; + /** Switches the session to this model before the turn. */ + readonly model?: MiniAppAgentModelSelection; + readonly signal?: AbortSignal; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; + }): Promise; + models(input: { readonly caller: ProcessLocalMiniAppAgentCaller }): Promise; + agents(input: { readonly caller: ProcessLocalMiniAppAgentCaller }): Promise; + /** Creates a session bound like a first `run` would, without running a turn. */ + create(input: { + readonly caller: ProcessLocalMiniAppAgentCaller; + readonly agent?: string; + }): Promise<{ readonly sessionId: string }>; + reply(input: { + readonly caller: ProcessLocalMiniAppAgentCaller; + readonly sessionId: string; + readonly requestId: string; + readonly decision: MiniAppAgentPermissionDecision; + }): Promise; + answer(input: { + readonly caller: ProcessLocalMiniAppAgentCaller; + readonly sessionId: string; + readonly requestId: string; + readonly answers: readonly AskQuestionnaireReplyAnswer[]; + /** Switches the session to this model before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; + readonly signal?: AbortSignal; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; + }): Promise; + /** Ownership + host check for showing a Mini App session in the host chat's side panel. */ + open(input: { + readonly caller: ProcessLocalMiniAppAgentCaller; + readonly sessionId: string; + }): Promise<{ readonly sessionId: string; readonly hostSessionId: string }>; + /** Host reports the side panel now shows / no longer shows a Mini App session (after its UI accepted). */ + panelOpened(input: { + readonly sessionId: string; + readonly hostSessionId: string; + }): Promise; + panelClosed(input: { + readonly sessionId: string; + readonly hostSessionId: string; + }): Promise; + }; readonly goals?: { isEnabled(): boolean; get(sessionId: string): Promise; diff --git a/packages/local-runtime-v2/src/application/session/process-local-application.test.ts b/packages/local-runtime-v2/src/application/session/process-local-application.test.ts index 70158d0c6..3f777aa30 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-application.test.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-application.test.ts @@ -53,6 +53,84 @@ describe("composeProcessLocalApplication workspace ownership", () => { }); }); +describe("composeProcessLocalApplication Mini App agent exposure", () => { + function compose(miniAppAgent: unknown) { + return composeProcessLocalApplication({ + eventBus: { subscribe: vi.fn(() => () => undefined) }, + usageCommits: undefined, + workspaceGit: { + getMetadata: vi.fn(), + getChanges: vi.fn(), + getReviewSummary: vi.fn(), + listReviewFileDiffs: vi.fn(), + getReviewFileContent: vi.fn(), + searchReviewDiffs: vi.fn(), + } as never, + compatibility: { + skills: { listSkills: vi.fn(), listRuntimeSkills: vi.fn() }, + peripherals: {}, + } as never, + listRuntimeSkills: vi.fn(), + plugins: {} as never, + pluginControl: {} as never, + miniApp: undefined, + miniAppAgent: miniAppAgent as never, + planEntryEnabled: () => false, + mcp: {} as never, + modelProvider: { + application: {} as never, + providers: {} as never, + listProviderPresets: vi.fn(), + oauth: {} as never, + }, + }); + } + + function gatewayDouble() { + return { + bind: vi.fn(), + registerArena: vi.fn(), + panelOpened: vi.fn(async () => undefined), + panelClosed: vi.fn(async () => undefined), + subscribePermissionAsks: vi.fn(), + run: vi.fn(async () => ({ sessionId: "s", status: "finished" as const })), + models: vi.fn(async () => ({ models: [], default: { providerId: "p", modelId: "m" } })), + agents: vi.fn(async () => ({ agents: [] })), + create: vi.fn(async () => ({ sessionId: "s" })), + reply: vi.fn(async () => undefined), + answer: vi.fn(async () => ({ sessionId: "s", status: "finished" as const })), + open: vi.fn(async () => ({ sessionId: "s", hostSessionId: "chat" })), + }; + } + + it("publishes the client operations without the gateway or its ports", () => { + const application = compose(gatewayDouble()); + + expect(Object.keys(application.miniAppAgent ?? {}).sort()).toEqual([ + "agents", + "answer", + "create", + "models", + "open", + "panelClosed", + "panelOpened", + "reply", + "run", + ]); + for (const internal of ["bind", "registerArena", "ports", "subscribePermissionAsks"]) { + expect(application.miniAppAgent).not.toHaveProperty(internal); + } + // Nothing else on the facade grew a hole into the gateway. + expect(JSON.stringify(application)).not.toContain("registerArena"); + }); + + it("publishes no agent API when the runtime has no gateway", () => { + const application = compose(undefined); + + expect(application.miniAppAgent).toBeUndefined(); + }); +}); + describe("SessionContentApplication history message serialization", () => { it("keeps ordered activity parts in the opaque rawJson channel", () => { const parts = [ diff --git a/packages/local-runtime-v2/src/application/session/process-local-composition.ts b/packages/local-runtime-v2/src/application/session/process-local-composition.ts index 6a3e0ad48..06b5ccf6f 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-composition.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-composition.ts @@ -1,10 +1,14 @@ import type { V1ServiceCompatibility } from '../../compat/v1/runtime.js'; import { createProcessLocalApplication } from '../index.js'; import { createProcessLocalMiniAppApplication } from './process-local-miniapp-application.js'; +import { createProcessLocalMiniAppAgentApplication } from './process-local-miniapp-agent-application.js'; import type { WorkspaceGitService } from '../../service/workspace/index.js'; type ProcessLocalApplicationOptions = Parameters[0]; type ProcessLocalMiniAppArgs = Parameters; +type ProcessLocalMiniAppAgentGateway = Parameters< + typeof createProcessLocalMiniAppAgentApplication +>[0]; /** * Wires the process-local Application facade over owners the runtime has already @@ -27,13 +31,14 @@ export function composeProcessLocalApplication(input: { readonly plugins: ProcessLocalApplicationOptions['plugins']; readonly pluginControl: ProcessLocalMiniAppArgs[0]; readonly miniApp: ProcessLocalMiniAppArgs[1]; + readonly miniAppAgent?: ProcessLocalMiniAppAgentGateway; readonly planEntryEnabled: () => boolean; readonly sessionReports?: ProcessLocalApplicationOptions['sessionReports']; readonly instructions?: ProcessLocalApplicationOptions['instructions']; readonly mcp: ProcessLocalApplicationOptions['peripherals']['mcp']; readonly modelProvider: ProcessLocalApplicationOptions['modelProvider']; }): ReturnType { - return createProcessLocalApplication({ + const application = createProcessLocalApplication({ eventBus: input.eventBus, usageCommits: input.usageCommits, skills: { @@ -70,4 +75,11 @@ export function composeProcessLocalApplication(input: { peripherals: { ...input.compatibility.peripherals, mcp: input.mcp }, modelProvider: input.modelProvider, }); + // The gateway is the runtime's private admission layer; only this projection reaches a client. + return input.miniAppAgent + ? { + ...application, + miniAppAgent: createProcessLocalMiniAppAgentApplication(input.miniAppAgent), + } + : application; } diff --git a/packages/local-runtime-v2/src/application/session/process-local-miniapp-agent-application.ts b/packages/local-runtime-v2/src/application/session/process-local-miniapp-agent-application.ts new file mode 100644 index 000000000..607fece48 --- /dev/null +++ b/packages/local-runtime-v2/src/application/session/process-local-miniapp-agent-application.ts @@ -0,0 +1,61 @@ +import type { MiniAppAgentCaller, MiniAppAgentGateway } from '../../service/miniapp/index.js'; +import type { LocalRuntimeApplication } from './process-local-application-contract.js'; + +type ProcessLocalMiniAppAgentApplication = NonNullable; +type PageCaller = Parameters[0]['caller']; + +function toGatewayCaller(caller: PageCaller): MiniAppAgentCaller { + return { + pluginId: caller.pluginId, + processGeneration: caller.runId, + source: 'page', + ...(caller.hostSessionId !== undefined ? { hostSessionId: caller.hostSessionId } : {}), + }; +} + +/** Page-path entry: the Electron bridge supplies the trusted tab identity, the gateway enforces policy. */ +export function createProcessLocalMiniAppAgentApplication( + gateway: Pick< + MiniAppAgentGateway, + | 'run' + | 'reply' + | 'answer' + | 'open' + | 'panelOpened' + | 'panelClosed' + | 'models' + | 'agents' + | 'create' + >, +): ProcessLocalMiniAppAgentApplication { + return { + run: ({ caller, text, sessionId, agent, model, signal, onEvent }) => + gateway.run( + toGatewayCaller(caller), + { + text, + ...(sessionId !== undefined ? { sessionId } : {}), + ...(agent !== undefined ? { agent } : {}), + ...(model !== undefined ? { model } : {}), + }, + { ...(signal ? { signal } : {}), ...(onEvent ? { onEvent } : {}) }, + ), + models: ({ caller }) => gateway.models(toGatewayCaller(caller)), + agents: ({ caller }) => gateway.agents(toGatewayCaller(caller)), + create: ({ caller, agent }) => + gateway.create(toGatewayCaller(caller), agent === undefined ? undefined : { agent }), + reply: ({ caller, sessionId, requestId, decision }) => + gateway.reply(toGatewayCaller(caller), { sessionId, requestId, decision }), + answer: ({ caller, sessionId, requestId, answers, model, signal, onEvent }) => + gateway.answer( + toGatewayCaller(caller), + { sessionId, requestId, answers, ...(model !== undefined ? { model } : {}) }, + { ...(signal ? { signal } : {}), ...(onEvent ? { onEvent } : {}) }, + ), + open: ({ caller, sessionId }) => gateway.open(toGatewayCaller(caller), { sessionId }), + panelOpened: async ({ sessionId, hostSessionId }) => + gateway.panelOpened({ sessionId, hostSessionId }), + panelClosed: async ({ sessionId, hostSessionId }) => + gateway.panelClosed({ sessionId, hostSessionId }), + }; +} diff --git a/packages/local-runtime-v2/src/application/session/process-local-miniapp-application.ts b/packages/local-runtime-v2/src/application/session/process-local-miniapp-application.ts index 01ef3a78a..43cf9ce35 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-miniapp-application.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-miniapp-application.ts @@ -2,6 +2,8 @@ import type { MiniAppSurfaceSummary } from '@mavis/shared/miniapp-surface'; import { miniAppBusyReason, + deriveMiniAppNodePort, + probeMiniAppPortOwnership, type MiniAppStatus, type MiniAppSupervisor, } from '../../service/miniapp/index.js'; @@ -19,7 +21,7 @@ type ProcessLocalMiniAppApplication = NonNullable | undefined, + supervisor: Pick | undefined, ): ProcessLocalMiniAppApplication | undefined { if (!supervisor) return undefined; @@ -44,12 +46,15 @@ export function createProcessLocalMiniAppApplication( ) { throw applicationError(status?.failureCode ?? 'RUNTIME_FAILED'); } + const credential = supervisor.launchCredential(miniApp.pluginId); + if (credential === undefined) throw applicationError('RUNTIME_FAILED'); return { miniApp: summary(miniApp, status), launch: { origin: active.origin, surfacePath: active.surfacePath, runId: active.processGeneration, + credential, }, }; }; @@ -71,13 +76,48 @@ export function createProcessLocalMiniAppApplication( return launch(miniApp, supervisor.inspect(pluginId)[0]); }; + const listRows = async ( + definitions: readonly AvailableMiniAppDefinition[], + statuses: ReadonlyMap, + ): Promise => + Promise.all( + definitions.map(async (miniApp) => { + const status = statuses.get(miniApp.pluginId); + if (status?.phase === 'active' || status?.phase === 'starting' || status?.phase === 'preparing') { + return summary(miniApp, status); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 500); + timer.unref(); + try { + const ownership = await probeMiniAppPortOwnership({ + port: deriveMiniAppNodePort(miniApp.pluginId), + timeoutMs: 500, + signal: controller.signal, + }); + const ownedElsewhere = ownership === 'guarded'; + const ownerClient = ownedElsewhere && supervisor.ownerClient + ? await supervisor.ownerClient(miniApp.pluginId).catch(() => undefined) + : undefined; + return summary(miniApp, status, ownedElsewhere, ownerClient); + } catch { + return summary(miniApp, status); + } finally { + clearTimeout(timer); + } + }), + ); + return { list: async () => { const definitions = await plugins.listAvailableMiniApps(); const statuses = new Map(supervisor.inspect().map((status) => [status.pluginId, status])); - return { - miniApps: definitions.map((miniApp) => summary(miniApp, statuses.get(miniApp.pluginId))), - }; + return { miniApps: await listRows(definitions, statuses) }; + }, + listInstalled: async () => { + const definitions = await plugins.listInstalledMiniApps(); + const statuses = new Map(supervisor.inspect().map((status) => [status.pluginId, status])); + return { miniApps: await listRows(definitions, statuses) }; }, open: ({ pluginId, signal }) => open(pluginId, signal), stop: async ({ pluginId, signal }) => { @@ -90,10 +130,13 @@ export function createProcessLocalMiniAppApplication( function summary( miniApp: AvailableMiniAppDefinition, status: MiniAppStatus | undefined, + ownedElsewhere = false, + ownerClient?: string, ): MiniAppSurfaceSummary { const runtimeStatus = mapRuntimeStatus(status?.phase); return { pluginId: miniApp.pluginId, + ...(miniApp.enabled !== undefined ? { enabled: miniApp.enabled } : {}), ...(miniApp.displayName ? { displayName: miniApp.displayName } : {}), ...(miniApp.description ? { description: miniApp.description } : {}), ...(miniApp.iconPath ? { iconUrl: localIconUrl(miniApp.iconPath) } : {}), @@ -103,6 +146,12 @@ function summary( ...(runtimeStatus === 'failed' && status?.failureCode ? { errorCode: status.failureCode } : {}), + ...(ownedElsewhere + ? { + ownership: 'other-client' as const, + ...(ownerClient ? { ownerClient } : {}), + } + : {}), }, }; } diff --git a/packages/local-runtime-v2/src/local/cli-service.ts b/packages/local-runtime-v2/src/local/cli-service.ts index 1ac22ecc9..f2ecb5678 100644 --- a/packages/local-runtime-v2/src/local/cli-service.ts +++ b/packages/local-runtime-v2/src/local/cli-service.ts @@ -118,6 +118,9 @@ export interface CliServiceOptions { export type CliSendMessageReq = ConversationSendMessageRequest; export type CliSendMessageOptions = ConversationSendOptions; +/** The runtime's Mini App Agent application, named once so the facade's signatures derive from it. */ +type MiniAppAgentCapability = NonNullable; + /** In-process CLI entry point: call local Application directly, without HTTP or RPC envelopes. */ export class CliService { constructor(private readonly options: CliServiceOptions) {} @@ -168,6 +171,100 @@ export class CliService { return { sessionId, workspaceRoot }; } + /** The Mini App listing the catalogue and the picker both read. */ + listMiniApps() { + return this.requireCapability("miniApps", "Mini App").list(); + } + + /** + * Every installed Mini App, enabled or not. + * + * The management read: `listMiniApps` answers what this host can start, and a + * disabled package is installed but not startable. A surface that shows the + * installed set — and offers to enable one of them — needs this one. + */ + listInstalledMiniApps() { + return this.requireCapability("miniApps", "Mini App").listInstalled(); + } + + /** + * Starts a Mini App and, when this host has a presenter, shows it. A forwarding call only: the + * application owns the lifecycle, and `launch` is server-only coordinates the caller must not forward + * to a renderer. + */ + openMiniApp(input: { readonly pluginId: string; readonly signal?: AbortSignal }) { + return this.requireCapability("miniApps", "Mini App").open(input); + } + + stopMiniApp(input: { readonly pluginId: string; readonly signal?: AbortSignal }) { + return this.requireCapability("miniApps", "Mini App").stop(input); + } + + /** + * The Mini App Agent API, as the page bridge's host half reaches it. + * + * A forwarding facade only: the application (`miniAppAgent`) already owns arena policy, + * per-plugin concurrency, session ownership and the turn algorithm, and every one of these + * methods is one call into it. What the caller must not do is spell identity — `caller` is the + * `{ pluginId, runId, hostSessionId }` the Host resolved from the view it framed, and `runId` is + * the launch's own process generation, because that is what the registered arena is keyed on. + * + * The refusal is the runtime's own `MiniAppAgentError`; mapping it to a renderer-safe failure + * code belongs to the layer that speaks to a page, not here. + */ + private miniAppAgent() { + return this.requireCapability("miniAppAgent", "Mini App Agent"); + } + + /** One `run` turn. Events arrive only when the caller passes `onEvent`. */ + runMiniAppAgentTurn(input: Parameters[0]) { + return this.miniAppAgent().run(input); + } + + /** The continuation turn a questionnaire answer starts; followed like `run`. */ + answerMiniAppAgentQuestion(input: Parameters[0]) { + return this.miniAppAgent().answer(input); + } + + /** Decides one pending permission ask: `allowOnce` or `deny`, never a global rule. */ + replyMiniAppAgentPermission(input: Parameters[0]) { + return this.miniAppAgent().reply(input); + } + + /** Ownership check for showing one of the plugin's sessions in the host's chat surface. */ + openMiniAppAgentSession(input: Parameters[0]) { + return this.miniAppAgent().open(input); + } + + /** The host's UI now shows that session, so its asks are answerable. Idempotent. */ + panelOpenedMiniAppAgentSession( + input: Parameters[0], + ) { + return this.miniAppAgent().panelOpened(input); + } + + /** The host's UI stopped showing it. Idempotent. */ + panelClosedMiniAppAgentSession( + input: Parameters[0], + ) { + return this.miniAppAgent().panelClosed(input); + } + + /** The enabled models a run may switch to, the default, and a page's host model. */ + listMiniAppAgentModels(input: Parameters[0]) { + return this.miniAppAgent().models(input); + } + + /** The agents a new Mini App session may be created for. */ + listMiniAppAgentAgents(input: Parameters[0]) { + return this.miniAppAgent().agents(input); + } + + /** A session without a turn; `runMiniAppAgentTurn({ sessionId })` continues it. */ + createMiniAppAgentSession(input: Parameters[0]) { + return this.miniAppAgent().create(input); + } + clearSessionMcpServers(sessionId: string): Promise { return this.requireCapability("mcp", "MCP").clearSessionServers(sessionId); } diff --git a/packages/local-runtime-v2/src/service/miniapp/agent/contracts.ts b/packages/local-runtime-v2/src/service/miniapp/agent/contracts.ts new file mode 100644 index 000000000..1a52169fc --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/agent/contracts.ts @@ -0,0 +1,464 @@ +/** + * Mini App Agent API contracts. + * + * A Mini App (Node process or page) hands text to an agent session owned by the Host and + * receives progress events plus the final assistant reply. Sessions are hidden + * `conversation` records tagged with the originating plugin; they never appear in + * user-facing lists. Permission prompts and questionnaires go to the run's listener. + */ +import type { + AskQuestionnaireMode, + AskQuestionnaireReplyAnswer, + AskQuestionnaireRequest, +} from '@mavis/shared/questionnaire'; +import type { MiniAppAgentPolicy } from '../contracts.js'; + +/** + * The two bounds are declared beside `MiniAppAgentPolicy` in `../contracts.ts`, which the manifest + * reader already imports; re-exported here because the agent module's own consumers read them here. + */ +export { + MINIAPP_AGENT_DEFAULT_CONCURRENCY, + MINIAPP_AGENT_MAX_CONCURRENCY, +} from '../contracts.js'; + +/** + * `run` / `answer` calls that may wait, in FIFO order, for one of the plugin's run slots: + * this many times its concurrency. + */ +export const MINIAPP_AGENT_QUEUE_FACTOR = 4; + +/** Session purpose label only; Mini App sessions resolve their prompt surface like any other. */ +export { MINIAPP_AGENT_PURPOSE_PREFIX } from '../contracts.js'; + +/** Agent for new Mini App sessions without a host chat session (`PRIMARY_AGENT_NAME`). */ +export const MINIAPP_AGENT_NAME = 'mavis'; + +export interface MiniAppAgentCaller { + readonly pluginId: string; + /** `processGeneration` for the Node path, tab `runId` (same value) for the page path. */ + readonly processGeneration: string; + readonly source: 'process' | 'page'; + /** + * The chat session whose side panel opened the Mini App; resolved by Main from the tab, + * never from page content. Absent on the Node path. + */ + readonly hostSessionId?: string; +} + +export interface MiniAppAgentRunInput { + readonly text: string; + /** Continue an existing session owned by the same plugin; omit to create one. */ + readonly sessionId?: string; + /** + * Switch the session to this model before the turn, like picking one in the composer; the + * session keeps it for later turns. Omit to run on the session's current model. + */ + readonly model?: MiniAppAgentModelSelection; + /** Only when creating a session (no `sessionId`); the session keeps its agent. */ + readonly agent?: string; +} + +type MiniAppAgentRunStatus = 'finished' | 'error' | 'aborted'; + +/** What a Mini App sends and receives for a model; the composer's selection minus provider internals. */ +export interface MiniAppAgentModelSelection { + readonly providerId: string; + readonly modelId: string; + readonly variant?: string; + readonly thinking?: { readonly effort: string }; +} + +/** One enabled model of the runtime catalog, as the composer's picker lists it. */ +interface MiniAppAgentModelOption { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly variants?: readonly string[]; + readonly thinking?: { readonly efforts: readonly string[]; readonly default?: string }; +} + +export interface MiniAppAgentModels { + readonly models: readonly MiniAppAgentModelOption[]; + /** What a session without a host chat model is created with. */ + readonly default: MiniAppAgentModelSelection; + /** Page callers only: the host chat's current model (its default when the chat has none). */ + readonly host?: MiniAppAgentModelSelection; +} + +interface MiniAppAgentAgentOption { + /** The agent's canonical name; what `agent` takes. */ + readonly name: string; + readonly displayName: string; + /** The default agent (`mavis`). */ + readonly primary: boolean; +} + +export interface MiniAppAgentAgents { + readonly agents: readonly MiniAppAgentAgentOption[]; +} + +export interface MiniAppAgentRunResult { + readonly sessionId: string; + /** Absent when no turn ran: an `answer` the runtime accepted without starting a continuation. */ + readonly turnId?: string; + /** The model the turn was dispatched with; absent when no turn ran. */ + readonly model?: MiniAppAgentModelSelection; + readonly status: MiniAppAgentRunStatus; + /** Final assistant reply text; absent when the turn produced none or did not finish. */ + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + /** Set when the turn ended waiting on a questionnaire; continue with `answer`. */ + readonly pendingQuestion?: { + readonly requestId: string; + readonly mode: AskQuestionnaireMode; + readonly request: AskQuestionnaireRequest; + }; +} + +export type MiniAppAgentPermissionDecision = 'allowOnce' | 'deny'; + +export interface MiniAppAgentReplyInput { + readonly sessionId: string; + readonly requestId: string; + readonly decision: MiniAppAgentPermissionDecision; +} + +export interface MiniAppAgentAnswerInput { + readonly sessionId: string; + readonly requestId: string; + readonly answers: readonly AskQuestionnaireReplyAnswer[]; + /** Same as `MiniAppAgentRunInput.model`, applied before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; +} + +/** + * Progress of one run, projected Host-side from the session stream; internal protocol + * shapes and ids never cross this contract. Every event but `run.queued` names its session so + * a listener can call `reply`/`answer` before the run that created the session has returned. + * Consumers must ignore unknown `type` values so the vocabulary can grow additively. + */ +export type MiniAppAgentRunEvent = + /** + * All run slots of the plugin are taken; the call waits at 1-based `position` of the FIFO. + * Sent once, before the call is admitted, so `sessionId` is present only when it continues + * an existing session (a new session is created after admission). + */ + | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'turn.started'; + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + } + | { + readonly type: 'text.delta'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'message.committed'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'tool.started'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + } + | { + readonly type: 'tool.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + readonly ok: boolean; + } + /** A tool call is waiting for approval; answer with `reply`. Only emitted while this run has a listener. */ + | { + readonly type: 'permission.requested'; + readonly sessionId: string; + /** Absent when the runtime did not attribute the ask to a turn. */ + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; + } + /** The turn paused on a questionnaire (`ask_user`, feature enable, Plan review); answer with `answer`. */ + | { + readonly type: 'question.requested'; + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly mode: AskQuestionnaireMode; + readonly request: AskQuestionnaireRequest; + } + | { + readonly type: 'turn.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly status: MiniAppAgentRunStatus; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + }; + +export interface MiniAppAgentRunOptions { + readonly signal?: AbortSignal; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; +} + +export const MINIAPP_AGENT_ERROR_CODES = [ + 'NOT_PERMITTED', + 'BUSY', + 'SESSION_NOT_FOUND', + 'INVALID_ARGUMENTS', + 'AGENT_UNAVAILABLE', + 'REQUEST_CANCELLED', + 'AGENT_OUTCOME_UNKNOWN', + 'REQUEST_NOT_FOUND', +] as const; + +export type MiniAppAgentErrorCode = (typeof MINIAPP_AGENT_ERROR_CODES)[number]; + +/** + * Why a `run` / `answer` is `BUSY`: its session is already running, or the plugin's wait + * queue is full (`MINIAPP_AGENT_QUEUE_FACTOR` × its concurrency). + */ +export type MiniAppAgentBusyReason = 'session_busy' | 'queue_full'; + +export function isMiniAppAgentBusyReason(value: unknown): value is MiniAppAgentBusyReason { + return value === 'session_busy' || value === 'queue_full'; +} + +export class MiniAppAgentError extends Error { + override readonly name = 'MiniAppAgentError'; + readonly code: MiniAppAgentErrorCode; + readonly retryable: boolean; + /** Set on every `BUSY`; absent (not an own property) otherwise. */ + declare readonly reason?: MiniAppAgentBusyReason; + + constructor( + code: MiniAppAgentErrorCode, + message: string, + retryable = false, + reason?: MiniAppAgentBusyReason, + ) { + super(message); + this.code = code; + this.retryable = retryable; + if (reason !== undefined) this.reason = reason; + } +} + +/** Active Mini App process that may call the Agent; registered by the Host IPC session. */ +export interface MiniAppAgentArena { + readonly pluginId: string; + readonly processGeneration: string; + readonly policy: MiniAppAgentPolicy; +} + +/** + * Host-side entry shared by the Node IPC path and the page bridge. Enforces + * manifest policy, per-plugin concurrency, session ownership and audit. + */ +export interface MiniAppAgentGateway { + /** + * Register on activate, call the returned release on retire/exit. Release aborts + * every in-flight run of that arena and cancels (`REQUEST_CANCELLED`) every queued one. + * Re-registering the same pluginId replaces the previous generation (cutover) and + * releases it. + */ + registerArena(arena: MiniAppAgentArena): () => void; + /** + * Rejects with NOT_PERMITTED when no arena is registered for the plugin or its policy lacks + * `session.run`, and with retryable AGENT_UNAVAILABLE when `caller` is of an older generation. + * With every run slot taken the call waits in the plugin's FIFO (`run.queued`) until one + * frees; it rejects with retryable BUSY when its session is already running (`session_busy`) + * or the queue is full (`queue_full`), and with REQUEST_CANCELLED when aborted while waiting. + */ + run( + caller: MiniAppAgentCaller, + input: MiniAppAgentRunInput, + options?: MiniAppAgentRunOptions, + ): Promise; + /** The enabled models a run may switch to, the default one and, for a page, the host chat's. */ + models(caller: MiniAppAgentCaller): Promise; + /** The agents a new session may be created for. */ + agents(caller: MiniAppAgentCaller): Promise; + /** + * Creates a session bound like a first `run` would (agent, directory, model snapshot) without + * running a turn. Takes no run slot. INVALID_ARGUMENTS when `agent` is not one of `agents()`. + */ + create( + caller: MiniAppAgentCaller, + input?: { readonly agent?: string }, + ): Promise<{ readonly sessionId: string }>; + /** + * Decide a pending permission request of a session this plugin owns. Only + * `allowOnce` / `deny`: a Mini App never writes the user's global permission rules. + * Rejects with REQUEST_NOT_FOUND when the request is unknown or already settled. + */ + reply(caller: MiniAppAgentCaller, input: MiniAppAgentReplyInput): Promise; + /** + * Answer the questionnaire a turn paused on. The answer starts the continuation + * turn, which is observed exactly like `run` (same options, same result shape). + * Rejects with AGENT_OUTCOME_UNKNOWN when the answer was recorded but the + * continuation turn was not observed in time. + */ + answer( + caller: MiniAppAgentCaller, + input: MiniAppAgentAnswerInput, + options?: MiniAppAgentRunOptions, + ): Promise; + /** + * Confirms that `sessionId` may be shown in the side panel of the chat the Mini App + * is open in now: the session belongs to the caller's plugin (whichever chat created + * it). Only page callers (those with `hostSessionId`) can open a panel. + * Rejects with SESSION_NOT_FOUND otherwise. Showing the tab is the Host UI's job. + */ + open( + caller: MiniAppAgentCaller, + input: { readonly sessionId: string }, + ): Promise<{ readonly sessionId: string; readonly hostSessionId: string }>; + /** + * Host-only, in memory: the host chat's side panel now shows this session, so a + * user can answer its asks there. Called by the Host after its UI accepted the + * open request (not on `open`, which only checks ownership). Idempotent. + */ + panelOpened(input: { readonly sessionId: string; readonly hostSessionId: string }): void; + /** + * Host-only, in memory: the tab in `hostSessionId`'s side panel was closed. The session + * stops counting as answerable only when no chat shows it any more. Idempotent. + */ + panelClosed(input: { readonly sessionId: string; readonly hostSessionId: string }): void; +} + +/** + * What the permission gate already knows about the asking session from the turn's + * trusted `SessionRecord`. Lets the router skip the storage walk for an ordinary + * root session or a SideChat (neither flag) and for a Mini App root (`miniApp`); + * only a delegated child (`parentSessionId` set) or a caller that passes no + * lineage still needs its root resolved. + */ +export interface MiniAppSessionLineage { + /** The session itself carries `miniAppOrigin`, i.e. it is a Mini App root. */ + readonly miniApp: boolean; + readonly parentSessionId?: string | null; +} + +/** + * Consulted by the permission gate before it waits on an approval. The ask is + * denied (with a reason the model can act on) only when nobody can answer it: + * the Mini App run that owns the root session registered no event listener and + * the Host has not reported the session open in a chat side panel. Where the + * session was created (`hostSessionId`) is not evidence that anyone is watching. + */ +export interface MiniAppApprovalRouter { + shouldDenyApproval(sessionId: string, lineage?: MiniAppSessionLineage): Promise; +} + +export interface MiniAppPermissionAsk { + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; +} + +/** Session-system glue bound after turn/session owners exist (cron `runtime-session-ports` precedent). */ +export interface MiniAppAgentSessionPorts { + createSession(input: { + readonly pluginId: string; + readonly agentName: string; + readonly workspaceDir: string; + /** The plugin directory when `createDir` is set; a host chat's project directory is used as it is. */ + readonly createDir: boolean; + /** Resolved at creation; the session record keeps it as its model. */ + readonly model: MiniAppAgentModelSelection; + /** Only with the runtime default model (see `defaultSessionModel`); written as the record's limit. */ + readonly contextWindow?: number; + }): Promise<{ readonly sessionId: string }>; + /** + * The runtime default model as a new session snapshots it: the selection (the global variant / + * thinking applied, `variant: ''` meaning thinking off) plus the global context window, which + * is not part of the public selection. + */ + defaultSessionModel(): Promise<{ + readonly selection: MiniAppAgentModelSelection; + readonly contextWindow?: number; + }>; + /** + * Validates like the composer does (`resolveRequestedSessionModel`); `undefined` resolves the + * runtime default. INVALID_ARGUMENTS when the user cannot use it. + */ + resolveModel(requested?: MiniAppAgentModelSelection): Promise; + /** Enabled models of the runtime catalog. */ + listModels(): Promise; + /** `AgentApplication.list({ includeAliases: false })` projected; aliases collapsed on canonicalViewName. */ + listAgents(): Promise; + /** The session record's current model; undefined only for records predating this feature. */ + readSessionModel(sessionId: string): Promise; + /** + * Persists an already resolved selection on the session, like picking a model for it in the + * composer; later turns use it. Fails as retryable AGENT_UNAVAILABLE (nothing dispatched). + */ + setSessionModel(sessionId: string, model: MiniAppAgentModelSelection): Promise; + /** Returns the owning pluginId, or undefined when the session is missing or not a Mini App session. */ + getOwner(sessionId: string): Promise; + /** + * The Mini App session the given session is or descends from (via delegated child sessions), + * or undefined when there is none within a bounded number of hops. + */ + resolveMiniAppRoot(sessionId: string): Promise< + | { + readonly rootSessionId: string; + readonly pluginId: string; + } + | undefined + >; + /** What a new Mini App session takes from its host chat session; undefined when it is missing. */ + resolveHostSession(sessionId: string): Promise< + | { + readonly agentName: string; + readonly workspaceDir: string; + readonly isDefaultWorkspace: boolean; + /** The host chat's model; undefined when its record has none. */ + readonly model?: MiniAppAgentModelSelection; + } + | undefined + >; + runTurn(input: { + readonly sessionId: string; + readonly text: string; + readonly signal?: AbortSignal; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; + }): Promise>; + /** Settles the questionnaire; the continuation turn it starts is followed like `runTurn`. */ + answerQuestion(input: { + readonly sessionId: string; + readonly requestId: string; + readonly answers: readonly AskQuestionnaireReplyAnswer[]; + readonly signal?: AbortSignal; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; + }): Promise>; + /** + * Decides one pending permission request raised by the session's Mini App root or one of its + * descendants; 'skipped' when it is unknown, already settled or raised under another root. + */ + replyPermission(input: { + readonly sessionId: string; + readonly requestId: string; + readonly decision: MiniAppAgentPermissionDecision; + }): Promise<'processed' | 'skipped'>; + /** Pushes every permission ask the runtime raises; the gateway routes by sessionId. */ + subscribePermissionAsks(listener: (ask: MiniAppPermissionAsk) => void): () => void; +} + +/** Gateway that fails closed (`AGENT_UNAVAILABLE`) until `bind` supplies ports. */ +export interface BindableMiniAppAgentGateway extends MiniAppAgentGateway, MiniAppApprovalRouter { + bind(ports: MiniAppAgentSessionPorts): void; +} diff --git a/packages/local-runtime-v2/src/service/miniapp/agent/gateway.ts b/packages/local-runtime-v2/src/service/miniapp/agent/gateway.ts new file mode 100644 index 000000000..986795526 --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/agent/gateway.ts @@ -0,0 +1,1169 @@ +import { stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + isMiniAppAgentPermissionDecision, + MINIAPP_AGENT_MAX_TEXT_LENGTH, +} from '@mavis/shared/miniapp-surface'; + +import { + MINIAPP_AGENT_NAME, + MINIAPP_AGENT_QUEUE_FACTOR, + MiniAppAgentError, + type BindableMiniAppAgentGateway, + type MiniAppAgentAnswerInput, + type MiniAppAgentArena, + type MiniAppAgentBusyReason, + type MiniAppAgentAgents, + type MiniAppAgentCaller, + type MiniAppAgentModels, + type MiniAppAgentModelSelection, + type MiniAppAgentReplyInput, + type MiniAppAgentRunEvent, + type MiniAppAgentRunOptions, + type MiniAppAgentRunResult, + type MiniAppAgentSessionPorts, + type MiniAppPermissionAsk, + type MiniAppSessionLineage, +} from './contracts.js'; +import { readMiniAppAgentModelSelection } from './model-selection.js'; + +export interface MiniAppAgentGatewayOptions { + readonly dataDir: string; + readonly logger: { + info(fields: Record, message: string): void; + warn(fields: Record, message: string): void; + }; + readonly nowMs: () => number; +} + +interface ArenaEntry { + readonly processGeneration: string; + readonly policy: MiniAppAgentArena['policy']; + /** Runs at once: the policy's `maxConcurrent` (deny-all never admits). */ + readonly maxConcurrent: number; + /** `MINIAPP_AGENT_QUEUE_FACTOR` × `maxConcurrent`. */ + readonly maxQueued: number; + readonly inflight: Set; + /** Sessions with a run in flight; a session runs one Turn at a time. */ + readonly runningSessions: Set; + /** Calls waiting for a free slot, oldest first; at most `maxQueued`. */ + readonly queue: QueuedTurn[]; +} + +/** A `run` / `answer` waiting in its arena's FIFO. */ +interface QueuedTurn { + /** A slot is free: take it, or settle as `BUSY` when the call's session started meanwhile. */ + dequeue(): void; + /** The arena was released; settle as `REQUEST_CANCELLED`. */ + cancel(): void; +} + +/** One taken run slot; `queuedAt` is the 1-based FIFO position the call waited at, if it waited. */ +interface Slot { + readonly controller: AbortController; + readonly queuedAt?: number; +} + +/** The caller of an in-flight run that can answer what the turn asks. */ +interface SessionListener { + readonly onEvent: (event: MiniAppAgentRunEvent) => void; + /** Last turn seen by this run; fallback for asks that carry no turn. */ + turnId?: string; +} + +interface GatewayState { + readonly options: MiniAppAgentGatewayOptions; + readonly arenas: Map; + /** Sessions whose in-flight run registered `onEvent`, i.e. someone can answer an ask. */ + readonly listeners: Map; + /** Session -> host chats whose side panel shows it; any one is a place a user can answer an ask. */ + readonly panels: Map>; + ports?: MiniAppAgentSessionPorts; + unsubscribeAsks?: () => void; +} + +interface Admitted { + readonly bound: MiniAppAgentSessionPorts; + readonly arena: ArenaEntry; +} + +/** One admitted `run` / `answer`; the two differ only in how the turn starts. */ +interface TurnCall { + readonly caller: MiniAppAgentCaller; + readonly operation: 'run' | 'answer'; + readonly sessionId: string | undefined; + readonly runOptions: MiniAppAgentRunOptions; + /** The shape-checked `model` the call asks to switch the session to. */ + readonly model?: MiniAppAgentModelSelection; + /** The shape-checked agent a new session is created for. */ + readonly agent?: string; + readonly start: ( + bound: MiniAppAgentSessionPorts, + turn: { + readonly sessionId: string; + readonly signal: AbortSignal; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; + }, + ) => Promise>; +} + +const MAX_REQUEST_ID_LENGTH = 256; +const AUDIT_MESSAGE = 'Mini App Agent run'; + +/** + * Fire-and-forget for work that already owns its error handling. The v2 tree has no shared + * `channel-system/settle` module, so the helper the shipped gateway imported lives here instead. + */ +const detached = new Set>(); + +function detach(promise: Promise): void { + detached.add(promise); + queueMicrotask(() => detached.delete(promise)); +} + +/** + * Host-side admission for Mini App Agent runs: policy, per-plugin run slots with a bounded FIFO + * of waiting calls, ownership, audit. + */ +export function createMiniAppAgentGateway( + options: MiniAppAgentGatewayOptions, +): BindableMiniAppAgentGateway { + const state: GatewayState = { + options, + arenas: new Map(), + listeners: new Map(), + panels: new Map(), + }; + return { + bind(next) { + state.unsubscribeAsks?.(); + state.ports = next; + state.unsubscribeAsks = next.subscribePermissionAsks((ask) => forwardAsk(state, ask)); + }, + registerArena: (arena) => registerArena(state, arena), + async run(caller, input, runOptions = {}) { + const requested = readRequestedModel(input.model); + const agent = readRequestedAgent(input.agent, input.sessionId); + return startTurn( + state, + { + caller, + operation: 'run', + sessionId: input.sessionId, + runOptions, + ...requested.call, + ...agent.call, + start: (bound, turn) => bound.runTurn({ ...turn, text: input.text }), + }, + invalidText(input.text) ?? requested.invalid ?? agent.invalid, + ); + }, + async answer(caller, input, runOptions = {}) { + const requested = readRequestedModel(input.model); + return startTurn( + state, + { + caller, + operation: 'answer', + sessionId: input.sessionId, + runOptions, + ...requested.call, + start: (bound, turn) => + bound.answerQuestion({ ...turn, requestId: input.requestId, answers: input.answers }), + }, + invalidAnswer(input) ?? requested.invalid, + ); + }, + models: (caller) => listModelOptions(state, caller), + agents: (caller) => listAgentOptions(state, caller), + create: (caller, input = {}) => createBoundSession(state, caller, input.agent), + reply: (caller, input) => replyPermission(state, caller, input), + open: (caller, input) => openSession(state, caller, input), + panelOpened({ sessionId, hostSessionId }) { + const hosts = state.panels.get(sessionId) ?? new Set(); + hosts.add(hostSessionId); + state.panels.set(sessionId, hosts); + state.options.logger.info( + { sessionId, hostSessionId }, + 'Mini App Agent session shown in a side panel', + ); + }, + panelClosed({ sessionId, hostSessionId }) { + const hosts = state.panels.get(sessionId); + if (!hosts) return; + hosts.delete(hostSessionId); + if (hosts.size === 0) state.panels.delete(sessionId); + }, + async shouldDenyApproval(sessionId, lineage) { + // No Mini App run can exist before bind, so nothing here can be a Mini App ask. + if (!state.ports) return false; + const root = await resolveApprovalRoot(state.ports, sessionId, lineage); + return root !== undefined && !canAnswer(state, root); + }, + }; +} + +/** + * The Mini App root an ask belongs to. The Turn's lineage spares the storage walk for every + * root session; only a delegated child (or a caller without lineage) walks up to its root. + */ +async function resolveApprovalRoot( + ports: MiniAppAgentSessionPorts, + sessionId: string, + lineage: MiniAppSessionLineage | undefined, +): Promise { + if (lineage?.miniApp) return sessionId; + // An ordinary root session or a SideChat (`parentSessionId: null`) is never a Mini App ask. + if (lineage && !lineage.parentSessionId) return undefined; + return (await ports.resolveMiniAppRoot(sessionId))?.rootSessionId; +} + +/** + * Someone can answer an ask of this Mini App root: the run that owns it registered `onEvent`, + * or the Host reported it shown in a chat side panel. Where it was created is not evidence. + */ +function canAnswer(state: GatewayState, rootSessionId: string): boolean { + return state.listeners.has(rootSessionId) || state.panels.has(rootSessionId); +} + +function registerArena(state: GatewayState, arena: MiniAppAgentArena): () => void { + const previous = state.arenas.get(arena.pluginId); + if (previous) releaseEntry(previous); + const maxConcurrent = arena.policy.kind === 'allowlist' ? arena.policy.maxConcurrent : 0; + const entry: ArenaEntry = { + processGeneration: arena.processGeneration, + policy: arena.policy, + maxConcurrent, + maxQueued: maxConcurrent * MINIAPP_AGENT_QUEUE_FACTOR, + inflight: new Set(), + runningSessions: new Set(), + queue: [], + }; + state.arenas.set(arena.pluginId, entry); + return () => { + releaseEntry(entry); + if (state.arenas.get(arena.pluginId) === entry) state.arenas.delete(arena.pluginId); + }; +} + +/** Nothing waits on a released arena: queued calls are cancelled, in-flight runs aborted. */ +function releaseEntry(entry: ArenaEntry): void { + for (const queued of entry.queue.splice(0)) queued.cancel(); + for (const controller of entry.inflight) controller.abort(); +} + +function reject( + state: GatewayState, + caller: MiniAppAgentCaller, + error: MiniAppAgentError, + fields: Record = {}, +): MiniAppAgentError { + state.options.logger.warn( + { + pluginId: caller.pluginId, + processGeneration: caller.processGeneration, + source: caller.source, + phase: 'rejected', + errorCode: error.code, + ...fields, + }, + AUDIT_MESSAGE, + ); + return error; +} + +/** + * Fails closed until bound, then checks the caller's arena and its policy. A caller of an + * older generation (its page `runId` is stale or the Node process restarted) is unavailable, + * not refused: the current generation may run. + */ +function authorize(state: GatewayState, caller: MiniAppAgentCaller): Admitted { + if (!state.ports) { + throw new MiniAppAgentError('AGENT_UNAVAILABLE', 'Mini App Agent is not available'); + } + const arena = state.arenas.get(caller.pluginId); + const refused = (reason: 'no-arena' | 'policy') => + reject( + state, + caller, + new MiniAppAgentError('NOT_PERMITTED', 'Mini App is not permitted to run the Agent'), + { reason }, + ); + // No arena: never authorized, or retired / revoked. + if (!arena) throw refused('no-arena'); + if (!allowsRun(arena.policy)) throw refused('policy'); + if (arena.processGeneration !== caller.processGeneration) { + throw reject( + state, + caller, + new MiniAppAgentError('AGENT_UNAVAILABLE', 'Mini App Agent caller is out of date', true), + { reason: 'generation-mismatch' }, + ); + } + return { bound: state.ports, arena }; +} + +function admit(state: GatewayState, call: TurnCall, invalid: string | undefined): Admitted { + const { caller, sessionId } = call; + const admitted = authorize(state, caller); + if (invalid !== undefined) { + throw reject(state, caller, new MiniAppAgentError('INVALID_ARGUMENTS', invalid)); + } + if (sessionId !== undefined && admitted.arena.runningSessions.has(sessionId)) { + throw busy(state, call, 'session_busy'); + } + return admitted; +} + +function busy( + state: GatewayState, + call: TurnCall, + reason: MiniAppAgentBusyReason, +): MiniAppAgentError { + const message = + reason === 'session_busy' + ? 'Mini App Agent session is already running' + : 'Too many Mini App Agent runs are waiting'; + return reject(state, call.caller, new MiniAppAgentError('BUSY', message, true, reason), { + reason, + }); +} + +/** + * Admits a `run` / `answer` into a free slot, or queues it behind the calls already waiting so + * runs start in strict FIFO order. + */ +async function startTurn( + state: GatewayState, + call: TurnCall, + invalid: string | undefined, +): Promise { + const admitted = admit(state, call, invalid); + const { arena } = admitted; + const slot = + arena.inflight.size < arena.maxConcurrent && arena.queue.length === 0 + ? occupy(arena, call.sessionId) + : await enqueue(state, arena, call); + return execute(state, admitted, call, slot); +} + +function occupy(arena: ArenaEntry, sessionId: string | undefined, queuedAt?: number): Slot { + const controller = new AbortController(); + arena.inflight.add(controller); + if (sessionId !== undefined) arena.runningSessions.add(sessionId); + return { controller, ...(queuedAt === undefined ? {} : { queuedAt }) }; +} + +/** Hands each free slot to the oldest waiting call; a refused call leaves the slot to the next. */ +function drain(arena: ArenaEntry): void { + while (arena.inflight.size < arena.maxConcurrent) { + const next = arena.queue.shift(); + if (!next) return; + next.dequeue(); + } +} + +/** + * Waits for a slot. The slot is taken synchronously in `drain`, so no later call can overtake; + * leaving the queue (caller abort, arena release) settles as `REQUEST_CANCELLED`. + */ +function enqueue(state: GatewayState, arena: ArenaEntry, call: TurnCall): Promise { + const { signal } = call.runOptions; + if (arena.queue.length >= arena.maxQueued) { + throw busy(state, call, 'queue_full'); + } + if (signal?.aborted) throw cancelledWhileQueued(state, call); + const position = arena.queue.length + 1; + return new Promise((resolve, rejectWait) => { + const leave = () => signal?.removeEventListener('abort', onAbort); + const entry: QueuedTurn = { + dequeue() { + leave(); + const { sessionId } = call; + // An earlier waiting call for the same session started first. + if (sessionId !== undefined && arena.runningSessions.has(sessionId)) { + rejectWait(busy(state, call, 'session_busy')); + return; + } + resolve(occupy(arena, sessionId, position)); + }, + cancel() { + leave(); + rejectWait(cancelledWhileQueued(state, call)); + }, + }; + const onAbort = () => { + const index = arena.queue.indexOf(entry); + if (index === -1) return; + arena.queue.splice(index, 1); + entry.cancel(); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + arena.queue.push(entry); + notifyQueued(state, call, position); + }); +} + +function cancelledWhileQueued(state: GatewayState, call: TurnCall): MiniAppAgentError { + return reject( + state, + call.caller, + new MiniAppAgentError('REQUEST_CANCELLED', 'Mini App Agent run was cancelled'), + { + stage: 'queue', + operation: call.operation, + ...(call.sessionId === undefined ? {} : { sessionId: call.sessionId }), + }, + ); +} + +/** `run.queued` precedes admission, so it names a session only when the call continues one. */ +function notifyQueued(state: GatewayState, call: TurnCall, position: number): void { + const { onEvent } = call.runOptions; + if (!onEvent) return; + const { sessionId } = call; + try { + onEvent({ type: 'run.queued', ...(sessionId === undefined ? {} : { sessionId }), position }); + } catch (error) { + state.options.logger.warn( + { pluginId: call.caller.pluginId, position, errorName: errorName(error) }, + 'Mini App Agent listener threw on run.queued', + ); + } +} + +/** Registers the run's caller as the one who answers its asks, for the run's duration. */ +function listen( + state: GatewayState, + sessionId: string, + onEvent: ((event: MiniAppAgentRunEvent) => void) | undefined, +): { readonly onEvent?: (event: MiniAppAgentRunEvent) => void; dispose(): void } { + if (!onEvent) return { dispose: () => undefined }; + const listener: SessionListener = { onEvent }; + state.listeners.set(sessionId, listener); + return { + onEvent: (event) => { + if ('turnId' in event && event.turnId) listener.turnId = event.turnId; + onEvent(event); + }, + dispose: () => { + if (state.listeners.get(sessionId) === listener) state.listeners.delete(sessionId); + }, + }; +} + +/** + * Routes an ask to the run of its Mini App root; an ask of a delegated child session keeps + * the child's `sessionId`, which is what the author replies with. + */ +function forwardAsk(state: GatewayState, ask: MiniAppPermissionAsk): void { + const direct = state.listeners.get(ask.sessionId); + if (direct) { + deliverAsk(state, direct, ask); + return; + } + const ports = state.ports; + // Only an in-flight run can answer; skip the ancestry walk for every other session's ask. + if (!ports || state.listeners.size === 0) return; + detach(forwardDelegatedAsk(state, ports, ask)); +} + +async function forwardDelegatedAsk( + state: GatewayState, + ports: MiniAppAgentSessionPorts, + ask: MiniAppPermissionAsk, +): Promise { + try { + const root = await ports.resolveMiniAppRoot(ask.sessionId); + const listener = root && state.listeners.get(root.rootSessionId); + if (listener) deliverAsk(state, listener, ask); + } catch (error) { + state.options.logger.warn( + { + stage: 'ask-routing', + sessionId: ask.sessionId, + requestId: ask.requestId, + ...(ask.turnId ? { turnId: ask.turnId } : {}), + errorName: errorName(error), + error: error instanceof Error ? error.message : String(error), + }, + 'Mini App Agent permission ask routing failed', + ); + } +} + +function deliverAsk( + state: GatewayState, + listener: SessionListener, + ask: MiniAppPermissionAsk, +): void { + const turnId = ask.turnId ?? listener.turnId; + try { + listener.onEvent({ + type: 'permission.requested', + sessionId: ask.sessionId, + ...(turnId === undefined ? {} : { turnId }), + requestId: ask.requestId, + toolName: ask.toolName, + ...(ask.toolInput === undefined ? {} : { toolInput: ask.toolInput }), + reason: ask.reason, + }); + } catch (error) { + // A listener failure must not break the approval publisher. + state.options.logger.warn( + { + sessionId: ask.sessionId, + requestId: ask.requestId, + errorName: errorName(error), + }, + 'Mini App Agent listener threw on ask', + ); + } +} + +async function execute( + state: GatewayState, + admitted: Admitted, + call: TurnCall, + slot: Slot, +): Promise { + const { bound, arena } = admitted; + const { runOptions } = call; + const { controller, queuedAt } = slot; + let runningSessionId = call.sessionId; + let listener: ReturnType | undefined; + const signal = runOptions.signal + ? AbortSignal.any([controller.signal, runOptions.signal]) + : controller.signal; + try { + // Before the session exists: an unusable model must not leave a new session behind. + const requested = await resolveRequestedModel(state, bound, call); + const { sessionId, agentName, model } = await resolveSession(state, bound, call, requested); + if (runningSessionId === undefined) { + runningSessionId = sessionId; + arena.runningSessions.add(sessionId); + } + if (signal.aborted) { + // The session may be new and hidden; leave a trace of it with the cancel. + throw reject( + state, + call.caller, + new MiniAppAgentError('REQUEST_CANCELLED', 'Mini App Agent run was cancelled'), + { + stage: 'admission', + operation: call.operation, + sessionId, + cancelledAfterCreate: agentName !== undefined, + }, + ); + } + // A new session is created with the requested model already; only an existing one is switched. + if (call.sessionId !== undefined) { + await saveSessionModel(state, bound, call, { sessionId, model: requested }); + } + listener = listen(state, sessionId, runOptions.onEvent); + const turn = { sessionId, signal, ...(listener.onEvent ? { onEvent: listener.onEvent } : {}) }; + return await dispatch( + state, + call, + { sessionId, agentName, model: requested ?? model, queuedAt }, + () => call.start(bound, turn), + ); + } finally { + listener?.dispose(); + arena.inflight.delete(controller); + if (runningSessionId !== undefined) arena.runningSessions.delete(runningSessionId); + drain(arena); + } +} + +/** + * Checks the model the call asked for like the composer does, once the call holds its slot and + * before its session is created. `INVALID_ARGUMENTS` reaches the caller as it is. + */ +async function resolveRequestedModel( + state: GatewayState, + bound: MiniAppAgentSessionPorts, + call: TurnCall, +): Promise { + if (!call.model) return undefined; + try { + return await bound.resolveModel(call.model); + } catch (error) { + throw modelFailure(state, call, error, { + stage: 'resolve-model', + ...(call.sessionId === undefined ? {} : { sessionId: call.sessionId }), + }); + } +} + +/** Persists the resolved model on the (now existing) session; a failure leaves nothing dispatched. */ +async function saveSessionModel( + state: GatewayState, + bound: MiniAppAgentSessionPorts, + call: TurnCall, + target: { readonly sessionId: string; readonly model: MiniAppAgentModelSelection | undefined }, +): Promise { + const { sessionId, model } = target; + if (!model) return; + try { + await bound.setSessionModel(sessionId, model); + } catch (error) { + throw modelFailure(state, call, error, { stage: 'set-session-model', sessionId }); + } +} + +/** + * A failure before the session ran anything: a `MiniAppAgentError` (e.g. INVALID_ARGUMENTS) reaches + * the caller as it is, anything else is a retryable `AGENT_UNAVAILABLE`. + */ +function setupFailure( + state: GatewayState, + caller: MiniAppAgentCaller, + error: unknown, + context: { readonly stage: string; readonly operation?: string; readonly sessionId?: string }, +): MiniAppAgentError { + if (!(error instanceof MiniAppAgentError)) return unavailable(state, caller, error, context); + return reject(state, caller, error, { ...context, errorName: errorName(error) }); +} + +function modelFailure( + state: GatewayState, + call: TurnCall, + error: unknown, + context: { readonly stage: string; readonly sessionId?: string }, +): MiniAppAgentError { + return setupFailure(state, call.caller, error, { ...context, operation: call.operation }); +} + +async function dispatch( + state: GatewayState, + call: TurnCall, + admission: { + readonly sessionId: string; + readonly agentName?: string; + /** What a newly created session was frozen with, or the model this call switched to. */ + readonly model?: MiniAppAgentModelSelection; + readonly queuedAt?: number; + }, + start: () => ReturnType, +): Promise { + const { options } = state; + const { caller } = call; + const { sessionId, agentName, model, queuedAt } = admission; + const startedAtMs = options.nowMs(); + const audit = { + pluginId: caller.pluginId, + processGeneration: caller.processGeneration, + source: caller.source, + sessionId, + operation: call.operation, + }; + const logTerminal = (fields: Record): void => + options.logger.info( + { ...audit, phase: 'terminal', ...fields, durationMs: options.nowMs() - startedAtMs }, + AUDIT_MESSAGE, + ); + options.logger.info( + { + ...audit, + phase: 'admission', + ...(agentName ? { agentName } : {}), + ...(model ? { model: `${model.providerId}/${model.modelId}` } : {}), + ...(queuedAt === undefined ? {} : { queued: true, position: queuedAt }), + }, + AUDIT_MESSAGE, + ); + try { + const result = await start(); + logTerminal({ status: result.status }); + return { sessionId, ...result }; + } catch (error) { + // The turn may already be dispatched, so an unexpected failure cannot be reported as not run. + const failure = + error instanceof MiniAppAgentError + ? error + : new MiniAppAgentError('AGENT_OUTCOME_UNKNOWN', 'Mini App Agent run outcome is unknown'); + logTerminal({ + status: 'error', + stage: 'turn', + errorCode: failure.code, + errorName: errorName(error), + }); + throw failure; + } +} + +/** A Mini App never writes the user's permission rules: only `allowOnce` / `deny` pass. */ +async function replyPermission( + state: GatewayState, + caller: MiniAppAgentCaller, + input: MiniAppAgentReplyInput, +): Promise { + const { bound } = authorize(state, caller); + const invalid = invalidReply(input); + if (invalid !== undefined) { + throw reject(state, caller, new MiniAppAgentError('INVALID_ARGUMENTS', invalid)); + } + await requireOwnedTree(state, bound, caller, input.sessionId); + let outcome: 'processed' | 'skipped'; + try { + outcome = await bound.replyPermission(input); + } catch (error) { + // Only a failure before the decision was dispatched arrives as a Mini App Agent error; + // anything else may have applied the decision. + const failure = + error instanceof MiniAppAgentError + ? error + : new MiniAppAgentError('AGENT_OUTCOME_UNKNOWN', 'Mini App Agent reply outcome is unknown'); + throw reject(state, caller, failure, { + stage: 'reply', + operation: 'reply', + sessionId: input.sessionId, + requestId: input.requestId, + decision: input.decision, + errorName: errorName(error), + }); + } + state.options.logger.info( + { + pluginId: caller.pluginId, + processGeneration: caller.processGeneration, + source: caller.source, + sessionId: input.sessionId, + operation: 'reply', + decision: input.decision, + outcome, + }, + AUDIT_MESSAGE, + ); + if (outcome === 'skipped') { + throw new MiniAppAgentError('REQUEST_NOT_FOUND', 'Mini App Agent request is not pending'); + } +} + +/** + * Any root session this plugin owns may be shown in the caller's host chat side panel, whichever + * chat or Node run created it; a delegated child never qualifies. + */ +async function openSession( + state: GatewayState, + caller: MiniAppAgentCaller, + input: { readonly sessionId: string }, +): Promise<{ readonly sessionId: string; readonly hostSessionId: string }> { + const { bound } = authorize(state, caller); + const { hostSessionId } = caller; + if (!hostSessionId) { + throw reject( + state, + caller, + new MiniAppAgentError('NOT_PERMITTED', 'Mini App is not open in a chat session'), + ); + } + const { sessionId } = input; + if (!isId(sessionId)) { + throw reject( + state, + caller, + new MiniAppAgentError('INVALID_ARGUMENTS', 'Mini App Agent open needs a sessionId'), + ); + } + let root: Awaited>; + try { + root = await bound.resolveMiniAppRoot(sessionId); + } catch (error) { + throw unavailable(state, caller, error, { stage: 'open-lookup', sessionId }); + } + // Any session this plugin owns may be shown in the chat the Mini App is open in now; + // where it was created only decides its agent and working directory. + if (root?.rootSessionId !== sessionId || root.pluginId !== caller.pluginId) { + throw reject( + state, + caller, + new MiniAppAgentError('SESSION_NOT_FOUND', 'Mini App Agent session not found'), + ); + } + state.options.logger.info( + { + pluginId: caller.pluginId, + processGeneration: caller.processGeneration, + source: caller.source, + sessionId, + operation: 'open', + }, + AUDIT_MESSAGE, + ); + return { sessionId, hostSessionId }; +} + +/** `run` / `answer` continue the Mini App session itself, never one of its delegated children. */ +async function requireOwned( + state: GatewayState, + bound: MiniAppAgentSessionPorts, + caller: MiniAppAgentCaller, + sessionId: string, +): Promise { + await requireOwner(state, caller, sessionId, () => bound.getOwner(sessionId)); +} + +/** A permission ask may come from the Mini App session or any session it delegated to. */ +async function requireOwnedTree( + state: GatewayState, + bound: MiniAppAgentSessionPorts, + caller: MiniAppAgentCaller, + sessionId: string, +): Promise { + await requireOwner( + state, + caller, + sessionId, + async () => (await bound.resolveMiniAppRoot(sessionId))?.pluginId, + ); +} + +async function requireOwner( + state: GatewayState, + caller: MiniAppAgentCaller, + sessionId: string, + readOwner: () => Promise, +): Promise { + let owner: string | undefined; + try { + owner = await readOwner(); + } catch (error) { + throw unavailable(state, caller, error, { stage: 'ownership', sessionId }); + } + if (owner !== caller.pluginId) { + throw reject( + state, + caller, + new MiniAppAgentError('SESSION_NOT_FOUND', 'Mini App Agent session not found'), + ); + } +} + +/** + * `agentName` is set only for a newly created session; continuing never changes the agent or + * the working directory, both bound at creation. + */ +async function resolveSession( + state: GatewayState, + bound: MiniAppAgentSessionPorts, + call: TurnCall, + requestedModel: MiniAppAgentModelSelection | undefined, +): Promise<{ + readonly sessionId: string; + readonly agentName?: string; + readonly model?: MiniAppAgentModelSelection; +}> { + const { caller, sessionId } = call; + if (sessionId !== undefined) { + await requireOwned(state, bound, caller, sessionId); + return { sessionId }; + } + return bindNewSession(state, bound, caller, { + agent: call.agent, + operation: call.operation, + model: requestedModel, + }); +} + +/** Creates the session a first `run` would, in the agent / directory / model the caller's context gives. */ +async function bindNewSession( + state: GatewayState, + bound: MiniAppAgentSessionPorts, + caller: MiniAppAgentCaller, + request: { + readonly agent: string | undefined; + readonly operation: string; + /** A model the call asked for; replaces the context's model in the new session record. */ + readonly model?: MiniAppAgentModelSelection | undefined; + }, +): Promise<{ + readonly sessionId: string; + readonly agentName: string; + readonly model: MiniAppAgentModelSelection; +}> { + const { pluginId } = caller; + try { + const context = await creationContext(state, bound, caller, request.agent); + const { agentName, workspaceDir, createDir } = context; + const model = request.model ?? context.model; + // The default's limit belongs to the default selection only, not to a model the call chose. + const contextWindow = request.model ? undefined : context.contextWindow; + const created = await bound.createSession({ + pluginId, + agentName, + workspaceDir, + createDir, + model, + ...(contextWindow === undefined ? {} : { contextWindow }), + }); + return { sessionId: created.sessionId, agentName, model }; + } catch (error) { + throw setupFailure(state, caller, error, { + stage: 'create-session', + operation: request.operation, + }); + } +} + +/** `create`: a session bound like a first `run`'s, with no turn and no run slot. */ +async function createBoundSession( + state: GatewayState, + caller: MiniAppAgentCaller, + agent: string | undefined, +): Promise<{ readonly sessionId: string }> { + const { bound } = authorize(state, caller); + const requested = readRequestedAgent(agent, undefined); + if (requested.invalid !== undefined) { + throw reject(state, caller, new MiniAppAgentError('INVALID_ARGUMENTS', requested.invalid)); + } + const { sessionId, agentName, model } = await bindNewSession(state, bound, caller, { + agent: requested.call.agent, + operation: 'create', + }); + state.options.logger.info( + { + pluginId: caller.pluginId, + processGeneration: caller.processGeneration, + source: caller.source, + sessionId, + operation: 'create', + phase: 'admission', + agentName, + model: `${model.providerId}/${model.modelId}`, + }, + AUDIT_MESSAGE, + ); + return { sessionId }; +} + +async function listModelOptions( + state: GatewayState, + caller: MiniAppAgentCaller, +): Promise { + const { bound } = authorize(state, caller); + try { + const [models, fallback] = await Promise.all([bound.listModels(), bound.resolveModel()]); + const host = caller.hostSessionId + ? await bound.resolveHostSession(caller.hostSessionId) + : undefined; + return { models, default: fallback, ...(host ? { host: host.model ?? fallback } : {}) }; + } catch (error) { + throw setupFailure(state, caller, error, { stage: 'list-models', operation: 'models' }); + } +} + +async function listAgentOptions( + state: GatewayState, + caller: MiniAppAgentCaller, +): Promise { + const { bound } = authorize(state, caller); + try { + return { agents: await bound.listAgents() }; + } catch (error) { + throw setupFailure(state, caller, error, { stage: 'list-agents', operation: 'agents' }); + } +} + +/** The chosen agent must be one the runtime offers; checked where the session is about to be bound. */ +async function requireKnownAgent(bound: MiniAppAgentSessionPorts, agent: string): Promise { + const agents = await bound.listAgents(); + if (!agents.some((option) => option.name === agent)) { + throw new MiniAppAgentError('INVALID_ARGUMENTS', 'Mini App Agent agent is not available'); + } +} + +interface CreationContext { + readonly agentName: string; + readonly workspaceDir: string; + /** Only the plugin directory is created; a host project directory must already exist. */ + readonly createDir: boolean; + /** The host chat's model, else the runtime default; frozen into the new session record. */ + readonly model: MiniAppAgentModelSelection; + /** The runtime default's context window; present only when `model` is that default. */ + readonly contextWindow?: number; +} + +/** + * A new session follows its host chat: that chat's agent (or the chosen one), and its project + * directory when the chat has one that still exists. Without a host chat: the default (or chosen) + * agent and the plugin directory. The model is the host chat's, else the runtime default. + */ +async function creationContext( + state: GatewayState, + bound: MiniAppAgentSessionPorts, + caller: MiniAppAgentCaller, + agent?: string, +): Promise { + if (agent !== undefined) await requireKnownAgent(bound, agent); + const { pluginId, hostSessionId } = caller; + const host = hostSessionId ? await bound.resolveHostSession(hostSessionId) : undefined; + if (!host?.agentName) { + const agentName = agent ?? MINIAPP_AGENT_NAME; + if (hostSessionId) { + state.options.logger.info( + { pluginId, hostSessionId, agentName, workspaceSource: 'plugin' }, + 'Mini App Agent host chat session not found; using the default agent', + ); + } + return pluginWorkspace(state, pluginId, agentName, await bound.defaultSessionModel()); + } + return hostCreationContext(state, bound, caller, { host, agentName: agent ?? host.agentName }); +} + +async function hostCreationContext( + state: GatewayState, + bound: MiniAppAgentSessionPorts, + caller: MiniAppAgentCaller, + target: { + readonly host: NonNullable>>; + readonly agentName: string; + }, +): Promise { + const { host, agentName } = target; + const { pluginId, hostSessionId } = caller; + const model = host.model ? { selection: host.model } : await bound.defaultSessionModel(); + const projectDir = host.isDefaultWorkspace ? '' : host.workspaceDir; + if (!projectDir) return pluginWorkspace(state, pluginId, agentName, model); + if (await isDirectory(projectDir)) { + return { + agentName, + workspaceDir: projectDir, + createDir: false, + ...creationModel(model), + }; + } + state.options.logger.info( + { pluginId, hostSessionId, agentName, workspaceSource: 'plugin' }, + 'Mini App Agent host chat project directory is gone; using the plugin directory', + ); + return pluginWorkspace(state, pluginId, agentName, model); +} + +interface SnapshotModel { + readonly selection: MiniAppAgentModelSelection; + readonly contextWindow?: number; +} + +function creationModel(model: SnapshotModel): Pick { + return { + model: model.selection, + ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }), + }; +} + +function pluginWorkspace( + state: GatewayState, + pluginId: string, + agentName: string, + model: SnapshotModel, +): CreationContext { + return { + agentName, + ...creationModel(model), + // Must stay under `agents//miniapps`: the rest of dataDir is private runtime state + // the agent cannot read back (see fs-permission `isProtectedRuntimeRead`). A host chat's + // project directory is an ordinary working directory and needs no such exemption. + workspaceDir: join( + state.options.dataDir, + 'agents', + agentName, + 'miniapps', + encodeURIComponent(pluginId), + ), + createDir: true, + }; +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +function unavailable( + state: GatewayState, + caller: MiniAppAgentCaller, + error: unknown, + context: { readonly stage: string; readonly sessionId?: string }, +): MiniAppAgentError { + // Every caller fails before a turn is dispatched, so the request can be retried as a whole. + return reject( + state, + caller, + new MiniAppAgentError('AGENT_UNAVAILABLE', 'Mini App Agent session is unavailable', true), + { ...context, errorName: errorName(error) }, + ); +} + +function errorName(error: unknown): string { + return error instanceof Error ? error.name : typeof error; +} + +function allowsRun(policy: ArenaEntry['policy']): boolean { + return policy.kind === 'allowlist' && policy.capabilities.includes('session.run'); +} + +function invalidText(text: unknown): string | undefined { + return typeof text === 'string' && text.trim() && text.length <= MINIAPP_AGENT_MAX_TEXT_LENGTH + ? undefined + : 'Mini App Agent text must be non-empty and bounded'; +} + +/** `model` is optional; once given, it must have the contract shape (availability is checked later). */ +function readRequestedModel(value: unknown): { + readonly call: { readonly model?: MiniAppAgentModelSelection }; + readonly invalid?: string; +} { + if (value === undefined) return { call: {} }; + const model = readMiniAppAgentModelSelection(value); + return model + ? { call: { model } } + : { + call: {}, + invalid: + 'Mini App Agent model must be { providerId, modelId, variant?, thinking?: { effort } }', + }; +} + +/** + * `agent` is optional and only chooses the agent of a session that is being created; once given it + * must be a non-empty string (that it names an offered agent is checked later). + */ +function readRequestedAgent( + value: unknown, + sessionId: string | undefined, +): { readonly call: { readonly agent?: string }; readonly invalid?: string } { + if (value === undefined) return { call: {} }; + if (sessionId !== undefined) { + return { call: {}, invalid: 'Mini App Agent agent can only be chosen when creating a session' }; + } + return typeof value === 'string' && value.trim() + ? { call: { agent: value } } + : { call: {}, invalid: 'Mini App Agent agent must be a non-empty agent name' }; +} + +function invalidAnswer(input: MiniAppAgentAnswerInput): string | undefined { + if (!isId(input.sessionId) || !isId(input.requestId) || !Array.isArray(input.answers)) { + return 'Mini App Agent answer needs a sessionId, a requestId and an answers list'; + } + return undefined; +} + +function invalidReply(input: MiniAppAgentReplyInput): string | undefined { + if (!isId(input.sessionId) || !isId(input.requestId)) { + return 'Mini App Agent reply needs a sessionId and a requestId'; + } + return isMiniAppAgentPermissionDecision(input.decision) + ? undefined + : 'Mini App Agent reply decision must be allowOnce or deny'; +} + +function isId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= MAX_REQUEST_ID_LENGTH; +} diff --git a/packages/local-runtime-v2/src/service/miniapp/agent/index.ts b/packages/local-runtime-v2/src/service/miniapp/agent/index.ts new file mode 100644 index 000000000..46c64a148 --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/agent/index.ts @@ -0,0 +1,3 @@ +export * from './contracts.js'; +export { createMiniAppAgentGateway } from './gateway.js'; +export { createMiniAppAgentSessionPorts } from './session-ports.js'; diff --git a/packages/local-runtime-v2/src/service/miniapp/agent/model-selection.ts b/packages/local-runtime-v2/src/service/miniapp/agent/model-selection.ts new file mode 100644 index 000000000..849b51cdd --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/agent/model-selection.ts @@ -0,0 +1,69 @@ +import type { InternalSessionCreateInput, SessionRecord } from '../../session-system/index.js'; +import type { MiniAppAgentModelSelection } from './contracts.js'; + +/** + * Reads the contract shape from untrusted input; anything else, extra fields included, is dropped. + * `variant: ''` is legal and kept: it means thinking explicitly off. + */ +export function readMiniAppAgentModelSelection(value: unknown): MiniAppAgentModelSelection | null { + if (!isRecord(value)) return null; + const { providerId, modelId, variant } = value; + if (!isNonEmptyString(providerId) || !isNonEmptyString(modelId)) return null; + if (variant !== undefined && typeof variant !== 'string') return null; + const thinking = readThinking(value.thinking); + if (thinking === null) return null; + return { + providerId, + modelId, + ...(variant === undefined ? {} : { variant }), + ...(thinking ? { thinking } : {}), + }; +} + +/** `undefined` when absent, `null` when present but malformed. */ +function readThinking(value: unknown): MiniAppAgentModelSelection['thinking'] | null { + if (value === undefined) return undefined; + return isRecord(value) && typeof value.effort === 'string' ? { effort: value.effort } : null; +} + +/** `effectiveModel` is `provider/model`; splits at the first `/`. */ +export function modelSelectionFromRecord( + record: Pick< + SessionRecord, + 'effectiveModel' | 'effectiveModelVariant' | 'effectiveModelThinking' + >, +): MiniAppAgentModelSelection | undefined { + const key = record.effectiveModel; + const slash = key ? key.indexOf('/') : -1; + if (!key || slash <= 0 || slash === key.length - 1) return undefined; + const effort = record.effectiveModelThinking?.effort; + return { + providerId: key.slice(0, slash), + modelId: key.slice(slash + 1), + ...(typeof record.effectiveModelVariant === 'string' + ? { variant: record.effectiveModelVariant } + : {}), + ...(effort ? { thinking: { effort } } : {}), + }; +} + +export function sessionModelFields( + selection: MiniAppAgentModelSelection, +): Pick< + InternalSessionCreateInput, + 'effectiveModel' | 'effectiveModelVariant' | 'effectiveModelThinking' +> { + return { + effectiveModel: `${selection.providerId}/${selection.modelId}`, + effectiveModelVariant: selection.variant ?? null, + effectiveModelThinking: selection.thinking ? { effort: selection.thinking.effort } : null, + }; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/local-runtime-v2/src/service/miniapp/agent/session-ports-projection.ts b/packages/local-runtime-v2/src/service/miniapp/agent/session-ports-projection.ts new file mode 100644 index 000000000..d0db7099a --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/agent/session-ports-projection.ts @@ -0,0 +1,176 @@ +import { + MsgType, + RespDataType, + Role, + RuntimeEventStatus, + RuntimeEventType, + ToolCallStatus, +} from '@mavis/agent-core/protocol'; + +import type { SessionFrame } from '../../session-system/index.js'; +import type { MiniAppAgentModelSelection, MiniAppAgentRunEvent } from './contracts.js'; + +export interface MiniAppAgentFrameProjector { + /** Never throws; frames that carry nothing a Mini App needs project to `[]`. */ + project(frame: SessionFrame): MiniAppAgentRunEvent[]; +} + +interface ProjectorState { + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + started: boolean; + /** Tool calls reported as started, by `tool_call_id`; results may omit the name. */ + readonly tools: Map; +} + +/** + * Projects one run's post-policy session frames onto the Mini App event vocabulary. + * `turn.finished` is not produced here: the run emits it from its settled result. + */ +export function createFrameProjector( + sessionId: string, + turnId: string, + model: MiniAppAgentModelSelection, +): MiniAppAgentFrameProjector { + const state: ProjectorState = { sessionId, turnId, model, started: false, tools: new Map() }; + return { + project(frame) { + if (frame.kind !== 'runtime-event' || frame.turnId !== turnId) return []; + try { + return projectData(state, frame.data); + } catch { + return []; + } + }, + }; +} + +function projectData(state: ProjectorState, data: unknown): MiniAppAgentRunEvent[] { + if (isRecord(data)) { + if (state.started || !isRunningStatus(data)) return []; + state.started = true; + return [ + { + type: 'turn.started', + sessionId: state.sessionId, + turnId: state.turnId, + model: state.model, + }, + ]; + } + const response = parseStreamResponse(data); + if (response?.type === RespDataType.AgentMessageChunk) { + return projectMessage(state, response.agent_message_chunk, 'text.delta'); + } + if (response?.type === RespDataType.AgentMessage) { + return projectMessage(state, response.agent_message, 'message.committed'); + } + return []; +} + +/** Chunks stream text deltas; completed messages commit their text. Both carry tool calls. */ +function projectMessage( + state: ProjectorState, + value: unknown, + textType: 'text.delta' | 'message.committed', +): MiniAppAgentRunEvent[] { + const message = assistantMessage(value); + if (!message) return []; + const text = typeof message.msg_content === 'string' ? message.msg_content : ''; + // Whitespace deltas are part of the reply; a whitespace-only message is not worth committing. + const hasText = textType === 'text.delta' ? text.length > 0 : text.trim().length > 0; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + return [ + ...(hasText + ? [{ type: textType, sessionId: state.sessionId, turnId: state.turnId, text }] + : []), + ...toolCalls.flatMap((call) => projectToolCall(state, call)), + ]; +} + +/** Reports each call once as started and once as finished, whichever frame shows it first. */ +function projectToolCall(state: ProjectorState, value: unknown): MiniAppAgentRunEvent[] { + const call = readToolCall(value); + if (!call) return []; + const known = state.tools.get(call.id); + const name = call.name ?? known?.name; + if (!name) return []; + const events: MiniAppAgentRunEvent[] = []; + const tool = known ?? { name, finished: false }; + if (!known) { + state.tools.set(call.id, tool); + events.push({ type: 'tool.started', sessionId: state.sessionId, turnId: state.turnId, name }); + } + if (call.done && !tool.finished) { + tool.finished = true; + events.push({ + type: 'tool.finished', + sessionId: state.sessionId, + turnId: state.turnId, + name: tool.name, + ok: call.ok, + }); + } + return events; +} + +/** Argument-streaming stages (`Preparing` / `Prepared`) are not tool execution and are skipped. */ +function readToolCall( + value: unknown, +): + | { readonly id: string; readonly name?: string; readonly done: boolean; readonly ok: boolean } + | undefined { + if (!isRecord(value) || typeof value.tool_call_id !== 'string') return undefined; + const status = value.tool_call_status; + if ( + status !== ToolCallStatus.Start && + status !== ToolCallStatus.Finished && + status !== ToolCallStatus.Failed + ) { + return undefined; + } + const name = typeof value.tool_name === 'string' && value.tool_name ? value.tool_name : undefined; + return { + id: value.tool_call_id, + ...(name ? { name } : {}), + done: status !== ToolCallStatus.Start, + ok: status === ToolCallStatus.Finished, + }; +} + +function isRunningStatus(event: Record): boolean { + return ( + event.type === RuntimeEventType.SESSION_STATUS && + isRecord(event.payload) && + event.payload.status === RuntimeEventStatus.RUNNING + ); +} + +/** Stream responses reach the frame as the JSON `RespData` string written by the agent projection. */ +function parseStreamResponse(data: unknown): Record | undefined { + if (typeof data !== 'string') return undefined; + try { + const response: unknown = JSON.parse(data); + return isRecord(response) ? response : undefined; + } catch { + return undefined; + } +} + +/** + * Only the Agent's own reply counts: user echoes, system events, compaction/review cards and + * terminal error bubbles (reported through `turn.finished`) are skipped. + */ +function assistantMessage(value: unknown): Record | undefined { + if (!isRecord(value) || value.role !== Role.Assistant || value.kind !== undefined) { + return undefined; + } + return value.msg_type === MsgType.SystemEvent || value.finish_reason === 'error' + ? undefined + : value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/local-runtime-v2/src/service/miniapp/agent/session-ports.ts b/packages/local-runtime-v2/src/service/miniapp/agent/session-ports.ts new file mode 100644 index 000000000..4955f64c9 --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/agent/session-ports.ts @@ -0,0 +1,1069 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir } from 'node:fs/promises'; + +import type { GlobalEvent } from '@mavis/shared/global-events'; +import type { + AskQuestionnaireReplyAnswer, + AskQuestionnaireRequest, +} from '@mavis/shared/questionnaire'; + +import type { + DisplayMessageRecord, + InternalSessionCreateInput, + ResumeSessionInput, + SessionFrame, + SessionRecord, +} from '../../session-system/index.js'; +import { getRuntimePresetKey, resolveModelAvailability } from '@mavis/config'; +import { + LocalModelProviderError, + resolveRequestedSessionModel, + type LocalConversationRuntimeConfig, + type ModelProviderModelEntry, +} from '../../model-system/index.js'; +import type { AbortTurnResult, TurnService } from '../../turn-system/index.js'; +import { + MINIAPP_AGENT_NAME, + MINIAPP_AGENT_PURPOSE_PREFIX, + MiniAppAgentError, + type MiniAppAgentModelSelection, + type MiniAppAgentPermissionDecision, + type MiniAppAgentRunEvent, + type MiniAppAgentRunResult, + type MiniAppAgentSessionPorts, + type MiniAppPermissionAsk, +} from './contracts.js'; +import { modelSelectionFromRecord, sessionModelFields } from './model-selection.js'; +import { createFrameProjector } from './session-ports-projection.js'; + +export interface MiniAppAgentSessionPortsOptions { + readonly records: { + createInternalSession(input: InternalSessionCreateInput): Promise; + }; + /** Writes through the lifecycle owner, so a model change reaches open views as `model-updated`. */ + readonly lifecycle: { + mutateSession(sessionId: string, fields: SessionModelSwitchFields): Promise; + }; + readonly repository: { + get( + sessionId: string, + ): Promise< + | Pick< + SessionRecord, + | 'purpose' + | 'agentName' + | 'parentSessionId' + | 'workspaceDir' + | 'isDefaultWorkspace' + | 'effectiveModel' + | 'effectiveModelVariant' + | 'effectiveModelThinking' + > + | undefined + >; + }; + readonly turns: Pick; + readonly stream: { + resume(input: ResumeSessionInput): AsyncIterableIterator; + }; + readonly messages: { + listTurn(sessionId: string, turnId: string): Promise; + }; + /** Settling a questionnaire also starts its continuation turn. */ + readonly questionnaires: { + getPending(input: { + agentName: string; + sessionId: string; + }): Promise; + reply(input: { + agentName: string; + requestId: string; + answers: AskQuestionnaireReplyAnswer[]; + submittedAt: number; + }): Promise; + }; + readonly permissions: { + listPending(): Promise; + reply(input: { + requestIds: readonly string[]; + decision: MiniAppAgentPermissionDecision; + }): Promise<{ readonly processed: readonly string[] }>; + }; + readonly eventBus: { + subscribe(subscriber: { next(event: GlobalEvent): void }): () => void; + }; + readonly logger: { + warn(fields: Record, message: string): void; + }; + /** The runtime's current model configuration; read per call so default changes apply. */ + readonly modelConfig: () => LocalConversationRuntimeConfig; + readonly models: { list(): Promise }; + /** `AgentApplication.list`, declared structurally to keep the agent module out of this seam. */ + readonly agents: { + list(options: { + readonly includeAliases: boolean; + }): Promise; + }; + readonly nowMs: () => number; +} + +/** + * What a model switch writes: the selection, and the limits of the model it replaces cleared + * (as the composer does), so they are not read as the new model's. + */ +type SessionModelSwitchFields = ReturnType & { + readonly effectiveModelContextWindow: null; + readonly effectiveModelMaxOutputTokens: null; +}; + +/** `injected: false` means the answer was saved but no continuation turn was started. */ +interface QuestionnaireReplyResult { + readonly ok: true; + readonly injected?: boolean; +} + +/** Upper bound on delegated-session hops walked to find a Mini App root. */ +const MAX_MINIAPP_ROOT_HOPS = 16; + +/** Binds the Mini App Agent gateway to SessionSystem records/stream and TurnSystem steering. */ +export function createMiniAppAgentSessionPorts( + options: MiniAppAgentSessionPortsOptions, +): MiniAppAgentSessionPorts { + return { + async createSession({ + pluginId, + agentName, + workspaceDir, + createDir, + model, + contextWindow, + }) { + if (createDir) await mkdir(workspaceDir, { recursive: true }); + const session = await options.records.createInternalSession({ + agentName, + sessionType: 'branch', + sessionKind: 'conversation', + visibility: 'hidden', + // Ownership is this label: the record has no structured Mini App origin here, and the + // prefix is what `getOwner` / `resolveMiniAppRoot` read back. + purpose: `${MINIAPP_AGENT_PURPOSE_PREFIX}${pluginId}`, + workspaceDir, + isDefaultWorkspace: false, + // Shown as the side-panel tab label; the gateway has no plugin display name. + title: pluginId, + ...sessionModelFields(model), + ...(contextWindow === undefined ? {} : { effectiveModelContextWindow: contextWindow }), + }); + return { sessionId: session.sessionId }; + }, + listModels: () => listModelOptions(options), + listAgents: () => listAgentOptions(options), + defaultSessionModel: () => resolveDefaultSessionModel(options), + resolveModel: async (requested) => + requested + ? resolveRequestedModel(options, requested) + : (await resolveDefaultSessionModel(options)).selection, + readSessionModel: (sessionId) => readSessionModel(options, sessionId), + setSessionModel: (sessionId, model) => + beforeDispatch(options, { stage: 'set-session-model', sessionId }, async () => { + await options.lifecycle.mutateSession(sessionId, { + ...sessionModelFields(model), + effectiveModelContextWindow: null, + effectiveModelMaxOutputTokens: null, + }); + }), + async getOwner(sessionId) { + return pluginIdFromPurpose((await options.repository.get(sessionId))?.purpose); + }, + resolveMiniAppRoot: (sessionId) => resolveMiniAppRoot(options, sessionId), + resolveHostSession: (sessionId) => + beforeDispatch(options, { stage: 'resolve-host-session', sessionId }, async () => { + const session = await options.repository.get(sessionId); + if (!session) return undefined; + const { agentName, workspaceDir, isDefaultWorkspace } = session; + // Same reading as the session wire projection: only an explicit `true` is the default. + const model = modelSelectionFromRecord(session); + return { + agentName, + workspaceDir, + isDefaultWorkspace: isDefaultWorkspace === true, + model, + }; + }), + runTurn: (input) => runMiniAppAgentTurn(options, input), + answerQuestion: (input) => answerMiniAppAgentQuestion(options, input), + replyPermission: (input) => replyMiniAppAgentPermission(options, input), + subscribePermissionAsks: (listener) => + options.eventBus.subscribe({ + next(event) { + if (event.type === 'permission.ask') listener(projectPermissionAsk(event.payload)); + }, + }), + }; +} + +type RunTurnInput = Parameters[0]; +type AnswerQuestionInput = Parameters[0]; +type RunTurnResult = Omit; +type PendingQuestion = NonNullable; + +async function listModelOptions( + options: MiniAppAgentSessionPortsOptions, +): ReturnType { + return (await options.models.list()) + .filter((entry) => entry.enabled) + .map((entry) => ({ + providerId: entry.providerId, + modelId: entry.modelId, + displayName: entry.displayName, + ...(entry.supportedVariants?.length ? { variants: [...entry.supportedVariants] } : {}), + ...(entry.effortOptions?.length + ? { + thinking: { + efforts: [...entry.effortOptions], + ...(entry.defaultEffort ? { default: entry.defaultEffort } : {}), + }, + } + : {}), + })); +} + +async function listAgentOptions( + options: MiniAppAgentSessionPortsOptions, +): ReturnType { + const byName = new Map(); + for (const agent of await options.agents.list({ includeAliases: false })) { + const name = agent.canonicalViewName; + if (byName.has(name)) continue; + byName.set(name, { + name, + displayName: agent.displayName || name, + primary: name === MINIAPP_AGENT_NAME, + }); + } + return [...byName.values()]; +} + +/** Same validation as the composer. */ +async function resolveRequestedModel( + options: MiniAppAgentSessionPortsOptions, + requested: MiniAppAgentModelSelection, +): Promise { + return (await resolveSelection(options, requested)).selection; +} + +/** + * The runtime default as the turn system would run it with no session model: the global + * variant, thinking and context window apply (`variant: ''` is thinking off and is kept). + */ +async function resolveDefaultSessionModel( + options: MiniAppAgentSessionPortsOptions, +): ReturnType { + const config = options.modelConfig(); + return resolveSelection(options, { + ...(config.defaultModelVariant === undefined ? {} : { variant: config.defaultModelVariant }), + ...(config.defaultModelThinking === undefined ? {} : { thinking: config.defaultModelThinking }), + ...(config.defaultModelContextWindow === undefined + ? {} + : { contextLimit: config.defaultModelContextWindow }), + }); +} + +async function resolveSelection( + options: MiniAppAgentSessionPortsOptions, + requested: Parameters[1], +): Promise<{ readonly selection: MiniAppAgentModelSelection; readonly contextWindow?: number }> { + try { + const config = options.modelConfig(); + const resolved = resolveRequestedSessionModel(config, requested); + if (!resolved.providerId || !resolved.modelId) { + throw new MiniAppAgentError( + 'INVALID_ARGUMENTS', + 'Model is not available: the runtime default model is not configured', + ); + } + // A retired official model was already replaced above. The Mini App API + // still validates its arguments up front: a model no route lists is an + // input error for the caller rather than a Turn left to fail upstream. + const availability = resolveModelAvailability({ + config, + providerId: resolved.providerId, + modelId: resolved.modelId, + preset: getRuntimePresetKey(), + source: 'explicit_request', + }); + if (!availability.available) { + throw new MiniAppAgentError( + 'INVALID_ARGUMENTS', + `Model is not available: ${availability.message}`, + ); + } + return { + selection: { + providerId: resolved.providerId, + modelId: resolved.modelId, + ...(typeof resolved.variant === 'string' ? { variant: resolved.variant } : {}), + ...(resolved.thinking?.effort ? { thinking: { effort: resolved.thinking.effort } } : {}), + }, + ...(resolved.contextLimit === undefined ? {} : { contextWindow: resolved.contextLimit }), + }; + } catch (error) { + if (error instanceof LocalModelProviderError) { + throw new MiniAppAgentError('INVALID_ARGUMENTS', `Model is not available: ${error.message}`); + } + throw error; + } +} + +function readSessionModel( + options: MiniAppAgentSessionPortsOptions, + sessionId: string, +): Promise { + return beforeDispatch(options, { stage: 'read-session-model', sessionId }, async () => { + const record = await options.repository.get(sessionId); + return record ? modelSelectionFromRecord(record) : undefined; + }); +} + +/** + * The model a turn is about to be dispatched with. A record predating the model snapshot has + * none stored, so the turn runs on the runtime default, which is what it is reported as. + */ +async function modelBeforeDispatch( + options: MiniAppAgentSessionPortsOptions, + sessionId: string, +): Promise { + const recorded = await readSessionModel(options, sessionId); + return ( + recorded ?? + beforeDispatch(options, { stage: 'resolve-default-model', sessionId }, async () => { + return (await resolveDefaultSessionModel(options)).selection; + }) + ); +} + +/** A failure before anything was dispatched: nothing ran, so the caller may retry. */ +async function beforeDispatch( + options: MiniAppAgentSessionPortsOptions, + context: { readonly stage: string; readonly sessionId: string; readonly requestId?: string }, + lookup: () => Promise, +): Promise { + try { + return await lookup(); + } catch (error) { + if (error instanceof MiniAppAgentError) throw error; + options.logger.warn( + { ...context, ...errorFields(error) }, + 'Mini App Agent lookup failed before dispatch', + ); + throw new MiniAppAgentError('AGENT_UNAVAILABLE', 'Mini App Agent session is unavailable', true); + } +} + +/** Unknown outcome: something may have run or been recorded, so a blind retry is not safe. */ +function outcomeUnknown(message: string): MiniAppAgentError { + return new MiniAppAgentError('AGENT_OUTCOME_UNKNOWN', message); +} + +function errorFields(error: unknown): { readonly errorName: string; readonly error: string } { + return error instanceof Error + ? { errorName: error.name, error: error.message } + : { errorName: typeof error, error: String(error) }; +} + +/** Walks delegated child sessions up to the Mini App session that owns them (bounded, cycle-safe). */ +async function resolveMiniAppRoot( + options: MiniAppAgentSessionPortsOptions, + sessionId: string, +): ReturnType { + const seen = new Set(); + let current: string | null | undefined = sessionId; + while (current) { + if (seen.has(current) || seen.size > MAX_MINIAPP_ROOT_HOPS) { + options.logger.warn( + { sessionId, hops: seen.size, cycle: seen.has(current) }, + 'Mini App root lookup stopped at the hop limit or a cycle', + ); + return undefined; + } + seen.add(current); + const record = await options.repository.get(current); + const pluginId = pluginIdFromPurpose(record?.purpose); + if (pluginId) return { rootSessionId: current, pluginId }; + current = record?.parentSessionId; + } + return undefined; +} + +/** + * The owning plugin of a Mini App session, read from the `purpose` label this module writes. + * + * The shipped generation stored a structured `miniAppOrigin` on the session record; this build's + * record has no such field, and `purpose` is the persisted label it already carries. + */ +function pluginIdFromPurpose(purpose: string | undefined): string | undefined { + if (!purpose || !purpose.startsWith(MINIAPP_AGENT_PURPOSE_PREFIX)) return undefined; + const pluginId = purpose.slice(MINIAPP_AGENT_PURPOSE_PREFIX.length); + return pluginId.length > 0 ? pluginId : undefined; +} + +/** What following one turn needs from either entry (`runTurn` / `answerQuestion`). */ +interface FollowInput { + readonly sessionId: string; + /** Read before dispatch; reported on `turn.started` and the result. */ + readonly model: MiniAppAgentModelSelection; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; +} + +/** The live resume iterator; replaced when a stale terminal forces a re-resume. */ +interface FrameSource { + current: AsyncIterableIterator; + /** A read that lost a race (cancel / timeout); the next read takes its frame. */ + pending?: Promise>; +} + +/** Reads the next frame unless `stop` settles first; an unread frame stays for the next read. */ +async function read( + source: FrameSource, + stop: Promise, +): Promise | TStop> { + source.pending ??= source.current.next(); + const result = await Promise.race([source.pending, stop]); + if (typeof result !== 'symbol') source.pending = undefined; + return result; +} + +interface FollowedTurn { + readonly source: FrameSource; + readonly turnId: string; + readonly aborted: Promise; + /** Before the turn was requested; only questionnaires created since belong to it. */ + readonly startedAtMs: number; +} + +/** How long a cancelled run waits for its own terminal so the session is idle afterwards. */ +const ABORT_SETTLE_TIMEOUT_MS = 15_000; +/** How long an answer waits for the continuation turn it should start. */ +const CONTINUATION_START_TIMEOUT_MS = 15_000; + +async function runMiniAppAgentTurn( + options: MiniAppAgentSessionPortsOptions, + input: RunTurnInput, +): Promise { + const model = await modelBeforeDispatch(options, input.sessionId); + const follow = { ...input, model }; + // Subscribe before steering; the iterator buffers frames until the turnId is known. + return withTurnStream(options, input, async (source, aborted) => { + const startedAtMs = options.nowMs(); + const turnId = await activateTurn(options, input); + return followOwnedTurn(options, follow, { source, turnId, aborted, startedAtMs }); + }); +} + +/** Opens the session stream and the abort watch for one run; both are released when it settles. */ +async function withTurnStream( + options: MiniAppAgentSessionPortsOptions, + input: { readonly sessionId: string; readonly signal?: AbortSignal }, + follow: (source: FrameSource, aborted: Promise) => Promise, +): Promise { + const source: FrameSource = { + current: options.stream.resume({ sessionId: input.sessionId, waitForTerminal: true }), + }; + const aborted = abortPromise(input.signal); + try { + return await follow(source, aborted.promise); + } finally { + aborted.dispose(); + await source.current.return?.(); + } +} + +/** + * Answers the questionnaire the session paused on and follows the continuation turn the + * answer starts. The stream is subscribed before replying, and the continuation is the + * first turn-scoped frame that follows. An answer the runtime saved without starting a + * continuation settles `finished` without a turn; one whose continuation is not observed + * within the bound has an unknown outcome (the turn may still start later). + */ +async function answerMiniAppAgentQuestion( + options: MiniAppAgentSessionPortsOptions, + input: AnswerQuestionInput, +): Promise { + const { sessionId, requestId } = input; + const { agentName, pending } = await beforeDispatch( + options, + { stage: 'answer-lookup', sessionId, requestId }, + async () => { + const name = (await options.repository.get(sessionId))?.agentName; + return { + agentName: name, + pending: name ? await pendingQuestionOf(options, name, sessionId) : undefined, + }; + }, + ); + // A request id of another session (even of the same agent) is never settled from here. + if (!agentName || pending?.requestId !== requestId) { + throw new MiniAppAgentError('REQUEST_NOT_FOUND', 'Mini App Agent question is not pending'); + } + const model = await modelBeforeDispatch(options, sessionId); + const follow = { ...input, model }; + return withTurnStream(options, input, async (source, aborted) => { + const startedAtMs = options.nowMs(); + const reply = await replyQuestion(options, agentName, input); + if (reply.injected === false) return { status: 'finished' }; + const first = await awaitContinuation(source, aborted); + if (first === TIMED_OUT) { + options.logger.warn( + { + stage: 'answer-continuation', + sessionId, + requestId, + timeoutMs: CONTINUATION_START_TIMEOUT_MS, + }, + 'Mini App Agent answer continuation turn was not observed', + ); + throw outcomeUnknown( + 'Mini App Agent answer was recorded but the follow-up turn was not observed', + ); + } + if (first === ABORTED) { + return finish(follow, await abortUnidentifiedTurn(options, sessionId, source)); + } + source.current = prepend(first, source.current); + return followOwnedTurn(options, follow, { + source, + turnId: first.turnId, + aborted, + startedAtMs, + }); + }); +} + +async function awaitContinuation( + source: FrameSource, + aborted: Promise, +): Promise { + const deadline = timeoutPromise(CONTINUATION_START_TIMEOUT_MS); + try { + return await nextTurnFrame(source, Promise.race([aborted, deadline.promise])); + } finally { + deadline.dispose(); + } +} + +async function replyQuestion( + options: MiniAppAgentSessionPortsOptions, + agentName: string, + input: AnswerQuestionInput, +): Promise { + try { + return await options.questionnaires.reply({ + agentName, + requestId: input.requestId, + answers: [...input.answers], + submittedAt: options.nowMs(), + }); + } catch (error) { + // These statuses are raised before the answer is saved, so nothing was dispatched. Any + // other failure (e.g. 503 QUESTIONNAIRE_CONTINUATION_NOT_STARTED, raised after the answer + // was persisted) propagates and is reported as an unknown outcome. + const status = readNumber(error, 'status'); + if (status === 400) { + throw new MiniAppAgentError('INVALID_ARGUMENTS', 'Mini App Agent answers were rejected'); + } + if (status === 404 || status === 409 || status === 410) { + throw new MiniAppAgentError('REQUEST_NOT_FOUND', 'Mini App Agent question is not pending'); + } + throw error; + } +} + +async function pendingQuestionOf( + options: MiniAppAgentSessionPortsOptions, + agentName: string, + sessionId: string, +): Promise { + const request = await options.questionnaires.getPending({ agentName, sessionId }); + return request + ? { requestId: request.id, mode: request.mode ?? 'questionnaire', request } + : undefined; +} + +async function replyMiniAppAgentPermission( + options: MiniAppAgentSessionPortsOptions, + input: Parameters[0], +): Promise<'processed' | 'skipped'> { + // Nothing is decided until `permissions.reply`; a failed lookup before it may be retried. + const decidable = await beforeDispatch( + options, + { stage: 'reply-lookup', sessionId: input.sessionId, requestId: input.requestId }, + () => isRaisedUnderSameRoot(options, input), + ); + if (!decidable) return 'skipped'; + const result = await options.permissions.reply({ + requestIds: [input.requestId], + decision: input.decision, + }); + return result.processed.includes(input.requestId) ? 'processed' : 'skipped'; +} + +/** Approval request ids are global; only a request raised under the same root may be decided. */ +async function isRaisedUnderSameRoot( + options: MiniAppAgentSessionPortsOptions, + input: { readonly sessionId: string; readonly requestId: string }, +): Promise { + const root = await resolveMiniAppRoot(options, input.sessionId); + if (!root) return false; + const pending = await options.permissions.listPending(); + const request = pending.find((entry) => entry.requestId === input.requestId); + if (!request) return false; + // Raised by the named session or the root itself: its root is already known. + if (request.sessionId === input.sessionId || request.sessionId === root.rootSessionId) { + return true; + } + const owner = await resolveMiniAppRoot(options, request.sessionId); + return owner?.rootSessionId === root.rootSessionId; +} + +function projectPermissionAsk( + payload: Extract['payload'], +): MiniAppPermissionAsk { + return { + sessionId: payload.sessionId, + requestId: payload.requestId, + toolName: payload.toolName, + reason: payload.reason, + ...(payload.turnId ? { turnId: payload.turnId } : {}), + ...(payload.toolInput === undefined ? {} : { toolInput: payload.toolInput }), + }; +} + +async function followOwnedTurn( + options: MiniAppAgentSessionPortsOptions, + input: FollowInput, + run: FollowedTurn, +): Promise { + try { + return await followTurn(options, input, run); + } catch (error) { + // A Mini App Agent error here is raised after the turn ended or after its abort was + // requested; anything else means the dispatched turn is no longer observed, so do not + // leave it running without a caller. + if (!(error instanceof MiniAppAgentError)) { + await abortQuietly(options, input.sessionId, run.turnId); + } + throw error; + } +} + +async function abortQuietly( + options: MiniAppAgentSessionPortsOptions, + sessionId: string, + turnId: string, +): Promise { + try { + await options.turns.abort({ sessionId, turnId, reason: 'miniapp-stream-lost' }); + } catch (error) { + // Best effort; the original failure is what the caller sees. + options.logger.warn( + { stage: 'abort-unobserved-turn', sessionId, turnId, ...errorFields(error) }, + 'Mini App Agent could not abort a turn it no longer observes', + ); + } +} + +async function activateTurn( + options: MiniAppAgentSessionPortsOptions, + input: RunTurnInput, +): Promise { + const { sessionId } = input; + const requestId = randomUUID(); + const steered = await options.turns.steer({ + sessionId, + input: { text: input.text }, + producerId: `miniapp:${sessionId}`, + idempotencyKey: requestId, + provenance: { + source: 'miniapp', + routingFingerprint: `miniapp:${sessionId}:${requestId}`, + }, + }); + if (!steered.delivered) { + throw new MiniAppAgentError('AGENT_UNAVAILABLE', 'Mini App Agent turn was rejected', true); + } + // A steered delivery merged the text into a turn this run does not own. + if (steered.mode !== 'activated') { + throw new MiniAppAgentError('BUSY', 'Mini App Agent session is busy', true, 'session_busy'); + } + return steered.turnId; +} + +async function followTurn( + options: MiniAppAgentSessionPortsOptions, + input: FollowInput, + run: FollowedTurn, +): Promise { + const { sessionId } = input; + const { source, turnId } = run; + const projector = createFrameProjector(sessionId, turnId, input.model); + for (;;) { + const next = await read(source, run.aborted); + if (next === ABORTED) { + const abort = await options.turns.abort({ sessionId, turnId, reason: 'miniapp-cancelled' }); + const terminal = await settleAbortedTurn(options, sessionId, source, turnId); + return finish(input, confirmCancelled(options, { sessionId, turnId, abort, terminal })); + } + if (next.done) throw new Error('Session stream ended before the Mini App Agent turn terminal'); + const frame = next.value; + // Frames without a turnId are session-level; frames of other turns are not this run's. + if (frame.turnId !== undefined && frame.turnId !== turnId) { + await resumeAfterStaleTerminal(options, sessionId, source, frame); + continue; + } + if (frame.kind === 'turn-terminal') { + return settleTerminal(options, input, run, frame.data); + } + emit(input.onEvent, projector.project(frame)); + } +} + +/** + * The resume iterator closes on any turn-terminal; a stale one from an earlier turn must not + * end this run, so continue from its cursor (the ring replays what followed). + */ +async function resumeAfterStaleTerminal( + options: MiniAppAgentSessionPortsOptions, + sessionId: string, + source: FrameSource, + frame: SessionFrame, +): Promise { + if (frame.kind !== 'turn-terminal' || !frame.cursor) return; + await source.current.return?.(); + source.current = options.stream.resume({ + sessionId, + afterCursor: frame.cursor, + waitForTerminal: true, + }); +} + +/** A turn that ended on a questionnaire it raised reports it before `turn.finished`. */ +async function settleTerminal( + options: MiniAppAgentSessionPortsOptions, + input: FollowInput, + run: FollowedTurn, + data: unknown, +): Promise { + const { sessionId } = input; + const { turnId } = run; + const result = await terminalResult(options, sessionId, turnId, data); + if (result.status === 'aborted') return finish(input, result); + let question: PendingQuestion | undefined; + try { + question = await findPendingQuestion(options, input, run); + } catch (error) { + options.logger.warn( + { stage: 'pending-question-lookup', sessionId, turnId, ...errorFields(error) }, + 'Mini App Agent pending questionnaire lookup failed', + ); + // The turn did end: deliver its terminal, but not as proof that nothing is pending. + finish(input, result); + throw outcomeUnknown( + 'The Agent turn ended, but whether a question is pending could not be determined; ' + + 'inspect the session, then continue with run({ sessionId }) or answer', + ); + } + if (!question) return finish(input, result); + emit(input.onEvent, [ + { + type: 'question.requested', + sessionId, + turnId, + requestId: question.requestId, + mode: question.mode, + request: question.request, + }, + ]); + return finish(input, { ...result, pendingQuestion: question }); +} + +/** + * Only a questionnaire created during this turn is its question; an older one left unanswered + * is not re-reported. + */ +async function findPendingQuestion( + options: MiniAppAgentSessionPortsOptions, + input: FollowInput, + run: FollowedTurn, +): Promise { + const { sessionId } = input; + const agentName = (await options.repository.get(sessionId))?.agentName; + const question = agentName ? await pendingQuestionOf(options, agentName, sessionId) : undefined; + const createdAt = question?.request.createdAt; + return createdAt === undefined || createdAt >= run.startedAtMs ? question : undefined; +} + +type TurnFrame = SessionFrame & { readonly turnId: string }; + +/** Reads until the first turn-scoped frame, or the `stop` symbol; session-level frames have no turn. */ +async function nextTurnFrame( + source: FrameSource, + stop: Promise, +): Promise { + for (;;) { + const next = await read(source, stop); + if (typeof next === 'symbol') return next; + if (next.done) throw new Error('Session stream ended before the Mini App Agent turn started'); + const frame = next.value; + if (frame.turnId !== undefined) return { ...frame, turnId: frame.turnId }; + } +} + +/** + * Cancelled before the continuation turn was seen: wait (bounded) for it to appear, abort it + * and wait for its terminal, so the session is idle for the next call. Not seeing the turn + * at all leaves the outcome unknown: it may still start after this call returns. + */ +async function abortUnidentifiedTurn( + options: MiniAppAgentSessionPortsOptions, + sessionId: string, + source: FrameSource, +): Promise { + const deadline = timeoutPromise(ABORT_SETTLE_TIMEOUT_MS); + let first: TurnFrame | typeof TIMED_OUT; + try { + first = await nextTurnFrame(source, deadline.promise); + } catch (error) { + options.logger.warn( + { stage: 'cancel-continuation-watch', sessionId, ...errorFields(error) }, + 'Mini App Agent lost the stream while waiting for the cancelled continuation turn', + ); + first = TIMED_OUT; + } finally { + deadline.dispose(); + } + if (first === TIMED_OUT) { + options.logger.warn( + { stage: 'cancel-continuation', sessionId, timeoutMs: ABORT_SETTLE_TIMEOUT_MS }, + 'Mini App Agent cancelled answer: continuation turn was not observed', + ); + throw outcomeUnknown( + 'Mini App Agent answer was cancelled but the follow-up turn was not observed', + ); + } + const turnId = first.turnId; + if (first.kind === 'turn-terminal') { + const terminal = { status: readString(first.data, 'status') }; + return confirmCancelled(options, { sessionId, turnId, terminal }); + } + const abort = await options.turns.abort({ sessionId, turnId, reason: 'miniapp-cancelled' }); + const terminal = await settleAbortedTurn(options, sessionId, source, turnId); + return confirmCancelled(options, { sessionId, turnId, abort, terminal }); +} + +/** The terminal frame a cancelled run saw for its turn; `status` is as the frame carried it. */ +interface ObservedTerminal { + readonly status: string | undefined; +} + +/** + * A cancel is reported `aborted` only when the runtime acknowledged the abort or the turn's + * own `aborted` terminal was seen. A timeout, an ended stream, or a turn that was no longer + * running (it may have finished on its own) does not prove the abort. + */ +function confirmCancelled( + options: MiniAppAgentSessionPortsOptions, + cancel: { + readonly sessionId: string; + readonly turnId: string; + readonly abort?: AbortTurnResult; + readonly terminal: ObservedTerminal | undefined; + }, +): RunTurnResult { + const { sessionId, turnId, abort, terminal } = cancel; + if (abort?.status === 'aborted' || terminal?.status === 'aborted') { + return { turnId, status: 'aborted' }; + } + options.logger.warn( + { + stage: 'cancel-confirmation', + sessionId, + turnId, + abortStatus: abort?.status ?? 'not-requested', + terminalStatus: terminal ? (terminal.status ?? 'missing') : 'not-observed', + }, + 'Mini App Agent cancel was not confirmed', + ); + const observed = terminal?.status; + throw outcomeUnknown( + observed === undefined + ? 'Mini App Agent run was cancelled but the abort was not confirmed' + : `Mini App Agent run was cancelled but the turn ended on its own first (terminal status: ${observed}); inspect the session before continuing`, + ); +} + +function prepend( + frame: SessionFrame, + rest: AsyncIterableIterator, +): AsyncIterableIterator { + let head: SessionFrame | undefined = frame; + const iterator: AsyncIterableIterator = { + [Symbol.asyncIterator]: () => iterator, + next() { + if (!head) return rest.next(); + const value = head; + head = undefined; + return Promise.resolve({ done: false, value }); + }, + return: () => rest.return?.() ?? Promise.resolve({ done: true, value: undefined }), + }; + return iterator; +} + +/** + * Waits (bounded) for the aborted turn's own terminal so a follow-up run finds the session + * idle; undefined when it was not seen (timeout or the stream ended). + */ +async function settleAbortedTurn( + options: MiniAppAgentSessionPortsOptions, + sessionId: string, + source: FrameSource, + turnId: string, +): Promise { + const deadline = timeoutPromise(ABORT_SETTLE_TIMEOUT_MS); + try { + for (;;) { + const next = await read(source, deadline.promise); + if (next === TIMED_OUT || next.done) return undefined; + if (next.value.kind === 'turn-terminal' && next.value.turnId === turnId) { + return { status: readString(next.value.data, 'status') }; + } + if (next.value.turnId !== turnId) { + await resumeAfterStaleTerminal(options, sessionId, source, next.value); + } + } + } finally { + deadline.dispose(); + } +} + +const TIMED_OUT = Symbol('timed-out'); + +function timeoutPromise(ms: number): { + readonly promise: Promise; + dispose(): void; +} { + let timer: ReturnType | undefined; + const promise = new Promise((resolve) => { + timer = setTimeout(() => resolve(TIMED_OUT), ms); + }); + return { promise, dispose: () => clearTimeout(timer) }; +} + +const ABORTED = Symbol('aborted'); + +function abortPromise(signal: AbortSignal | undefined): { + readonly promise: Promise; + dispose(): void; +} { + if (!signal) return { promise: new Promise(() => undefined), dispose: () => undefined }; + let onAbort: () => void = () => undefined; + const promise = new Promise((resolve) => { + onAbort = () => resolve(ABORTED); + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }); + return { promise, dispose: () => signal.removeEventListener('abort', onAbort) }; +} + +/** + * The final event carries the settled result, so it is emitted only once that result exists; + * a result without a turn (nothing ran) has no `turn.finished`. + */ +function finish(input: FollowInput, result: RunTurnResult): RunTurnResult { + if (result.turnId === undefined) return result; + const reported = { ...result, model: input.model }; + emit(input.onEvent, [ + { + type: 'turn.finished', + sessionId: input.sessionId, + turnId: result.turnId, + status: result.status, + ...(result.text === undefined ? {} : { text: result.text }), + ...(result.error ? { error: result.error } : {}), + }, + ]); + return reported; +} + +function emit( + onEvent: ((event: MiniAppAgentRunEvent) => void) | undefined, + events: readonly MiniAppAgentRunEvent[], +): void { + if (!onEvent) return; + for (const event of events) { + try { + onEvent(event); + } catch { + // A listener failure must not break the run. + } + } +} + +async function terminalResult( + options: MiniAppAgentSessionPortsOptions, + sessionId: string, + turnId: string, + data: unknown, +): Promise { + const status = readField(data, 'status'); + if (status === 'aborted') return { turnId, status: 'aborted' }; + if (status === 'failed') { + const code = readString(data, 'errorSource'); + return { + turnId, + status: 'error', + error: { + code: code && TURN_ERROR_CODE_PATTERN.test(code) ? code : 'TURN_FAILED', + message: 'The Agent turn failed.', + }, + }; + } + if (status !== 'completed') { + // Malformed terminal data says nothing about how the turn ended. + options.logger.warn( + { stage: 'turn-terminal', sessionId, turnId, terminalStatus: status ?? null }, + 'Mini App Agent turn terminal has no known status', + ); + throw outcomeUnknown('Mini App Agent turn ended with an unreadable result'); + } + const text = finalAssistantText(await options.messages.listTurn(sessionId, turnId)); + return { turnId, status: 'finished', ...(text ? { text } : {}) }; +} + +/** Runtime error sources are forwarded only when they are short identifiers, never free text. */ +const TURN_ERROR_CODE_PATTERN = /^[A-Za-z][\w.-]{0,63}$/; + +function finalAssistantText(messages: readonly DisplayMessageRecord[]): string | undefined { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role !== 'assistant') continue; + const body = message.msg_content ?? message.msgContent ?? message.content; + if (typeof body === 'string' && body.trim()) return body; + } + return undefined; +} + +function readString(value: unknown, key: string): string | undefined { + const field = readField(value, key); + return typeof field === 'string' ? field : undefined; +} + +function readNumber(value: unknown, key: string): number | undefined { + const field = readField(value, key); + return typeof field === 'number' ? field : undefined; +} + +function readField(value: unknown, key: string): unknown { + if (typeof value !== 'object' || value === null) return undefined; + return (value as Record)[key]; +} diff --git a/packages/local-runtime-v2/src/service/miniapp/composition.ts b/packages/local-runtime-v2/src/service/miniapp/composition.ts index ca34226d2..2d029b28b 100644 --- a/packages/local-runtime-v2/src/service/miniapp/composition.ts +++ b/packages/local-runtime-v2/src/service/miniapp/composition.ts @@ -2,6 +2,7 @@ import { join } from 'node:path'; import type { AppDb } from '../../infra/db/client.js'; import type { HostProcessConnectorGateway } from '../host-connector-system/index.js'; +import type { MiniAppAgentGateway } from './agent/contracts.js'; import type { MiniAppCandidate, MiniAppSupervisor } from './contracts.js'; import { initializeMiniAppSupervisor, @@ -27,13 +28,15 @@ export interface RuntimeMiniAppServiceOptions { } export function resolveRuntimeMiniAppOptions( - input: RuntimeMiniAppServiceOptions & { readonly runtimeOwnerKind?: string }, + input: RuntimeMiniAppServiceOptions & { + /** The runtime owner policy's `wiring.miniAppService` cell for this host. */ + readonly miniAppService?: boolean; + }, ): RuntimeMiniAppOptions | undefined { - if (input.runtimeOwnerKind !== undefined && input.runtimeOwnerKind !== 'electron') { - return undefined; - } + // Explicit options stay reachable: a caller that constructs the runtime with them has asked for the + // service by name, including the case with no declared owner kind. if (input.miniApp) return input.miniApp; - return input.runtimeOwnerKind === 'electron' ? {} : undefined; + return input.miniAppService === true ? {} : undefined; } export function createDeferredRuntimeOwnersLifecycle(): { @@ -79,6 +82,8 @@ export async function composeMiniAppRuntimeCapability(input: { readonly dataDir: string; readonly nowMs: () => number; readonly plugin: MiniAppPluginPublicationCapability; + /** Passed through so the child IPC connector can register its arena on the real gateway. */ + readonly agentGateway?: MiniAppAgentGateway; readonly readyOwners: () => Promise; readonly closeOwners: () => Promise; }): Promise<{ @@ -93,6 +98,7 @@ export async function composeMiniAppRuntimeCapability(input: { ...input.options, db: input.db, connectorGateway: input.plugin.hostConnectorGateway, + ...(input.agentGateway ? { agentGateway: input.agentGateway } : {}), verifyCandidate: (candidate) => input.plugin.verifyMiniAppCandidate(candidate), materializePackage: (materialization) => input.plugin.materializeMiniAppRuntimePackage(materialization), diff --git a/packages/local-runtime-v2/src/service/miniapp/contracts.ts b/packages/local-runtime-v2/src/service/miniapp/contracts.ts index 59d446393..cd7032114 100644 --- a/packages/local-runtime-v2/src/service/miniapp/contracts.ts +++ b/packages/local-runtime-v2/src/service/miniapp/contracts.ts @@ -1,3 +1,38 @@ +/** Largest `agentConcurrency` a Mini App manifest may declare. */ +export const MINIAPP_AGENT_MAX_CONCURRENCY = 32; + +/** + * Session `purpose` label that marks a Mini App-owned session, followed by the plugin id. + * + * It lives here, with the rest of the Mini App policy constants, because the session boundary rejects + * client-supplied `purpose` values starting with it and the turn owner reads it for approval lineage. + */ +export const MINIAPP_AGENT_PURPOSE_PREFIX = 'miniapp:'; + + +/** Runs of one plugin at once when its manifest omits `agentConcurrency`. */ +export const MINIAPP_AGENT_DEFAULT_CONCURRENCY = 4; + +type MiniAppAgentCapability = 'session.run'; + +/** + * Derived from the parsed manifest's `agentAccess`; absent or empty means deny-all. + * `maxConcurrent` is the manifest's `agentConcurrency` (runs of this plugin at once). + */ +export type MiniAppAgentPolicy = + | { readonly kind: 'deny-all' } + | { + readonly kind: 'allowlist'; + readonly capabilities: readonly MiniAppAgentCapability[]; + readonly maxConcurrent: number; + }; + +/** Every Mini App HTTP server answers this path from the guard, never from plugin code. */ +export const MINIAPP_LOOPBACK_HEALTH_PATH = '/__mavis/health'; + +/** Request header carrying the Host-issued loopback credential. */ +export const MINIAPP_LOOPBACK_TOKEN_HEADER = 'x-mavis-miniapp-token'; + export type MiniAppHostConnectorPolicy = | { readonly kind: 'deny-all' } | { readonly kind: 'allowlist'; readonly providers: readonly string[] }; @@ -19,6 +54,8 @@ export interface ProcessMiniAppCandidate extends MiniAppCandidateBase { readonly path: string; }[]; readonly hostConnectorPolicy: MiniAppHostConnectorPolicy; + /** Optional so existing fixtures stay valid; missing is treated as deny-all. */ + readonly agentPolicy?: MiniAppAgentPolicy; } /** Process-only package contract consumed by the PluginSystem composition layer. */ @@ -49,15 +86,16 @@ export interface MiniAppHostConnectorSession { close(): Promise; } -export interface MiniAppHostConnectorReadable { - on(event: 'data' | 'end' | 'error', listener: (...args: unknown[]) => void): unknown; - removeListener(event: string, listener: (...args: unknown[]) => void): unknown; +/** One message on the parent/child IPC channel; lifecycle frames carry their own `type`. */ +export interface MiniAppIpcMessage { + readonly type: 'miniapp:connector'; + readonly payload: unknown; } -export interface MiniAppHostConnectorWritable { - readonly destroyed?: boolean; - write(chunk: Buffer): boolean; - once(event: 'drain' | 'error' | 'close', listener: (...args: unknown[]) => void): unknown; +export interface MiniAppIpcTransport { + readonly connected?: boolean; + send(message: MiniAppIpcMessage, callback: (error: Error | null) => void): boolean; + on(event: 'message' | 'disconnect' | 'error', listener: (...args: unknown[]) => void): unknown; removeListener(event: string, listener: (...args: unknown[]) => void): unknown; } @@ -66,8 +104,10 @@ export interface MiniAppHostConnectorSessionFactory { readonly pluginId: string; readonly processGeneration: string; readonly providers: readonly string[]; - readonly request: MiniAppHostConnectorReadable; - readonly response: MiniAppHostConnectorWritable; + /** Missing is treated as deny-all; the child then receives no `agent` context. */ + readonly agentPolicy?: MiniAppAgentPolicy; + /** The parent/child IPC channel, shared with lifecycle frames and told apart by `type`. */ + readonly transport: MiniAppIpcTransport; readonly onFatal: (error: Error) => void; }): MiniAppHostConnectorSession; } @@ -91,12 +131,16 @@ export interface MiniAppNodeRuntime { readonly nodeDigest: string; readonly port: number; readonly origin: string; + /** Server-only: the proxy injects it. Never forwarded to a renderer or a published view. */ + readonly loopbackToken: string; readonly hostConnectorSession?: MiniAppHostConnectorSession; readonly closed: Promise; stop(): Promise<{ readonly proven: boolean }>; } export interface MiniAppNodeRuntimeFactory { + /** Reads the renderer-safe owner label after the process guard proves another client is live. */ + ownerClient?(pluginId: string): Promise; prepare(input: { readonly candidate: ProcessMiniAppCandidate; readonly processGeneration: string; @@ -226,6 +270,13 @@ export interface MiniAppSupervisor extends MiniAppPublicationCoordinator { readonly expectedProcessGeneration?: string; }): Promise; inspect(pluginId?: string): readonly MiniAppStatus[]; + /** The label recorded by a live external Mini App owner, when readable. */ + ownerClient?(pluginId: string): Promise; + /** + * Server-only: the live generation's loopback credential, so the proxy that injects it has something + * to inject. Never part of a published view and never forwarded to a renderer. + */ + launchCredential(pluginId: string): string | undefined; close(): Promise; } diff --git a/packages/local-runtime-v2/src/service/miniapp/errors.ts b/packages/local-runtime-v2/src/service/miniapp/errors.ts index 90fb1437d..da33dd1bc 100644 --- a/packages/local-runtime-v2/src/service/miniapp/errors.ts +++ b/packages/local-runtime-v2/src/service/miniapp/errors.ts @@ -22,6 +22,9 @@ type MiniAppFailureReasonCode = | 'HOST_CONNECTOR_PROTOCOL_VIOLATION' | 'HOST_CONNECTOR_UNAVAILABLE' | 'NO_CANDIDATE_PORT' + | 'OWNED_ELSEWHERE' + | 'READINESS_PROBE_REJECTED' + | 'READINESS_PROBE_TIMEOUT' | 'READINESS_TIMEOUT' | 'RUNNER_LIFECYCLE_INVALID' | 'RUNNER_START_FAILED' @@ -34,6 +37,9 @@ const MINIAPP_RUNTIME_FAILURE_REASON_CODES: ReadonlySet = new Set Promise; readonly materializePackage: (input: { readonly sourceRoot: string; @@ -30,12 +35,14 @@ export function initializeMiniAppSupervisor( const ports = new HostMiniAppPortRegistry(options.productReservedPorts); const nodeRuntime = createMiniAppNodeRuntimeAdapter({ resolvePluginDataDir: options.resolvePluginDataDir, + ...(options.ownerClient ? { ownerClient: options.ownerClient } : {}), verifyCandidate: options.verifyCandidate, materializePackage: options.materializePackage, isPortReserved: (input) => ports.isReserved(input), reservePort: (input) => ports.reserve(input), hostConnectorSessionFactory: createMiniAppHostConnectorSessionFactory({ gateway: options.connectorGateway, + ...(options.agentGateway ? { agentGateway: options.agentGateway } : {}), }), }); const supervisor = new DefaultMiniAppSupervisor({ diff --git a/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-agent.ts b/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-agent.ts new file mode 100644 index 000000000..6d704ba46 --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-agent.ts @@ -0,0 +1,1047 @@ +import { + isMiniAppAgentPermissionDecision, + isMiniAppAgentQuestionMode, + readMiniAppAgentAnswers, +} from '@mavis/shared/miniapp-surface'; +import type { AskQuestionnaireRequest } from '@mavis/shared/questionnaire'; + +import { + isMiniAppAgentBusyReason, + MINIAPP_AGENT_ERROR_CODES, + MiniAppAgentError, + type MiniAppAgentAgents, + type MiniAppAgentAnswerInput, + type MiniAppAgentBusyReason, + type MiniAppAgentCaller, + type MiniAppAgentErrorCode, + type MiniAppAgentGateway, + type MiniAppAgentModels, + type MiniAppAgentModelSelection, + type MiniAppAgentReplyInput, + type MiniAppAgentRunEvent, + type MiniAppAgentRunInput, + type MiniAppAgentRunOptions, + type MiniAppAgentRunResult, +} from '../../agent/contracts.js'; +import { readMiniAppAgentModelSelection } from '../../agent/model-selection.js'; +import type { MiniAppAgentPolicy } from '../../contracts.js'; +import { HostConnectorProtocolError } from './host-connector-errors.js'; +import { + type AgentRequestMethod, + hasExactKeys, + isBoundedIdentifier, + isRecord, +} from './host-connector-wire.js'; + +/** Child-side `context.agent`; mirrors `MiniAppAgentClient` in the authoring API. */ +export interface MiniAppAgentClient { + run( + input: MiniAppAgentRunInput, + options?: MiniAppAgentRunOptions, + ): Promise; + reply(input: MiniAppAgentReplyInput): Promise; + answer( + input: MiniAppAgentAnswerInput, + options?: MiniAppAgentRunOptions, + ): Promise; + models(): Promise; + agents(): Promise; + create(input?: { readonly agent?: string }): Promise<{ readonly sessionId: string }>; +} + +interface MiniAppAgentWireError { + readonly code: MiniAppAgentErrorCode; + readonly message: string; + readonly retryable: boolean; + /** Only on `BUSY`. */ + readonly reason?: MiniAppAgentBusyReason; +} + +const AGENT_ERROR_MESSAGES: Readonly> = { + NOT_PERMITTED: 'Mini App Agent access is not permitted', + BUSY: 'Mini App Agent is busy', + SESSION_NOT_FOUND: 'Mini App Agent session was not found', + INVALID_ARGUMENTS: 'Mini App Agent arguments are invalid', + AGENT_UNAVAILABLE: 'Mini App Agent is unavailable', + REQUEST_CANCELLED: 'Mini App Agent run was cancelled', + AGENT_OUTCOME_UNKNOWN: 'Mini App Agent outcome is unknown after dispatch', + REQUEST_NOT_FOUND: 'Mini App Agent request was not found or already settled', +}; + +const AGENT_ERROR_CODES: ReadonlySet = new Set(MINIAPP_AGENT_ERROR_CODES); + +/** Event frames buffered per run before later ones are dropped; the terminal frame is never dropped. */ +const MAX_QUEUED_AGENT_EVENTS = 256; + +/** Events the child must act on; never dropped for the queue bound. */ +const INTERACTION_EVENT_TYPES: ReadonlySet = new Set([ + 'permission.requested', + 'question.requested', +]); + +export function agentClientError( + code: MiniAppAgentErrorCode, + retryable = false, +): MiniAppAgentError { + return new MiniAppAgentError(code, AGENT_ERROR_MESSAGES[code], retryable); +} + +export function encodeAgentRunParams(input: unknown): Record { + if ( + !isRecord(input) || + typeof input.text !== 'string' || + (input.sessionId !== undefined && !isBoundedIdentifier(input.sessionId)) || + (input.agent !== undefined && !isBoundedIdentifier(input.agent)) + ) { + throw agentClientError('INVALID_ARGUMENTS'); + } + return { + text: input.text, + ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), + ...encodeAgentModel(input.model), + ...(input.agent === undefined ? {} : { agent: input.agent }), + }; +} + +function encodeAgentModel(model: unknown): { readonly model?: MiniAppAgentModelSelection } { + if (model === undefined) return {}; + const selection = readMiniAppAgentModelSelection(model); + if (!selection) throw agentClientError('INVALID_ARGUMENTS'); + return { model: selection }; +} + +/** A present `model` must decode; the child client already refuses malformed ones. */ +function decodeAgentModel(params: Record): { + readonly model?: MiniAppAgentModelSelection; +} { + if (!('model' in params)) return {}; + const selection = readMiniAppAgentModelSelection(params.model); + if (!selection) throw new HostConnectorProtocolError('Host Connector request is invalid'); + return { model: selection }; +} + +export function encodeAgentCreateParams(input: unknown): Record { + if (input === undefined) return {}; + if (!isRecord(input) || (input.agent !== undefined && !isBoundedIdentifier(input.agent))) { + throw agentClientError('INVALID_ARGUMENTS'); + } + return input.agent === undefined ? {} : { agent: input.agent }; +} + +function decodeAgentCreateParams(params: Record | undefined): { + readonly agent?: string; +} { + if ( + !params || + !hasOptionalKeys(params, [], ['agent']) || + !isOptional(params, 'agent', isBoundedIdentifier) + ) { + throw new HostConnectorProtocolError('Host Connector request is invalid'); + } + return typeof params.agent === 'string' ? { agent: params.agent } : {}; +} + +function decodeAgentRunParams(params: Record | undefined): { + readonly params: MiniAppAgentRunInput; + readonly observe: boolean; +} { + if ( + !params || + !hasOptionalKeys(params, ['text'], ['sessionId', 'observe', 'model', 'agent']) || + typeof params.text !== 'string' || + !isOptional(params, 'sessionId', isBoundedIdentifier) || + !isOptional(params, 'agent', isBoundedIdentifier) || + !isOptional(params, 'observe', isBoolean) + ) { + throw new HostConnectorProtocolError('Host Connector request is invalid'); + } + return { + params: { + text: params.text, + ...(typeof params.sessionId === 'string' ? { sessionId: params.sessionId } : {}), + ...decodeAgentModel(params), + ...(typeof params.agent === 'string' ? { agent: params.agent } : {}), + }, + observe: params.observe === true, + }; +} + +export function encodeAgentReplyParams(input: unknown): Record { + if ( + !isRecord(input) || + !isBoundedIdentifier(input.sessionId) || + !isBoundedIdentifier(input.requestId) || + !isMiniAppAgentPermissionDecision(input.decision) + ) { + throw agentClientError('INVALID_ARGUMENTS'); + } + return { sessionId: input.sessionId, requestId: input.requestId, decision: input.decision }; +} + +function decodeAgentReplyParams( + params: Record | undefined, +): MiniAppAgentReplyInput { + if ( + !params || + !hasExactKeys(params, ['sessionId', 'requestId', 'decision']) || + !isBoundedIdentifier(params.sessionId) || + !isBoundedIdentifier(params.requestId) || + !isMiniAppAgentPermissionDecision(params.decision) + ) { + throw new HostConnectorProtocolError('Host Connector request is invalid'); + } + return { sessionId: params.sessionId, requestId: params.requestId, decision: params.decision }; +} + +export function encodeAgentAnswerParams(input: unknown): Record { + const answers = isRecord(input) ? readMiniAppAgentAnswers(input.answers) : null; + if ( + !isRecord(input) || + !answers || + !isBoundedIdentifier(input.sessionId) || + !isBoundedIdentifier(input.requestId) + ) { + throw agentClientError('INVALID_ARGUMENTS'); + } + return { + sessionId: input.sessionId, + requestId: input.requestId, + answers, + ...encodeAgentModel(input.model), + }; +} + +function decodeAgentAnswerParams(params: Record | undefined): { + readonly params: MiniAppAgentAnswerInput; + readonly observe: boolean; +} { + const answers = params ? readMiniAppAgentAnswers(params.answers) : null; + if ( + !params || + !answers || + !hasOptionalKeys(params, ['sessionId', 'requestId', 'answers'], ['observe', 'model']) || + !isBoundedIdentifier(params.sessionId) || + !isBoundedIdentifier(params.requestId) || + !isOptional(params, 'observe', isBoolean) + ) { + throw new HostConnectorProtocolError('Host Connector request is invalid'); + } + return { + params: { + sessionId: params.sessionId, + requestId: params.requestId, + answers, + ...decodeAgentModel(params), + }, + observe: params.observe === true, + }; +} + +function isAgentRunResult(value: unknown): value is MiniAppAgentRunResult { + return ( + isRecord(value) && + hasOptionalKeys( + value, + ['sessionId', 'status'], + ['turnId', 'model', 'text', 'error', 'pendingQuestion'], + ) && + isBoundedIdentifier(value.sessionId) && + isOptional(value, 'turnId', isBoundedIdentifier) && + isOptional(value, 'model', isModelSelection) && + isRunStatus(value.status) && + isOptional(value, 'text', isString) && + isOptional(value, 'error', isResultError) && + isOptional(value, 'pendingQuestion', isPendingQuestion) + ); +} + +/** + * Child side: decodes the terminal result of any `agent.*` request. `agent.reply` settles with + * `result: null`; anything else is a protocol error. + */ +export function decodeAgentResult(method: AgentRequestMethod, value: unknown): unknown { + const decoded = AGENT_RESULT_DECODERS[method](value); + if (decoded) return decoded.value; + throw new HostConnectorProtocolError('Mini App Agent result is invalid'); +} + +type AgentResultDecoder = (value: unknown) => { readonly value: unknown } | undefined; + +const AGENT_RESULT_DECODERS: Readonly> = { + 'agent.reply': (value) => (value === null ? { value: undefined } : undefined), + 'agent.models': (value) => (isAgentModels(value) ? { value } : undefined), + 'agent.agents': (value) => (isAgentAgents(value) ? { value } : undefined), + 'agent.create': (value) => + isRecord(value) && hasExactKeys(value, ['sessionId']) && isBoundedIdentifier(value.sessionId) + ? { value: { sessionId: value.sessionId } } + : undefined, + 'agent.run': decodeAgentTurnResult, + 'agent.answer': decodeAgentTurnResult, +}; + +function decodeAgentTurnResult(value: unknown): { readonly value: unknown } | undefined { + return isAgentRunResult(value) ? { value: projectAgentResult(value) } : undefined; +} + +function isModelSelection(value: unknown): boolean { + return readMiniAppAgentModelSelection(value) !== null; +} + +function isStringArray(value: unknown): boolean { + return Array.isArray(value) && value.every(isString); +} + +function isModelOption(value: unknown): boolean { + return ( + isRecord(value) && + hasOptionalKeys(value, ['providerId', 'modelId', 'displayName'], ['variants', 'thinking']) && + isString(value.providerId) && + isString(value.modelId) && + isString(value.displayName) && + isOptional(value, 'variants', isStringArray) && + isOptional(value, 'thinking', isThinkingOption) + ); +} + +function isThinkingOption(value: unknown): boolean { + return ( + isRecord(value) && + hasOptionalKeys(value, ['efforts'], ['default']) && + isStringArray(value.efforts) && + isOptional(value, 'default', isString) + ); +} + +function isAgentModels(value: unknown): value is MiniAppAgentModels { + return ( + isRecord(value) && + hasOptionalKeys(value, ['models', 'default'], ['host']) && + Array.isArray(value.models) && + value.models.every(isModelOption) && + isModelSelection(value.default) && + isOptional(value, 'host', isModelSelection) + ); +} + +function isAgentAgents(value: unknown): value is MiniAppAgentAgents { + return ( + isRecord(value) && + hasExactKeys(value, ['agents']) && + Array.isArray(value.agents) && + value.agents.every( + (agent) => + isRecord(agent) && + hasExactKeys(agent, ['name', 'displayName', 'primary']) && + isString(agent.name) && + isString(agent.displayName) && + isBoolean(agent.primary), + ) + ); +} + +/** + * Child side: unknown `type`s (a newer Host) are ignored; a known type with a bad shape is a + * protocol error. + */ +export function decodeAgentRunEvent(value: unknown): MiniAppAgentRunEvent | undefined { + const event = copyAgentRunEvent(value); + if (event) return event; + if (isRecord(value) && typeof value.type === 'string' && !agentEventShape(value.type)) { + return undefined; + } + throw new HostConnectorProtocolError('Mini App Agent event is invalid'); +} + +interface AgentEventShape { + /** Keys besides `type`, `sessionId` and `turnId`. */ + readonly required: readonly string[]; + readonly optional?: readonly string[]; + /** `turnId` is required unless set. */ + readonly turnId?: 'optional' | 'absent'; + /** `sessionId` is required unless set. */ + readonly sessionIdOptional?: true; + readonly valid: (value: Record) => boolean; +} + +const isString = (field: unknown): field is string => typeof field === 'string'; +const isBoolean = (field: unknown): field is boolean => typeof field === 'boolean'; +const TEXT_EVENT: AgentEventShape = { required: ['text'], valid: (value) => isString(value.text) }; + +const AGENT_EVENT_SHAPES: Readonly> = { + 'run.queued': { + required: ['position'], + turnId: 'absent', + sessionIdOptional: true, + valid: (value) => + typeof value.position === 'number' && + Number.isSafeInteger(value.position) && + value.position >= 1, + }, + 'turn.started': { required: ['model'], valid: (value) => isModelSelection(value.model) }, + 'text.delta': TEXT_EVENT, + 'message.committed': TEXT_EVENT, + 'tool.started': { required: ['name'], valid: (value) => isString(value.name) }, + 'tool.finished': { + required: ['name', 'ok'], + valid: (value) => isString(value.name) && typeof value.ok === 'boolean', + }, + 'permission.requested': { + required: ['requestId', 'toolName', 'reason'], + optional: ['toolInput'], + turnId: 'optional', + valid: (value) => + isBoundedIdentifier(value.requestId) && + isString(value.toolName) && + isString(value.reason) && + isOptional(value, 'toolInput', isString), + }, + 'question.requested': { + required: ['requestId', 'mode', 'request'], + turnId: 'optional', + valid: (value) => + isBoundedIdentifier(value.requestId) && + isMiniAppAgentQuestionMode(value.mode) && + isQuestionnaireRequest(value.request), + }, + 'turn.finished': { + required: ['status'], + optional: ['text', 'error'], + valid: (value) => + isRunStatus(value.status) && + isOptional(value, 'text', isString) && + isOptional(value, 'error', isResultError), + }, +}; + +function hasAgentEventEnvelope(value: Record, shape: AgentEventShape): boolean { + const sessionKey = shape.sessionIdOptional ? 'optional' : 'required'; + const turnKey = shape.turnId ?? 'required'; + const required = [ + 'type', + ...(sessionKey === 'required' ? ['sessionId'] : []), + ...(turnKey === 'required' ? ['turnId'] : []), + ...shape.required, + ]; + const optional = [ + ...(sessionKey === 'optional' ? ['sessionId'] : []), + ...(turnKey === 'optional' ? ['turnId'] : []), + ...(shape.optional ?? []), + ]; + return ( + isOptional(value, 'sessionId', isBoundedIdentifier) && + isOptional(value, 'turnId', isBoundedIdentifier) && + hasOptionalKeys(value, required, optional) + ); +} + +function agentEventShape(type: string): AgentEventShape | undefined { + return Object.prototype.hasOwnProperty.call(AGENT_EVENT_SHAPES, type) + ? AGENT_EVENT_SHAPES[type as MiniAppAgentRunEvent['type']] + : undefined; +} + +/** + * Exact-key copy of a known event; anything else is `undefined` so the Host never sends it. + * An `undefined` field counts as absent, as it would after JSON encoding. + */ +function copyAgentRunEvent(raw: unknown): MiniAppAgentRunEvent | undefined { + if (!isRecord(raw) || typeof raw.type !== 'string') return undefined; + const value = Object.fromEntries(Object.entries(raw).filter(([, field]) => field !== undefined)); + const shape = agentEventShape(raw.type); + if (!shape || !hasAgentEventEnvelope(value, shape) || !shape.valid(value)) return undefined; + // Every field is now a validated primitive except `error`, whose exact keys were checked, + // and the questionnaire `request`, whose size the response frame bound limits. + return { + ...value, + ...(isRecord(value.error) ? { error: { ...value.error } } : {}), + ...(raw.type === 'turn.started' ? { model: readMiniAppAgentModelSelection(value.model) } : {}), + } as MiniAppAgentRunEvent; +} + +export function decodeAgentWireError(value: unknown): MiniAppAgentError { + if ( + !isRecord(value) || + !hasOptionalKeys(value, ['code', 'message', 'retryable'], ['reason']) || + !AGENT_ERROR_CODES.has(String(value.code)) || + typeof value.message !== 'string' || + typeof value.retryable !== 'boolean' || + !isOptional( + value, + 'reason', + (reason) => value.code === 'BUSY' && isMiniAppAgentBusyReason(reason), + ) + ) { + throw new HostConnectorProtocolError('Mini App Agent error is invalid'); + } + return new MiniAppAgentError( + value.code as MiniAppAgentErrorCode, + value.message, + value.retryable, + value.reason as MiniAppAgentBusyReason | undefined, + ); +} + +interface HostAgentSessionInput { + readonly gateway: MiniAppAgentGateway | undefined; + readonly identity: { + readonly pluginId: string; + readonly processGeneration: string; + readonly agentPolicy?: MiniAppAgentPolicy; + }; + readonly lifecycle: { + gate(): 'open' | 'unavailable' | 'service-restarted'; + isActive(): boolean; + }; + readonly makeInvocationId: () => string; + readonly maxFrameBytes: number; + readonly enqueueResponse: (value: unknown) => Promise; + readonly logger?: { warn(fields: Record, message: string): void }; +} + +type AgentShortRequest = + | { readonly method: 'agent.reply'; readonly params: MiniAppAgentReplyInput } + | { readonly method: 'agent.models' | 'agent.agents' } + | { readonly method: 'agent.create'; readonly params: { readonly agent?: string } }; + +type AgentRequest = AgentTurnRequest | AgentShortRequest; + +/** Malformed params are a protocol error, raised before any response is written. */ +function decodeAgentRequest( + method: AgentRequestMethod, + params: Record | undefined, +): AgentRequest { + if (method === 'agent.reply') return { method, params: decodeAgentReplyParams(params) }; + if (method === 'agent.create') return { method, params: decodeAgentCreateParams(params) }; + if (method === 'agent.models' || method === 'agent.agents') { + if (!params || !hasExactKeys(params, [])) { + throw new HostConnectorProtocolError('Host Connector request is invalid'); + } + return { method }; + } + if (method === 'agent.answer') return { method, ...decodeAgentAnswerParams(params) }; + return { method, ...decodeAgentRunParams(params) }; +} + +/** Host-side `agent.*` handling for one generation-bound connector session. */ +export function createHostAgentSession(input: HostAgentSessionInput) { + const { identity, lifecycle } = input; + let releaseArena: (() => void) | undefined; + let released = false; + const admissionError = (): MiniAppAgentWireError | undefined => { + const gate = lifecycle.gate(); + if (gate === 'service-restarted') return agentWireError('AGENT_UNAVAILABLE', true); + return gate === 'open' && lifecycle.isActive() + ? undefined + : agentWireError('AGENT_UNAVAILABLE'); + }; + const respondError = (id: string, error: MiniAppAgentWireError) => + input.enqueueResponse({ v: 1, id, ok: false, error }); + /** Policy and lifecycle admission; answers the request itself when refused. */ + const admit = async (id: string): Promise => { + const gateway = input.gateway; + const refusal = + !gateway || identity.agentPolicy?.kind !== 'allowlist' + ? agentWireError('NOT_PERMITTED') + : admissionError(); + if (refusal) await respondError(id, refusal); + return refusal ? undefined : gateway; + }; + + return { + /** Registers the arena once; a released session never re-registers. */ + activate(): void { + const policy = identity.agentPolicy; + if (released || releaseArena || !input.gateway || policy?.kind !== 'allowlist') return; + releaseArena = input.gateway.registerArena({ + pluginId: identity.pluginId, + processGeneration: identity.processGeneration, + policy, + }); + }, + release(): void { + released = true; + const release = releaseArena; + releaseArena = undefined; + release?.(); + }, + /** + * Runs one `agent.*` request. `agent.run` / `agent.answer` follow a turn (admitted receipt, + * events, terminal); the others are short and mark dispatch so a quiesce drains them. + */ + async handle( + frame: { + readonly id: string; + readonly method: AgentRequestMethod; + readonly params?: Record; + }, + signal: AbortSignal, + markDispatched: () => void, + ): Promise { + const request = decodeAgentRequest(frame.method, frame.params); + const gateway = await admit(frame.id); + if (!gateway) return; + if (isShortAgentRequest(request)) { + await settleShortHostAgent({ + id: frame.id, + operation: () => startShortAgentOperation(gateway, processCaller(identity), request), + signal, + markDispatched, + respond: input.enqueueResponse, + }); + return; + } + const invocationId = input.makeInvocationId(); + await input.enqueueResponse({ v: 1, id: frame.id, receipt: 'admitted', invocationId }); + const after = admissionError(); + if (after) { + await respondError(frame.id, after); + return; + } + await followHostAgentTurn(input, { id: frame.id, gateway, request, signal }); + }, + }; +} + +function isShortAgentRequest(request: AgentRequest): request is AgentShortRequest { + return request.method !== 'agent.run' && request.method !== 'agent.answer'; +} + +async function startShortAgentOperation( + gateway: MiniAppAgentGateway, + caller: MiniAppAgentCaller, + request: AgentShortRequest, +): Promise { + switch (request.method) { + case 'agent.reply': + return gateway.reply(caller, request.params); + case 'agent.models': + return projectAgentModels(await gateway.models(caller)); + case 'agent.agents': + return projectAgentAgents(await gateway.agents(caller)); + case 'agent.create': + return projectAgentCreate(await gateway.create(caller, request.params)); + } +} + +/** + * Short `agent.*` requests: no receipt or events; the connector request timeout applies. + * `agent.reply` settles with `null`. + */ +async function settleShortHostAgent(short: { + readonly id: string; + readonly operation: () => Promise; + readonly signal: AbortSignal; + readonly markDispatched: () => void; + readonly respond: (value: unknown) => Promise; +}): Promise { + try { + const operation = short.operation(); + short.markDispatched(); + const result = await raceAgentSignal(operation, short.signal); + await short.respond({ v: 1, id: short.id, ok: true, result: result ?? null }); + } catch (error) { + const wireError = + error instanceof MiniAppAgentError + ? projectAgentError(error) + : agentWireError('AGENT_OUTCOME_UNKNOWN'); + await short.respond({ v: 1, id: short.id, ok: false, error: wireError }); + } +} + +async function followHostAgentTurn( + input: HostAgentSessionInput, + turn: { + readonly id: string; + readonly gateway: MiniAppAgentGateway; + readonly request: AgentTurnRequest; + readonly signal: AbortSignal; + }, +): Promise { + const events = createAgentEventQueue({ + maxFrameBytes: input.maxFrameBytes, + enqueueResponse: input.enqueueResponse, + onRetainedDrop: (event, reason) => reportRetainedDrop(input, turn, event, reason), + }); + const terminal = await runHostAgent({ + ...turn, + identity: input.identity, + notify: events.notify, + }); + if (events.dropped() > 0) { + input.logger?.warn( + { + pluginId: input.identity.pluginId, + processGeneration: input.identity.processGeneration, + droppedEvents: events.dropped(), + }, + 'Mini App Agent run dropped event frames', + ); + } + await input.enqueueResponse(terminal); +} + +type RetainedDropReason = 'oversized' | 'serialize_failed'; + +interface AgentEventFrame { + readonly v: 1; + readonly id: string; + readonly event: MiniAppAgentRunEvent; +} + +/** + * A dropped interaction frame leaves the turn waiting on an ask the child never saw, so it is + * reported now rather than after the turn; a dropped approval is denied so the turn can go on. + */ +function reportRetainedDrop( + input: HostAgentSessionInput, + turn: { readonly id: string; readonly gateway: MiniAppAgentGateway }, + event: MiniAppAgentRunEvent, + reason: RetainedDropReason, +): void { + if (event.type !== 'permission.requested' && event.type !== 'question.requested') return; + const fields = { + pluginId: input.identity.pluginId, + processGeneration: input.identity.processGeneration, + sessionId: event.sessionId, + requestId: event.requestId, + type: event.type, + reason, + }; + input.logger?.warn(fields, 'Mini App Agent run dropped an interaction event frame'); + if (event.type === 'permission.requested') + denyDroppedPermission(input, turn.gateway, event, fields); +} + +/** + * Detached on purpose: the terminal frame must not wait on a reply that takes no signal. + * Runs after the Gateway's listener callback returns, since that callback is delivering + * the very ask being denied. + */ +function denyDroppedPermission( + input: HostAgentSessionInput, + gateway: MiniAppAgentGateway, + event: Extract, + fields: Record, +): void { + detach( + (async () => { + // Leave the Gateway's listener callback before replying to the ask it is delivering. + await Promise.resolve(); + try { + await gateway.reply(processCaller(input.identity), { + sessionId: event.sessionId, + requestId: event.requestId, + decision: 'deny', + }); + } catch (error) { + input.logger?.warn( + { ...fields, errorName: error instanceof Error ? error.name : typeof error }, + 'Mini App Agent could not deny a dropped permission request', + ); + } + })(), + ); +} + +/** + * Per-run event notifier. Oversized, non-JSON or over-budget event frames are dropped and + * counted; the terminal frame still settles the run. `retained` frames (an approval or a + * questionnaire the child must answer) skip the budget, since dropping one stalls the turn; + * if one cannot be sent at all, `onRetainedDrop` hears about it immediately. + */ +function createAgentEventQueue(input: { + readonly maxFrameBytes: number; + readonly enqueueResponse: (value: unknown) => Promise; + readonly onRetainedDrop: (event: MiniAppAgentRunEvent, reason: RetainedDropReason) => void; +}): { + notify(frame: AgentEventFrame, retained: boolean): Promise | undefined; + dropped(): number; +} { + let queuedEvents = 0; + let droppedEvents = 0; + const drop = () => { + droppedEvents += 1; + return undefined; + }; + return { + notify(frame, retained) { + if (!retained && queuedEvents >= MAX_QUEUED_AGENT_EVENTS) return drop(); + const unsendable = unsendableFrameReason(frame, input.maxFrameBytes); + if (unsendable) { + if (retained) input.onRetainedDrop(frame.event, unsendable); + return drop(); + } + queuedEvents += 1; + return settleQueuedEvent(input.enqueueResponse(frame), () => { + queuedEvents -= 1; + }); + }, + dropped: () => droppedEvents, + }; +} + +function unsendableFrameReason( + frame: AgentEventFrame, + maxFrameBytes: number, +): RetainedDropReason | undefined { + let json: string | undefined; + try { + json = JSON.stringify(frame); + } catch { + return 'serialize_failed'; + } + if (json === undefined) return 'serialize_failed'; + return Buffer.byteLength(json) > maxFrameBytes ? 'oversized' : undefined; +} + +/** `observe` is set when the child passed `onEvent`; without it the turn runs unobserved. */ +type AgentTurnRequest = + | { + readonly method: 'agent.run'; + readonly params: MiniAppAgentRunInput; + readonly observe: boolean; + } + | { + readonly method: 'agent.answer'; + readonly params: MiniAppAgentAnswerInput; + readonly observe: boolean; + }; + +function processCaller(identity: { + readonly pluginId: string; + readonly processGeneration: string; +}): MiniAppAgentCaller { + return { + pluginId: identity.pluginId, + processGeneration: identity.processGeneration, + source: 'process', + }; +} + +async function runHostAgent(run: { + readonly id: string; + readonly gateway: MiniAppAgentGateway; + readonly identity: { readonly pluginId: string; readonly processGeneration: string }; + readonly request: AgentTurnRequest; + readonly signal: AbortSignal; + readonly notify: (frame: AgentEventFrame, retained: boolean) => Promise | undefined; +}): Promise { + let terminal = false; + let notifications: Promise | undefined; + // The Gateway denies approvals of a run nobody listens to, so only an observing child gets one. + const onEvent = (event: MiniAppAgentRunEvent) => { + if (terminal) return; + const projected = copyAgentRunEvent(event); + if (!projected) return; + notifications = + run.notify( + { v: 1, id: run.id, event: projected }, + INTERACTION_EVENT_TYPES.has(projected.type), + ) ?? notifications; + }; + const options: MiniAppAgentRunOptions = { + signal: run.signal, + ...(run.request.observe ? { onEvent } : {}), + }; + const caller = processCaller(run.identity); + try { + const result = + run.request.method === 'agent.run' + ? await run.gateway.run(caller, run.request.params, options) + : await run.gateway.answer(caller, run.request.params, options); + return { v: 1, id: run.id, ok: true, result: projectAgentResult(result) }; + } catch (error) { + return { v: 1, id: run.id, ok: false, error: hostAgentWireError(error, run.signal) }; + } finally { + terminal = true; + // The shared write queue already orders notifications before the terminal frame. + await notifications; + } +} + +async function settleQueuedEvent(write: Promise, settled: () => void): Promise { + try { + await ignoreRejection(write); + } finally { + settled(); + } +} + +async function ignoreRejection(operation: Promise): Promise { + try { + await operation; + } catch { + // Transport loss is reported by the owning session's channel binding. + } +} + +function hostAgentWireError(error: unknown, signal: AbortSignal): MiniAppAgentWireError { + if (error instanceof MiniAppAgentError) return projectAgentError(error); + return signal.aborted + ? agentWireError('REQUEST_CANCELLED') + : agentWireError('AGENT_OUTCOME_UNKNOWN'); +} + +/** + * `gateway.reply` takes no signal; a timeout or teardown stops waiting for it, but the + * decision may still apply, so the caller reports an unknown outcome. + */ +async function raceAgentSignal(operation: Promise, signal: AbortSignal): Promise { + if (signal.aborted) throw signal.reason; + let onAbort: (() => void) | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + }), + ]); + } finally { + if (onAbort) signal.removeEventListener('abort', onAbort); + } +} + +function agentWireError( + code: MiniAppAgentErrorCode, + retryable = false, + reason?: MiniAppAgentBusyReason, +): MiniAppAgentWireError { + return { code, message: AGENT_ERROR_MESSAGES[code], retryable, ...(reason ? { reason } : {}) }; +} + +/** Host-side messages may carry internal detail; the child only gets the fixed text. */ +function projectAgentError(error: MiniAppAgentError): MiniAppAgentWireError { + const reason = + error.code === 'BUSY' && isMiniAppAgentBusyReason(error.reason) ? error.reason : undefined; + return agentWireError(error.code, error.retryable, reason); +} + +function projectAgentResult(result: MiniAppAgentRunResult): MiniAppAgentRunResult { + const model = readMiniAppAgentModelSelection(result.model); + return { + sessionId: result.sessionId, + ...(result.turnId ? { turnId: result.turnId } : {}), + ...(model ? { model } : {}), + status: result.status, + ...(result.text === undefined ? {} : { text: result.text }), + ...(result.error ? { error: { code: result.error.code, message: result.error.message } } : {}), + ...(result.pendingQuestion + ? { + pendingQuestion: { + requestId: result.pendingQuestion.requestId, + mode: result.pendingQuestion.mode, + request: result.pendingQuestion.request, + }, + } + : {}), + }; +} + +/** Whitelist copies: a field the runtime adds later must not break the child's strict decoder. */ +function projectAgentModels(result: MiniAppAgentModels): MiniAppAgentModels { + const defaultModel = readMiniAppAgentModelSelection(result.default); + const host = readMiniAppAgentModelSelection(result.host); + if (!defaultModel) throw new MiniAppAgentError('AGENT_UNAVAILABLE', 'Default model is invalid'); + return { + models: result.models.map((model) => ({ + providerId: model.providerId, + modelId: model.modelId, + displayName: model.displayName, + ...(model.variants ? { variants: [...model.variants] } : {}), + ...(model.thinking + ? { + thinking: { + efforts: [...model.thinking.efforts], + ...(model.thinking.default === undefined ? {} : { default: model.thinking.default }), + }, + } + : {}), + })), + default: defaultModel, + ...(host ? { host } : {}), + }; +} + +function projectAgentAgents(result: MiniAppAgentAgents): MiniAppAgentAgents { + return { + agents: result.agents.map((agent) => ({ + name: agent.name, + displayName: agent.displayName, + primary: agent.primary, + })), + }; +} + +function projectAgentCreate(result: { readonly sessionId: string }): { + readonly sessionId: string; +} { + return { sessionId: result.sessionId }; +} + +function isRunStatus(value: unknown): value is MiniAppAgentRunResult['status'] { + return value === 'finished' || value === 'error' || value === 'aborted'; +} + +function isPendingQuestion( + value: unknown, +): value is NonNullable { + return ( + isRecord(value) && + hasExactKeys(value, ['requestId', 'mode', 'request']) && + isBoundedIdentifier(value.requestId) && + isMiniAppAgentQuestionMode(value.mode) && + isQuestionnaireRequest(value.request) + ); +} + +/** Envelope check only; the step list is passed through as the Host chat renders it. */ +function isQuestionnaireRequest(value: unknown): value is AskQuestionnaireRequest { + return ( + isRecord(value) && + value.schemaVersion === 2 && + isBoundedIdentifier(value.id) && + Array.isArray(value.steps) + ); +} + +function isOptional( + value: Record, + key: string, + check: (field: unknown) => boolean, +): boolean { + return !(key in value) || check(value[key]); +} + +function isResultError( + value: unknown, +): value is { readonly code: string; readonly message: string } { + return ( + isRecord(value) && + hasExactKeys(value, ['code', 'message']) && + typeof value.code === 'string' && + typeof value.message === 'string' + ); +} + +function hasOptionalKeys( + value: Record, + required: readonly string[], + optional: readonly string[], +): boolean { + return hasExactKeys(value, [...required, ...optional.filter((key) => key in value)]); +} + +/** Transient strong refs so `detach` visibly consumes its argument. */ +const detached = new Set>(); + +/** + * Explicitly consume a fire-and-forget promise whose errors it already owns. + * + * Local copy: this build has no `service/channel-system/settle.ts`, and the same helper is already + * hoisted this way in `service/miniapp/agent/gateway.ts`. + */ +function detach(promise: Promise): void { + detached.add(promise); + queueMicrotask(() => detached.delete(promise)); +} diff --git a/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-errors.ts b/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-errors.ts index c45f7e69a..e9289fe7d 100644 --- a/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-errors.ts +++ b/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-errors.ts @@ -1,5 +1,9 @@ import { ConnectorGatewayError } from '../../../host-connector-system/index.js'; +export class HostConnectorProtocolError extends Error { + override readonly name = 'HostConnectorProtocolError'; +} + export type HostConnectorDisposition = | 'not_dispatched' | 'provider_reported' diff --git a/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-ndjson.ts b/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-ndjson.ts deleted file mode 100644 index a74a4859e..000000000 --- a/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-ndjson.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { MiniAppHostConnectorWritable } from '../../contracts.js'; - -export class HostConnectorProtocolError extends Error { - override readonly name = 'HostConnectorProtocolError'; -} - -/** Strict single-frame NDJSON codec shared by the child and Host halves of the bridge. */ -export class StrictNdjsonDecoder { - private buffer: Buffer; - private length = 0; - private ended = false; - - constructor( - private readonly maxBytes: number, - private readonly onFrame: (value: unknown) => void, - ) { - this.buffer = Buffer.allocUnsafe(Math.min(1024, maxBytes)); - } - - push(chunk: Buffer | string): void { - if (this.ended) throw new HostConnectorProtocolError('Host Connector stream ended'); - const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; - let offset = 0; - for (;;) { - const newline = bytes.indexOf(0x0a, offset); - if (newline < 0) { - this.append(bytes.subarray(offset)); - return; - } - this.append(bytes.subarray(offset, newline)); - this.takeFrame(); - offset = newline + 1; - if (offset === bytes.byteLength) return; - } - } - - private append(bytes: Buffer): void { - if (bytes.byteLength === 0) return; - const required = this.length + bytes.byteLength; - if (required > this.maxBytes) { - throw new HostConnectorProtocolError('Host Connector frame exceeds its byte limit'); - } - this.ensureCapacity(required); - bytes.copy(this.buffer, this.length); - this.length = required; - } - - private ensureCapacity(required: number): void { - if (required <= this.buffer.byteLength) return; - const doubled = Math.max(1024, this.buffer.byteLength * 2); - const next = Buffer.allocUnsafe(Math.min(this.maxBytes, Math.max(required, doubled))); - this.buffer.copy(next, 0, 0, this.length); - this.buffer = next; - } - - private takeFrame(): void { - if (this.length === 0 || this.buffer.subarray(0, this.length).includes(0x0d)) { - throw new HostConnectorProtocolError('Host Connector frame is invalid'); - } - let value: unknown; - try { - value = JSON.parse( - new TextDecoder('utf-8', { fatal: true }).decode(this.buffer.subarray(0, this.length)), - ); - } catch { - throw new HostConnectorProtocolError('Host Connector frame is not valid JSON'); - } - this.length = 0; - this.onFrame(value); - } - - end(): void { - this.ended = true; - if (this.length !== 0) { - throw new HostConnectorProtocolError('Host Connector stream ended before LF'); - } - } -} - -export function encodeFrame(value: unknown, maxBytes: number): Buffer { - let json: string | undefined; - try { - json = JSON.stringify(value); - } catch { - throw new HostConnectorProtocolError('Host Connector payload is not valid JSON'); - } - if (json === undefined) { - throw new HostConnectorProtocolError('Host Connector payload is not valid JSON'); - } - const frame = Buffer.from(`${json}\n`); - if (frame.byteLength - 1 > maxBytes) { - throw new HostConnectorProtocolError('Host Connector frame exceeds its byte limit'); - } - return frame; -} - -export async function writeBytes( - output: MiniAppHostConnectorWritable, - bytes: Buffer, - onWrite?: () => void, -): Promise { - if (output.destroyed) throw new HostConnectorProtocolError('Host Connector output closed'); - const accepted = output.write(bytes); - onWrite?.(); - if (accepted) return; - await new Promise((resolve, reject) => { - const finish = (error?: Error) => { - output.removeListener('drain', onDrain); - output.removeListener('error', onError); - output.removeListener('close', onClose); - if (error) reject(error); - else resolve(); - }; - const onDrain = () => finish(); - const onError = () => finish(new HostConnectorProtocolError('Host Connector write failed')); - const onClose = () => finish(new HostConnectorProtocolError('Host Connector output closed')); - output.once('drain', onDrain); - output.once('error', onError); - output.once('close', onClose); - }); -} diff --git a/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-quiesce.ts b/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-quiesce.ts index 7bc9fb763..58e1fc69b 100644 --- a/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-quiesce.ts +++ b/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-quiesce.ts @@ -1,6 +1,6 @@ import type { HostProcessConnectorGateway } from '../../../host-connector-system/index.js'; import type { MiniAppHostConnectorQuiesceAttempt } from '../../contracts.js'; -import { HostConnectorProtocolError } from './host-connector-ndjson.js'; +import { HostConnectorProtocolError } from './host-connector-errors.js'; type HostConnectorSessionState = | 'candidate' diff --git a/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-wire.ts b/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-wire.ts new file mode 100644 index 000000000..0938c1917 --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector-wire.ts @@ -0,0 +1,96 @@ +/** Frame limits, identifiers and JSON shapes shared by the child client and the Host session. */ +export const REQUEST_MAX_BYTES = 256 * 1024; +export const RESPONSE_MAX_BYTES = 8 * 1024 * 1024; +const MAX_IDENTIFIER_BYTES = 256; +const MAX_PENDING = 16; +/** Permission replies must still enter when Agent turns fill the ordinary request budget. */ +const MAX_PENDING_AGENT_REPLIES = 16; + +type JsonPrimitive = null | boolean | number | string; +export type HostConnectorJson = + | JsonPrimitive + | { readonly [key: string]: HostConnectorJson } + | readonly HostConnectorJson[]; +export type HostConnectorJsonObject = { readonly [key: string]: HostConnectorJson }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: true; +}; + +export type ChildRequestMethod = + | 'connector.list' + | 'connector.call' + | 'agent.run' + | 'agent.reply' + | 'agent.answer' + | 'agent.models' + | 'agent.agents' + | 'agent.create'; + +export type AgentRequestMethod = Extract; + +/** Both endpoints retain one pending map, with separate bounded admission for permission replies. */ +export function hasHostConnectorRequestCapacity( + method: ChildRequestMethod, + pending: Iterable<{ readonly method: ChildRequestMethod }>, +): boolean { + const reply = method === 'agent.reply'; + const limit = reply ? MAX_PENDING_AGENT_REPLIES : MAX_PENDING; + let count = 0; + for (const request of pending) { + if ((request.method === 'agent.reply') === reply && ++count >= limit) return false; + } + return true; +} + +export function isAgentMethod(method: string): method is AgentRequestMethod { + return isAgentShortMethod(method) || isAgentTurnMethod(method); +} + +/** Request/response `agent.*` calls: no admission receipt, events or run slot. */ +export function isAgentShortMethod( + method: string, +): method is Exclude { + return ( + method === 'agent.reply' || + method === 'agent.models' || + method === 'agent.agents' || + method === 'agent.create' + ); +} + +/** + * Turn-following requests: no request timeout, streamed events, aborted on cutover. The short + * `agent.*` requests are drained and timed like a connector call. + */ +export function isAgentTurnMethod(method: string): method is 'agent.run' | 'agent.answer' { + return method === 'agent.run' || method === 'agent.answer'; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isBoundedIdentifier(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + Buffer.byteLength(value) <= MAX_IDENTIFIER_BYTES + ); +} + +export function isRequestId(value: unknown): value is string { + if (typeof value !== 'string' || !/^[1-9]\d{0,19}$/u.test(value)) return false; + try { + return BigInt(value) <= 18_446_744_073_709_551_615n; + } catch { + return false; + } +} + +export function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} diff --git a/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector.ts b/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector.ts index d9d17c744..25b7c6009 100644 --- a/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector.ts +++ b/packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { format } from 'node:util'; import { ConnectorGatewayError, @@ -6,17 +7,32 @@ import { type ConnectorToolHandle, } from '../../../host-connector-system/index.js'; import type { - MiniAppHostConnectorReadable, + MiniAppAgentGateway, + MiniAppAgentRunEvent, +} from '../../agent/contracts.js'; +import type { MiniAppHostConnectorSession, MiniAppHostConnectorSessionFactory, - MiniAppHostConnectorWritable, + MiniAppIpcMessage, + MiniAppIpcTransport, } from '../../contracts.js'; import { - encodeFrame, - HostConnectorProtocolError, - StrictNdjsonDecoder, - writeBytes, -} from './host-connector-ndjson.js'; + bindMiniAppIpcChannel, + encodeMiniAppIpcMessage, + sendMiniAppIpcMessage, +} from '../node-runtime/ipc-transport.js'; +import { + agentClientError, + createHostAgentSession, + decodeAgentResult, + decodeAgentRunEvent, + decodeAgentWireError, + encodeAgentAnswerParams, + encodeAgentCreateParams, + encodeAgentReplyParams, + encodeAgentRunParams, + type MiniAppAgentClient, +} from './host-connector-agent.js'; import { createHostConnectorGatewayRelease, HostConnectorSessionLifecycle, @@ -27,6 +43,7 @@ import { type HostConnectorDiagnosticIssue, type HostConnectorDisposition, HostConnectorError, + HostConnectorProtocolError, HostConnectorRequestTimeoutError, type HostConnectorWireError as WireError, isSafeHostConnectorDiagnosticConstraint, @@ -38,23 +55,27 @@ import { unavailableWireError, } from './host-connector-errors.js'; -const REQUEST_MAX_BYTES = 256 * 1024; -const RESPONSE_MAX_BYTES = 8 * 1024 * 1024; -const MAX_IDENTIFIER_BYTES = 256; -const MAX_PENDING = 16; - -type JsonPrimitive = null | boolean | number | string; -type HostConnectorJson = - | JsonPrimitive - | { readonly [key: string]: HostConnectorJson } - | readonly HostConnectorJson[]; -type HostConnectorJsonObject = { readonly [key: string]: HostConnectorJson }; type HostConnectorListedTool = Awaited>['tools'][number]; -declare const HOST_CONNECTOR_TOOL_REF: unique symbol; -type HostConnectorToolRef = string & { - readonly [HOST_CONNECTOR_TOOL_REF]: true; -}; +import { + type ChildRequestMethod, + hasExactKeys, + hasHostConnectorRequestCapacity, + type HostConnectorJson, + type HostConnectorJsonObject, + type HostConnectorToolRef, + isAgentMethod, + isAgentShortMethod, + isAgentTurnMethod, + isBoundedIdentifier, + isRecord, + isRequestId, + REQUEST_MAX_BYTES, + RESPONSE_MAX_BYTES, +} from './host-connector-wire.js'; + +export type { MiniAppAgentClient }; +export { hasHostConnectorRequestCapacity, REQUEST_MAX_BYTES, RESPONSE_MAX_BYTES }; export interface HostConnectorClient { list(options?: { readonly signal?: AbortSignal }): Promise<{ @@ -77,14 +98,15 @@ export interface HostConnectorClient { interface RequestFrame { readonly id: string; - readonly method: 'connector.list' | 'connector.call' | 'connector.cancel'; + readonly method: ChildRequestMethod | 'connector.cancel' | 'agent.cancel'; readonly params?: Record; } interface PendingChildRequest { - readonly method: 'connector.list' | 'connector.call'; + readonly method: ChildRequestMethod; readonly resolve: (value: unknown) => void; readonly reject: (error: Error) => void; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; written: boolean; invocationId?: string; abortCleanup?: () => void; @@ -92,13 +114,13 @@ interface PendingChildRequest { export interface MiniAppHostConnectorRuntimeClient { readonly client: HostConnectorClient; + readonly agent: MiniAppAgentClient; close(): void; } /** Child-side implementation injected into `start(context)`. */ export function createMiniAppHostConnectorRuntimeClient(options: { - readonly input: MiniAppHostConnectorReadable; - readonly output: MiniAppHostConnectorWritable; + readonly transport: MiniAppIpcTransport; readonly onFatal?: (error: Error) => void; }): MiniAppHostConnectorRuntimeClient { const pending = new Map(); @@ -106,39 +128,12 @@ export function createMiniAppHostConnectorRuntimeClient(options: { let closed = false; let writeTail = Promise.resolve(); - const decoder = new StrictNdjsonDecoder(RESPONSE_MAX_BYTES, (value) => { - try { - handleResponse(value); - } catch (error) { - fatal(error); - } + const detach = bindMiniAppIpcChannel(options.transport, { + type: 'miniapp:connector', + maxBytes: RESPONSE_MAX_BYTES, + onMessage: handleResponse, + onFatal: fatal, }); - const onData = (chunk: unknown) => { - try { - decoder.push(chunk as Buffer | string); - } catch (error) { - fatal(error); - } - }; - const onEnd = () => { - try { - decoder.end(); - } catch (error) { - fatal(error); - return; - } - fatal(new HostConnectorProtocolError('Host Connector response ended')); - }; - const onError = () => fatal(new HostConnectorProtocolError('Host Connector response failed')); - options.input.on('data', onData); - options.input.on('end', onEnd); - options.input.on('error', onError); - - function detach(): void { - options.input.removeListener('data', onData); - options.input.removeListener('end', onEnd); - options.input.removeListener('error', onError); - } function fatal(error: unknown): void { if (closed) return; @@ -164,20 +159,12 @@ export function createMiniAppHostConnectorRuntimeClient(options: { recordAdmissionReceipt(value, entry); return; } - const success = value.ok === true; - const keys = success ? ['v', 'id', 'ok', 'result'] : ['v', 'id', 'ok', 'error']; - if (!hasExactKeys(value, keys)) { - throw new HostConnectorProtocolError('Host Connector response shape is invalid'); - } - if (success) { - const result = decodeChildResult(entry.method, value.result); - assertInvocationIdentity(entry, result); - settle(value.id, entry, undefined, result); + if ('event' in value) { + deliverAgentEvent(value, entry); return; } - const decoded = decodeWireError(value.error); - assertInvocationIdentity(entry, decoded); - settle(value.id, entry, decoded); + const outcome = decodeChildOutcome(entry, value); + settle(value.id, entry, outcome.error, outcome.result); } function settle(id: string, entry: PendingChildRequest, error?: Error, result?: unknown): void { @@ -188,37 +175,50 @@ export function createMiniAppHostConnectorRuntimeClient(options: { } function send( - method: PendingChildRequest['method'], + method: ChildRequestMethod, params: Record, signal?: AbortSignal, + onEvent?: (event: MiniAppAgentRunEvent) => void, ): Promise { - if (closed) return Promise.reject(unavailableError()); - if (signal?.aborted) return Promise.reject(cancelledError()); - if (pending.size >= MAX_PENDING) return Promise.reject(unavailableError(true)); + if (closed) return Promise.reject(childUnavailableError(method)); + if (signal?.aborted) return Promise.reject(childCancelledError(method)); + if (!hasHostConnectorRequestCapacity(method, pending.values())) { + return Promise.reject(childUnavailableError(method, true)); + } const id = allocateRequestId(); - let frame: Buffer; + let frame: MiniAppIpcMessage; try { - frame = encodeFrame({ v: 1, id, method, params }, REQUEST_MAX_BYTES); - } catch { - return Promise.reject( - method === 'connector.call' - ? invalidArgumentsError() - : new HostConnectorProtocolError('Host Connector list request is invalid'), + frame = encodeMiniAppIpcMessage( + 'miniapp:connector', + { v: 1, id, method, params }, + REQUEST_MAX_BYTES, ); + } catch { + return Promise.reject(childEncodeError(method)); } return new Promise((resolve, reject) => { - const entry: PendingChildRequest = { method, resolve, reject, written: false }; + const entry: PendingChildRequest = { + method, + resolve, + reject, + written: false, + ...(onEvent ? { onEvent } : {}), + }; pending.set(id, entry); const onAbort = () => { if (!entry.written) { - settle(id, entry, cancelledError()); + settle(id, entry, childCancelledError(method)); return; } enqueueWrite(async () => { if (pending.has(id)) { - await writeBytes( - options.output, - encodeFrame({ v: 1, id, method: 'connector.cancel' }, REQUEST_MAX_BYTES), + await sendMiniAppIpcMessage( + options.transport, + encodeMiniAppIpcMessage( + 'miniapp:connector', + { v: 1, id, method: isAgentMethod(method) ? 'agent.cancel' : 'connector.cancel' }, + REQUEST_MAX_BYTES, + ), ); } }); @@ -229,7 +229,7 @@ export function createMiniAppHostConnectorRuntimeClient(options: { } enqueueWrite(async () => { if (!pending.has(id)) return; - await writeBytes(options.output, frame, () => { + await sendMiniAppIpcMessage(options.transport, frame, () => { entry.written = true; }); if (signal?.aborted) onAbort(); @@ -272,8 +272,48 @@ export function createMiniAppHostConnectorRuntimeClient(options: { }, }); + type AgentTurnOptions = Parameters[1]; + type AgentTurnResult = Awaited>; + async function sendAgentTurn( + method: 'agent.run' | 'agent.answer', + params: Record, + turnOptions: AgentTurnOptions, + ): Promise { + const onEvent = typeof turnOptions?.onEvent === 'function' ? turnOptions.onEvent : undefined; + // Without `observe` the Host runs the turn with no listener, so its approvals are denied. + const wireParams = onEvent ? { ...params, observe: true } : params; + return (await send(method, wireParams, turnOptions?.signal, onEvent)) as AgentTurnResult; + } + + const agent: MiniAppAgentClient = Object.freeze({ + async run(input: Parameters[0], runOptions?: AgentTurnOptions) { + return sendAgentTurn('agent.run', encodeAgentRunParams(input), runOptions); + }, + async reply(input: Parameters[0]) { + await send('agent.reply', encodeAgentReplyParams(input)); + }, + async answer( + input: Parameters[0], + answerOptions?: AgentTurnOptions, + ) { + return sendAgentTurn('agent.answer', encodeAgentAnswerParams(input), answerOptions); + }, + async models() { + return (await send('agent.models', {})) as Awaited>; + }, + async agents() { + return (await send('agent.agents', {})) as Awaited>; + }, + async create(input?: Parameters[0]) { + return (await send('agent.create', encodeAgentCreateParams(input))) as Awaited< + ReturnType + >; + }, + }); + return { client, + agent, close() { fatal(new HostConnectorProtocolError('Host Connector client closed')); }, @@ -294,8 +334,10 @@ async function runChildWrite( } interface ActiveHostRequest { + readonly method: ChildRequestMethod; readonly controller: AbortController; readonly completion: Promise; + readonly agent: boolean; execution?: Promise; dispatched: boolean; } @@ -304,6 +346,31 @@ interface RegisteredActiveHostRequest extends ActiveHostRequest { readonly complete: () => void; } +function requireHostRequestAdmission( + id: string, + method: ChildRequestMethod, + highestRequestId: bigint, + requests: ReadonlyMap, +): bigint { + const numericId = BigInt(id); + if ( + numericId <= highestRequestId || + !hasHostConnectorRequestCapacity(method, requests.values()) + ) { + throw new HostConnectorProtocolError('Host Connector request ordering is invalid'); + } + return numericId; +} + +/** Agent turns never hold a cutover open; they settle through their own terminal frame. */ +function abortAgentRequests(requests: ReadonlyMap): void { + for (const request of requests.values()) { + if (request.agent) { + request.controller.abort(new HostConnectorCancelledError('Mini App Agent run cancelled')); + } + } +} + function markHostRequestDispatched(requests: Map, id: string): void { const request = requests.get(id); if (request) request.dispatched = true; @@ -327,14 +394,17 @@ function registerActiveHostRequest( requests: Map, id: string, controller: AbortController, + method: ChildRequestMethod, ): RegisteredActiveHostRequest { let resolveCompletion!: () => void; const completion = new Promise((resolve) => { resolveCompletion = resolve; }); const request: RegisteredActiveHostRequest = { + method, controller, completion, + agent: isAgentTurnMethod(method), dispatched: false, complete: () => { requests.delete(id); @@ -347,11 +417,13 @@ function registerActiveHostRequest( interface HostSessionFactoryOptions { readonly gateway: HostProcessConnectorGateway; + readonly agentGateway?: MiniAppAgentGateway; readonly makeToolRef?: () => string; readonly makeInvocationId?: () => string; readonly graceTimeoutMs?: number; readonly settleTimeoutMs?: number; readonly requestTimeoutMs?: number; + readonly logger?: { warn(fields: Record, message: string): void }; } /** Host-side generation-bound session backed only by the ConnectorGateway seam. */ @@ -384,25 +456,36 @@ function createHostConnectorSession( let writeTail = Promise.resolve(); let retirePromise: Promise | undefined; const releaseGateway = createHostConnectorGatewayRelease(options.gateway, input); + const agent = createHostAgentSession({ + gateway: options.agentGateway, + identity: input, + lifecycle, + makeInvocationId: () => requireInvocationId(makeInvocationId()), + maxFrameBytes: RESPONSE_MAX_BYTES, + enqueueResponse: (value: unknown) => enqueueResponse(value), + ...(options.logger ? { logger: options.logger } : {}), + }); + const releaseBindings = () => { + releaseGateway(); + agent.release(); + }; const makeToolRef = options.makeToolRef ?? randomUUID; const makeInvocationId = options.makeInvocationId ?? randomUUID; const timeouts = hostSessionTimeouts(options); - const decoder = new StrictNdjsonDecoder(REQUEST_MAX_BYTES, (value) => { - try { + const detach = bindMiniAppIpcChannel(input.transport, { + type: 'miniapp:connector', + maxBytes: REQUEST_MAX_BYTES, + onMessage(value) { + if (!handshakeStarted) { + throw new HostConnectorProtocolError('Host Connector request preceded handshake'); + } handleFrame(value); - } catch (error) { - fail(error); - } - }); - const detach = bindHostRequestDecoder({ - request: input.request, - decoder, - handshakeStarted: () => handshakeStarted, - fail, + }, + onFatal: fail, }); function handleFrame(value: unknown): void { const frame = decodeRequest(value); - if (frame.method === 'connector.cancel') { + if (frame.method === 'connector.cancel' || frame.method === 'agent.cancel') { const request = requests.get(frame.id); if (request) { request.controller.abort(new HostConnectorCancelledError('Request cancelled')); @@ -411,17 +494,18 @@ function createHostConnectorSession( } return; } - const numericId = BigInt(frame.id); - if (numericId <= highestRequestId || requests.size >= MAX_PENDING) { - throw new HostConnectorProtocolError('Host Connector request ordering is invalid'); - } - highestRequestId = numericId; + highestRequestId = requireHostRequestAdmission( + frame.id, + frame.method, + highestRequestId, + requests, + ); const controller = new AbortController(); - const request = registerActiveHostRequest(requests, frame.id, controller); + const request = registerActiveHostRequest(requests, frame.id, controller, frame.method); request.execution = runTimedHostRequest({ frame, controller, - timeoutMs: timeouts.requestMs, + timeoutMs: isAgentTurnMethod(frame.method) ? undefined : timeouts.requestMs, dispatch, fail, complete: request.complete, @@ -429,6 +513,10 @@ function createHostConnectorSession( } async function dispatch(frame: RequestFrame, signal: AbortSignal): Promise { + if (isAgentMethod(frame.method)) { + const markDispatched = () => markHostRequestDispatched(requests, frame.id); + return agent.handle({ ...frame, method: frame.method }, signal, markDispatched); + } const gate = lifecycle.gate(); if (gate !== 'open') { await respondError( @@ -501,8 +589,8 @@ function createHostConnectorSession( function enqueueResponse(value: unknown): Promise { const operation = writeHostResponse( writeTail, - () => publishResponses && !lifecycle.isRetired() && !input.response.destroyed, - input.response, + () => publishResponses && !lifecycle.isRetired() && input.transport.connected !== false, + input.transport, value, ); writeTail = ignoreFailure(operation); @@ -511,6 +599,7 @@ function createHostConnectorSession( async function retire(): Promise { if (!lifecycle.beginRetirement()) return; + abortAgentRequests(requests); const pending = requestCompletions(requests); if (!(await settleWithin(Promise.allSettled(pending), timeouts.graceMs))) { publishResponses = false; @@ -524,7 +613,7 @@ function createHostConnectorSession( refs.clear(); refsByHandle.clear(); detach(); - releaseGateway(); + releaseBindings(); } function fail(error: unknown): void { @@ -536,7 +625,7 @@ function createHostConnectorSession( refsByHandle.clear(); for (const request of requests.values()) request.controller.abort(); detach(); - releaseGateway(); + releaseBindings(); retirePromise ??= retire(); input.onFatal( error instanceof Error ? error : new HostConnectorProtocolError('Host Connector failed'), @@ -547,16 +636,22 @@ function createHostConnectorSession( startHandshake() { if (!lifecycle.isRetired()) handshakeStarted = true; }, - activate: () => lifecycle.activate(), + activate() { + lifecycle.activate(); + agent.activate(); + }, beginQuiesce(quiesceInput) { return lifecycle.beginQuiesce({ pending: dispatchedRequestCompletions(requests), deadlineMs: quiesceInput.deadlineMs, signal: quiesceInput.signal, + // Agent turns are aborted only once the switch is final: a `busy` or resumed quiesce + // leaves the old generation running, and its in-flight turns must survive that. commit: () => { + abortAgentRequests(requests); refs.clear(); refsByHandle.clear(); - releaseGateway(); + releaseBindings(); }, }); }, @@ -570,7 +665,7 @@ function createHostConnectorSession( refs.clear(); refsByHandle.clear(); detach(); - releaseGateway(); + releaseBindings(); retirePromise ??= retire(); return retirePromise; }, @@ -647,52 +742,27 @@ function hostSessionTimeouts(options: HostSessionFactoryOptions) { }; } -function bindHostRequestDecoder(input: { - readonly request: MiniAppHostConnectorReadable; - readonly decoder: StrictNdjsonDecoder; - readonly handshakeStarted: () => boolean; - readonly fail: (error: unknown) => void; -}): () => void { - const onData = (chunk: unknown) => { - if (!input.handshakeStarted()) { - input.fail(new HostConnectorProtocolError('Host Connector output preceded handshake')); - return; - } - try { - input.decoder.push(chunk as Buffer | string); - } catch (error) { - input.fail(error); - } - }; - const onEnd = () => { - try { - input.decoder.end(); - } catch (error) { - input.fail(error); - return; - } - input.fail(new HostConnectorProtocolError('Host Connector request ended')); - }; - const onError = () => input.fail(new HostConnectorProtocolError('Host Connector request failed')); - return bindRequestStream(input.request, { onData, onEnd, onError }); -} async function runTimedHostRequest(input: { readonly frame: RequestFrame; readonly controller: AbortController; - readonly timeoutMs: number; + /** Absent for `agent.run` / `agent.answer`: the Agent turn, not the IPC session, owns completion. */ + readonly timeoutMs: number | undefined; readonly dispatch: (frame: RequestFrame, signal: AbortSignal) => Promise; readonly fail: (error: unknown) => void; readonly complete: () => void; }): Promise { - const timer = setTimeout( - () => - input.controller.abort( - new HostConnectorRequestTimeoutError('Host Connector request timed out'), - ), - input.timeoutMs, - ); - timer.unref(); + const timer = + input.timeoutMs === undefined + ? undefined + : setTimeout( + () => + input.controller.abort( + new HostConnectorRequestTimeoutError('Host Connector request timed out'), + ), + input.timeoutMs, + ); + timer?.unref(); try { await input.dispatch(input.frame, input.controller.signal); } catch (error) { @@ -703,23 +773,6 @@ async function runTimedHostRequest(input: { } } -function bindRequestStream( - request: MiniAppHostConnectorReadable, - listeners: { - readonly onData: (chunk: unknown) => void; - readonly onEnd: () => void; - readonly onError: () => void; - }, -): () => void { - request.on('data', listeners.onData); - request.on('end', listeners.onEnd); - request.on('error', listeners.onError); - return () => { - request.removeListener('data', listeners.onData); - request.removeListener('end', listeners.onEnd); - request.removeListener('error', listeners.onError); - }; -} async function listHostConnectorTools(input: { readonly gateway: HostProcessConnectorGateway; @@ -861,12 +914,15 @@ function mintToolRef( async function writeHostResponse( previous: Promise, shouldPublish: () => boolean, - output: MiniAppHostConnectorWritable, + transport: MiniAppIpcTransport, value: unknown, ): Promise { await previous; if (!shouldPublish()) return; - await writeBytes(output, encodeFrame(value, RESPONSE_MAX_BYTES)); + await sendMiniAppIpcMessage( + transport, + encodeMiniAppIpcMessage('miniapp:connector', value, RESPONSE_MAX_BYTES), + ); } async function raceWithSignal(operation: Promise, signal: AbortSignal): Promise { @@ -909,11 +965,14 @@ function decodeCallParams(params: Record | undefined): { function decodeRequest(value: unknown): RequestFrame { if (!isRecord(value) || value.v !== 1 || !isRequestId(value.id)) throw protocolParamsError(); - if (value.method === 'connector.cancel' && hasExactKeys(value, ['v', 'id', 'method'])) { + if ( + (value.method === 'connector.cancel' || value.method === 'agent.cancel') && + hasExactKeys(value, ['v', 'id', 'method']) + ) { return { id: value.id, method: value.method }; } if ( - (value.method !== 'connector.list' && value.method !== 'connector.call') || + !isParamsMethod(value.method) || !hasExactKeys(value, ['v', 'id', 'method', 'params']) || !isRecord(value.params) ) { @@ -922,7 +981,60 @@ function decodeRequest(value: unknown): RequestFrame { return { id: value.id, method: value.method, params: value.params }; } -function decodeChildResult(method: PendingChildRequest['method'], value: unknown): unknown { +function isParamsMethod(value: unknown): value is ChildRequestMethod { + return ( + value === 'connector.list' || + value === 'connector.call' || + (typeof value === 'string' && isAgentMethod(value)) + ); +} + +function decodeChildOutcome( + entry: PendingChildRequest, + value: Record, +): { readonly error?: Error; readonly result?: unknown } { + const success = value.ok === true; + const keys = success ? ['v', 'id', 'ok', 'result'] : ['v', 'id', 'ok', 'error']; + if (!hasExactKeys(value, keys)) { + throw new HostConnectorProtocolError('Host Connector response shape is invalid'); + } + if (success) { + const result = decodeChildResult(entry.method, value.result); + assertInvocationIdentity(entry, result); + return { result }; + } + if (isAgentMethod(entry.method)) return { error: decodeAgentWireError(value.error) }; + const error = decodeWireError(value.error); + assertInvocationIdentity(entry, error); + return { error }; +} + +function deliverAgentEvent(value: Record, entry: PendingChildRequest): void { + if (!hasExactKeys(value, ['v', 'id', 'event'])) { + throw new HostConnectorProtocolError('Host Connector notification shape is invalid'); + } + if (!isAgentTurnMethod(entry.method)) return; + const event = decodeAgentRunEvent(value.event); + if (!event) return; + try { + entry.onEvent?.(event); + } catch (error) { + // A Mini App callback failure must not tear down the shared connector channel or end the + // run, but the author still needs to see it; child stderr is collected into runtime logs. + reportAgentEventHandlerError(error); + } +} + +function reportAgentEventHandlerError(error: unknown): void { + try { + process.stderr.write(`${format('[miniapp] agent onEvent handler threw', error)}\n`); + } catch { + // Diagnostics are best-effort; a broken stderr must not fail the run either. + } +} + +function decodeChildResult(method: ChildRequestMethod, value: unknown): unknown { + if (isAgentMethod(method)) return decodeAgentResult(method, value); if (!isRecord(value)) throw new HostConnectorProtocolError('Host Connector result is invalid'); if (method === 'connector.list') { if ( @@ -1024,7 +1136,8 @@ function decodeWireDiagnosticIssue(value: unknown): HostConnectorDiagnosticIssue function recordAdmissionReceipt(value: Record, entry: PendingChildRequest): void { if ( - entry.method !== 'connector.call' || + entry.method === 'connector.list' || + isAgentShortMethod(entry.method) || entry.invocationId || !hasExactKeys(value, ['v', 'id', 'receipt', 'invocationId']) || !isBoundedIdentifier(value.invocationId) @@ -1076,7 +1189,12 @@ async function resolvesTrue(operation: Promise): Promise { return true; } -function disconnectedError(id: string, request: PendingChildRequest): HostConnectorError { +function disconnectedError(id: string, request: PendingChildRequest): Error { + if (isAgentMethod(request.method)) { + return request.written + ? agentClientError('AGENT_OUTCOME_UNKNOWN') + : agentClientError('AGENT_UNAVAILABLE', true); + } return request.method === 'connector.call' && request.written ? new HostConnectorError( 'CONNECTOR_OUTCOME_UNKNOWN', @@ -1087,6 +1205,23 @@ function disconnectedError(id: string, request: PendingChildRequest): HostConnec : unavailableError(true); } +function childUnavailableError(method: ChildRequestMethod, retryable = false): Error { + return isAgentMethod(method) + ? agentClientError('AGENT_UNAVAILABLE', retryable) + : unavailableError(retryable); +} + +function childCancelledError(method: ChildRequestMethod): Error { + return isAgentMethod(method) ? agentClientError('REQUEST_CANCELLED') : cancelledError(); +} + +function childEncodeError(method: ChildRequestMethod): Error { + if (isAgentMethod(method)) return agentClientError('INVALID_ARGUMENTS'); + return method === 'connector.call' + ? invalidArgumentsError() + : new HostConnectorProtocolError('Host Connector list request is invalid'); +} + function unavailableError(retryable = false): HostConnectorError { return new HostConnectorError( 'CONNECTOR_UNAVAILABLE', @@ -1125,29 +1260,3 @@ function requireInvocationId(value: unknown): string { return value; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function isBoundedIdentifier(value: unknown): value is string { - return ( - typeof value === 'string' && - value.length > 0 && - Buffer.byteLength(value) <= MAX_IDENTIFIER_BYTES - ); -} - -function isRequestId(value: unknown): value is string { - if (typeof value !== 'string' || !/^[1-9]\d{0,19}$/u.test(value)) return false; - try { - return BigInt(value) <= 18_446_744_073_709_551_615n; - } catch { - return false; - } -} - -function hasExactKeys(value: Record, keys: readonly string[]): boolean { - const actual = Object.keys(value).sort(); - const expected = [...keys].sort(); - return actual.length === expected.length && actual.every((key, index) => key === expected[index]); -} diff --git a/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/host-runner.ts b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/host-runner.ts index 7582b7195..5a5fc36d7 100644 --- a/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/host-runner.ts +++ b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/host-runner.ts @@ -1,12 +1,24 @@ +import { timingSafeEqual } from 'node:crypto'; import { realpath } from 'node:fs/promises'; +import http from 'node:http'; import path from 'node:path'; +import type { Duplex } from 'node:stream'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import type { MiniAppHostConnectorPolicy } from '../../contracts.js'; +import { + MINIAPP_AGENT_MAX_CONCURRENCY, + MINIAPP_LOOPBACK_HEALTH_PATH, + MINIAPP_LOOPBACK_TOKEN_HEADER, + type MiniAppAgentPolicy, + type MiniAppHostConnectorPolicy, + type MiniAppIpcMessage, + type MiniAppIpcTransport, +} from '../../contracts.js'; import { boundMiniAppRuntimeErrorText } from '../../errors.js'; import { createMiniAppHostConnectorRuntimeClient, type HostConnectorClient, + type MiniAppAgentClient, type MiniAppHostConnectorRuntimeClient, } from '../host-connector/host-connector.js'; @@ -43,7 +55,11 @@ export interface MiniAppRunnerInitMessage { readonly dataDir: string; readonly listen: { readonly host: '127.0.0.1'; readonly port: number }; readonly readiness: { readonly startupToken: string; readonly nodeDigest: string }; + /** Per-process loopback credential. Consumed by the HTTP guard; never enters plugin context. */ + readonly loopbackToken: string; readonly hostConnectorPolicy: MiniAppHostConnectorPolicy; + /** Absent policy is deny-all: the child then receives no `agent` context. */ + readonly agentPolicy: MiniAppAgentPolicy; } export type MiniAppRunnerChildMessage = @@ -92,6 +108,7 @@ interface MiniAppStartContext { error(message: string, fields?: Readonly>): void; }; readonly hostConnector?: HostConnectorClient; + readonly agent?: MiniAppAgentClient; } type StartResult = void | { @@ -111,12 +128,16 @@ export async function startMiniAppModule( readonly processGeneration: string; readonly policy: MiniAppHostConnectorPolicy; }) => HostConnectorClient | undefined; + readonly createAgent?: () => MiniAppAgentClient | undefined; } = {}, ): Promise { const resolveRealPath = options.resolveRealPath ?? realpath; const packageRoot = await resolveRealPath(init.packageRoot); + // Must precede loading the entry: plugin servers inherit the guard from the patched prototype. + installMiniAppLoopbackGuard({ port: init.listen.port, token: init.loopbackToken }); const start = await loadStartFunction(init, packageRoot, resolveRealPath, options.loadModule); assertConnectorAvailable(init, options.createHostConnector); + assertAgentAvailable(init, options.createAgent); const abortController = new AbortController(); const logger = createLogger(options.emit); const hostConnector = options.createHostConnector?.({ @@ -124,6 +145,7 @@ export async function startMiniAppModule( processGeneration: init.processGeneration, policy: init.hostConnectorPolicy, }); + const agent = init.agentPolicy.kind === 'allowlist' ? options.createAgent?.() : undefined; const context = Object.freeze({ pluginId: init.pluginId, pluginRoot: packageRoot, @@ -133,6 +155,7 @@ export async function startMiniAppModule( signal: abortController.signal, logger, ...(hostConnector ? { hostConnector } : {}), + ...(agent ? { agent } : {}), }); let result: StartResult; try { @@ -197,6 +220,18 @@ function assertConnectorAvailable( } } +function assertAgentAvailable( + init: MiniAppRunnerInitMessage, + create: (() => MiniAppAgentClient | undefined) | undefined, +): void { + if (init.agentPolicy.kind === 'allowlist' && !create) { + throw new MiniAppRunnerError( + 'HOST_CONNECTOR_UNAVAILABLE', + 'Mini App Agent access requires a Host-owned client', + ); + } +} + function readDispose( result: StartResult, abortController: AbortController, @@ -240,11 +275,15 @@ interface RunnerProcess { exitCode?: number; on(event: string, listener: (...args: unknown[]) => void): this; once(event: string, listener: (...args: unknown[]) => void): this; - send?(message: MiniAppRunnerChildMessage, callback?: (error: Error | null) => void): boolean; + removeListener(event: string, listener: (...args: unknown[]) => void): this; + send?( + message: MiniAppRunnerChildMessage | MiniAppIpcMessage, + callback?: (error: Error | null) => void, + ): boolean; disconnect?(): void; } -/** Child executable entry. IPC controls lifecycle; stdio is reserved for Host Connector wire. */ +/** Child executable entry. IPC routes lifecycle and Connector messages; stdio carries logs. */ export function runMiniAppHostRunner( options: { readonly process?: RunnerProcess; @@ -269,7 +308,8 @@ export function runMiniAppHostRunner( // A concurrent parent disconnect already releases the IPC handle. } }; - const start = options.start ?? ((init) => startWithStdioConnector(init, send)); + const transport = runnerIpcTransport(processPort); + const start = options.start ?? ((init) => startWithIpcConnector(init, send, transport)); let running: RunningMiniAppModule | undefined; let initialized = false; let stopping: Promise | undefined; @@ -293,6 +333,8 @@ export function runMiniAppHostRunner( runnerObservations.add(observed); }; processPort.on('message', (message: unknown) => { + // Connector responses must be delivered while start(context) is awaiting list(). + if (isConnectorMessage(message)) return; dispatch( (async () => { if (isStopMessage(message)) { @@ -365,29 +407,25 @@ async function dispatchOperation( } } -async function startWithStdioConnector( +async function startWithIpcConnector( init: MiniAppRunnerInitMessage, emit: (message: MiniAppRunnerChildMessage) => void, + transport: MiniAppIpcTransport, ): Promise { let connector: MiniAppHostConnectorRuntimeClient | undefined; + const channel = () => { + connector ??= createMiniAppHostConnectorRuntimeClient({ transport }); + return connector; + }; const closeConnector = () => { - if (!connector) return; - connector.close(); - process.stdin.pause(); + connector?.close(); }; try { const running = await startMiniAppModule(init, { emit, createHostConnector: - init.hostConnectorPolicy.kind === 'allowlist' - ? () => { - connector = createMiniAppHostConnectorRuntimeClient({ - input: process.stdin, - output: process.stdout, - }); - return connector.client; - } - : undefined, + init.hostConnectorPolicy.kind === 'allowlist' ? () => channel().client : undefined, + createAgent: init.agentPolicy.kind === 'allowlist' ? () => channel().agent : undefined, }); return { async stop() { @@ -404,6 +442,77 @@ async function startWithStdioConnector( } } +const GUARDED_REQUEST_EVENTS: ReadonlySet = new Set([ + 'request', + 'checkContinue', + 'checkExpectation', +]); +const GUARDED_SOCKET_EVENTS: ReadonlySet = new Set(['upgrade', 'connect']); + +/** + * Authenticates every `node:http` server in this process before plugin code can observe a request. + * Only the Host-issued credential on the exact loopback Host passes; `GET /__mavis/health` is + * answered here so readiness proves the guard is in the path rather than merely that a socket accepts. + */ +function installMiniAppLoopbackGuard(input: { readonly port: number; readonly token: string }): void { + const prototype = http.Server.prototype; + const originalEmit = prototype.emit; + const expectedHost = `127.0.0.1:${input.port}`; + const expectedToken = Buffer.from(input.token, 'utf8'); + const authorized = (request: http.IncomingMessage) => + request.headers.host === expectedHost && + tokenMatches(request.headers[MINIAPP_LOOPBACK_TOKEN_HEADER], expectedToken); + prototype.emit = function guardedEmit( + this: http.Server, + event: string | symbol, + ...args: unknown[] + ) { + if (typeof event === 'string' && GUARDED_REQUEST_EVENTS.has(event)) { + const [request, response] = args as [http.IncomingMessage, http.ServerResponse]; + if (!authorized(request)) { + response.writeHead(401).end(); + return true; + } + if (isHealthRequest(request)) { + response.writeHead(204).end(); + return true; + } + stripLoopbackToken(request); + } else if (typeof event === 'string' && GUARDED_SOCKET_EVENTS.has(event)) { + const [request, socket] = args as [http.IncomingMessage, Duplex]; + if (!authorized(request)) { + socket.destroy(); + return true; + } + stripLoopbackToken(request); + } + return Reflect.apply(originalEmit, this, [event, ...args]) as boolean; + } as typeof prototype.emit; +} + +function tokenMatches(value: string | string[] | undefined, expected: Buffer): boolean { + if (typeof value !== 'string') return false; + const actual = Buffer.from(value, 'utf8'); + return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected); +} + +/** Application code never needs the credential; removing it keeps proxies and header dumps clean. */ +function stripLoopbackToken(request: http.IncomingMessage): void { + delete request.headers[MINIAPP_LOOPBACK_TOKEN_HEADER]; + const raw = request.rawHeaders; + for (let index = raw.length - 2; index >= 0; index -= 2) { + if (raw[index]?.toLowerCase() === MINIAPP_LOOPBACK_TOKEN_HEADER) raw.splice(index, 2); + } +} + +function isHealthRequest(request: http.IncomingMessage): boolean { + const url = request.url ?? ''; + return ( + request.method === 'GET' && + (url === MINIAPP_LOOPBACK_HEALTH_PATH || url.startsWith(`${MINIAPP_LOOPBACK_HEALTH_PATH}?`)) + ); +} + export function isMiniAppRunnerInitMessage(value: unknown): value is MiniAppRunnerInitMessage { if (!isRecord(value)) return false; const keys = [ @@ -416,7 +525,9 @@ export function isMiniAppRunnerInitMessage(value: unknown): value is MiniAppRunn 'dataDir', 'listen', 'readiness', + 'loopbackToken', 'hostConnectorPolicy', + 'agentPolicy', ]; if (!hasExactKeys(value, keys)) return false; return ( @@ -425,10 +536,36 @@ export function isMiniAppRunnerInitMessage(value: unknown): value is MiniAppRunn hasStringIdentityFields(value) && isListenAddress(value.listen) && isReadinessIdentity(value.readiness) && - isConnectorPolicy(value.hostConnectorPolicy) + isLoopbackToken(value.loopbackToken) && + isConnectorPolicy(value.hostConnectorPolicy) && + isAgentPolicy(value.agentPolicy) ); } +function isAgentPolicy(value: unknown): value is MiniAppAgentPolicy { + if (!isRecord(value)) return false; + return value.kind === 'deny-all' + ? hasExactKeys(value, ['kind']) + : value.kind === 'allowlist' && + hasExactKeys(value, ['kind', 'capabilities', 'maxConcurrent']) && + Array.isArray(value.capabilities) && + value.capabilities.every((capability) => capability === 'session.run') && + isAgentConcurrency(value.maxConcurrent); +} + +function isAgentConcurrency(value: unknown): boolean { + return ( + typeof value === 'number' && + Number.isInteger(value) && + value >= 1 && + value <= MINIAPP_AGENT_MAX_CONCURRENCY + ); +} + +function isLoopbackToken(value: unknown): boolean { + return typeof value === 'string' && value.length > 0; +} + function hasStringIdentityFields(value: Record): boolean { return ['pluginId', 'processGeneration', 'packageRoot', 'nodeEntry', 'dataDir'].every( (field) => typeof value[field] === 'string', @@ -472,6 +609,10 @@ function nativeProcessPort(): RunnerProcess { process.once(event, listener); return this; }, + removeListener(event, listener) { + process.removeListener(event, listener); + return this; + }, send(message, callback) { if (!process.send) { callback?.(new Error('Runner IPC channel is unavailable')); @@ -495,6 +636,27 @@ function isConnectorPolicy(value: unknown): value is MiniAppHostConnectorPolicy value.providers.every((provider) => typeof provider === 'string'); } +function runnerIpcTransport(processPort: RunnerProcess): MiniAppIpcTransport { + return { + get connected() { + return processPort.connected; + }, + send(message, callback) { + if (!processPort.send) { + callback(new Error('Runner IPC channel is unavailable')); + return false; + } + return processPort.send(message, callback); + }, + on: (event, listener) => processPort.on(event, listener), + removeListener: (event, listener) => processPort.removeListener(event, listener), + }; +} + +function isConnectorMessage(value: unknown): boolean { + return isRecord(value) && value.type === 'miniapp:connector'; +} + function isStopMessage(value: unknown): boolean { return isRecord(value) && hasExactKeys(value, ['type']) && value.type === 'miniapp:stop'; } diff --git a/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/ipc-transport.ts b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/ipc-transport.ts new file mode 100644 index 000000000..5e4a3fc06 --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/ipc-transport.ts @@ -0,0 +1,106 @@ +import type { MiniAppIpcMessage, MiniAppIpcTransport } from '../../contracts.js'; + +const IPC_SEND_TIMEOUT_MS = 30_000; + +/** JSON semantics and application-level limits; Node has already parsed incoming IPC messages. */ +export function encodeMiniAppIpcMessage( + type: MiniAppIpcMessage['type'], + payload: unknown, + maxBytes: number, +): MiniAppIpcMessage { + const json = boundedJson(payload, maxBytes); + return { type, payload: JSON.parse(json) as unknown }; +} + +export function bindMiniAppIpcChannel( + transport: MiniAppIpcTransport, + options: { + readonly type: MiniAppIpcMessage['type']; + readonly maxBytes: number; + readonly onMessage: (payload: unknown) => void; + readonly onFatal: (error: Error) => void; + }, +): () => void { + const onMessage = (value: unknown) => { + if (!isRecord(value) || value.type !== options.type) return; + try { + if (Object.keys(value).length !== 2 || !Object.hasOwn(value, 'payload')) { + throw new Error('Mini App IPC message envelope is invalid'); + } + boundedJson(value.payload, options.maxBytes); + options.onMessage(value.payload); + } catch (error) { + options.onFatal(error instanceof Error ? error : new Error('Mini App IPC message failed')); + } + }; + const onDisconnect = () => options.onFatal(new Error('Mini App IPC channel disconnected')); + const onError = () => options.onFatal(new Error('Mini App IPC channel failed')); + const detach = () => { + transport.removeListener('message', onMessage); + transport.removeListener('disconnect', onDisconnect); + transport.removeListener('error', onError); + }; + try { + transport.on('message', onMessage); + transport.on('disconnect', onDisconnect); + transport.on('error', onError); + } catch { + detach(); + throw new Error('Mini App IPC channel registration failed'); + } + return detach; +} + +/** Bound the IPC callback wait, including backpressure; this does not confirm execution. */ +export function sendMiniAppIpcMessage( + transport: MiniAppIpcTransport, + message: MiniAppIpcMessage, + onSend?: () => void, +): Promise { + if (transport.connected === false) return Promise.reject(new Error('Mini App IPC is closed')); + return new Promise((resolve, reject) => { + let settled = false; + const complete = (error: Error | null = null) => { + if (settled) return; + settled = true; + clearTimeout(deadline); + transport.removeListener('disconnect', onDisconnect); + transport.removeListener('error', onError); + if (error) reject(error); + else resolve(); + }; + const onDisconnect = () => complete(new Error('Mini App IPC disconnected during send')); + const onError = () => complete(new Error('Mini App IPC send failed')); + const deadline = setTimeout( + () => complete(new Error('Mini App IPC send timed out')), + IPC_SEND_TIMEOUT_MS, + ); + deadline.unref?.(); + try { + transport.on('disconnect', onDisconnect); + transport.on('error', onError); + transport.send(message, complete); + // Returning from send only proves a transport handoff; callers retain unknown-outcome rules. + onSend?.(); + } catch { + complete(new Error('Mini App IPC send failed')); + } + }); +} + +function boundedJson(value: unknown, maxBytes: number): string { + let json: string | undefined; + try { + json = JSON.stringify(value); + } catch { + throw new Error('Mini App IPC payload is not valid JSON'); + } + if (json === undefined) throw new Error('Mini App IPC payload is not valid JSON'); + if (Buffer.byteLength(json) > maxBytes) + throw new Error('Mini App IPC message exceeds its byte limit'); + return json; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-ownership.ts b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-ownership.ts new file mode 100644 index 000000000..08a6269c8 --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-ownership.ts @@ -0,0 +1,94 @@ +import { request as httpRequest } from 'node:http'; + +import { MINIAPP_LOOPBACK_HEALTH_PATH } from '../../contracts.js'; + +/** + * What is holding a derived Mini App port. + * + * - `free` — nothing is listening; the port is this client's to take. + * - `guarded` — a Mini App's own guard answered: another client is running this Mini App, or a + * previous generation of this one outlived its owner. Only the process that minted the credential + * can talk to it, so this client refuses rather than starting a second process over the same + * plugin data. + * - `foreign` — something else holds the port and does not answer like the guard. Not this Mini App; + * the runtime may pick another port. + * - `unknown` — the probe could not distinguish a listener from a free port; callers must fail + * closed rather than start on a different port. + */ +export type MiniAppPortOwnership = 'free' | 'guarded' | 'foreign' | 'unknown'; + +export interface MiniAppPortProbeInput { + readonly port: number; + readonly timeoutMs: number; + readonly signal: AbortSignal; +} + +/** + * Probes a derived port before a Mini App is spawned. The shipped desktop client writes no ownership + * record, so for anything already running this is the only signal there is, and it has to be the + * guard's own answer rather than a bare connect: a socket that accepts proves nothing. + */ +export async function probeMiniAppPortOwnership( + input: MiniAppPortProbeInput, +): Promise { + let status: number; + try { + status = await requestHealth(input); + } catch (error) { + if (input.signal.aborted) throw input.signal.reason; + if (isConnectionRefused(error)) return 'free'; + return 'unknown'; + } + return status === 401 ? 'guarded' : 'foreign'; +} + +function isConnectionRefused(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ECONNREFUSED' + ); +} + +function requestHealth(input: MiniAppPortProbeInput): Promise { + if (input.signal.aborted) return Promise.reject(input.signal.reason); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (outcome: { readonly status: number } | { readonly error: unknown }) => { + if (settled) return; + settled = true; + clearTimeout(timer); + input.signal.removeEventListener('abort', onAbort); + if ('status' in outcome) { + resolve(outcome.status); + return; + } + request.destroy(); + reject(outcome.error); + }; + const request = httpRequest( + { + host: '127.0.0.1', + port: input.port, + method: 'GET', + path: MINIAPP_LOOPBACK_HEALTH_PATH, + agent: false, + }, + (response) => { + response.on('error', () => undefined); + response.resume(); + finish({ status: response.statusCode ?? 0 }); + }, + ); + const onAbort = () => finish({ error: input.signal.reason }); + const timer = setTimeout( + () => finish({ error: new Error('Mini App port probe timed out') }), + Math.max(1, input.timeoutMs), + ); + timer.unref(); + request.on('error', (error) => finish({ error })); + input.signal.addEventListener('abort', onAbort, { once: true }); + request.end(); + }); +} diff --git a/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-readiness.ts b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-readiness.ts new file mode 100644 index 000000000..3d388ebc5 --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-readiness.ts @@ -0,0 +1,144 @@ +import { request as httpRequest } from 'node:http'; + +import { MINIAPP_LOOPBACK_HEALTH_PATH, MINIAPP_LOOPBACK_TOKEN_HEADER } from '../../contracts.js'; +import { MiniAppNodeRuntimeError } from './node-runtime-startup-error.js'; + +export interface LoopbackReadinessInput { + readonly port: number; + /** The credential the runtime minted for this process generation. */ + readonly token: string; + /** Absolute wall-clock deadline for readiness as a whole. */ + readonly deadline: number; + readonly nowMs: () => number; + readonly probeTimeoutMs: number; + readonly retryMs: number; + readonly wait: (ms: number, signal: AbortSignal) => Promise; + readonly signal: AbortSignal; +} + +/** + * Readiness for a Mini App's own server: an anonymous health request must be rejected with 401 and the + * credentialed one answered 204 by the guard itself. A socket that merely accepts proves nothing, + * because the port is derived from the plugin id and any local process can hold it. + * + * A port that answers wrongly is not retried — it keeps answering wrongly — only a failed connection is, + * while the child is still binding. + */ +export async function waitForLoopbackReadiness(input: LoopbackReadinessInput): Promise { + let lastError: unknown; + for (;;) { + throwIfAborted(input.signal); + try { + await probeLoopbackAuth({ + port: input.port, + timeoutMs: Math.min(input.probeTimeoutMs, remaining(input)), + signal: input.signal, + token: input.token, + }); + return; + } catch (error) { + if (isProbeRejection(error)) throw error; + lastError = error; + } + await input.wait(Math.min(input.retryMs, remaining(input)), input.signal); + if (input.nowMs() >= input.deadline) { + throw new MiniAppNodeRuntimeError('READINESS_TIMEOUT', 'Loopback readiness timed out', { + cause: lastError, + }); + } + } +} + +async function probeLoopbackAuth(input: { + readonly port: number; + readonly token: string; + readonly timeoutMs: number; + readonly signal: AbortSignal; +}): Promise { + const anonymous = await requestLoopbackHealth(input, undefined); + if (anonymous !== 401) { + throw new MiniAppNodeRuntimeError( + 'READINESS_PROBE_REJECTED', + `unauthenticated health probe expected 401, received ${anonymous}`, + ); + } + const authenticated = await requestLoopbackHealth(input, input.token); + if (authenticated !== 204) { + throw new MiniAppNodeRuntimeError( + 'READINESS_PROBE_REJECTED', + `authenticated health probe expected 204, received ${authenticated}`, + ); + } +} + +function isProbeRejection(error: unknown): boolean { + return error instanceof MiniAppNodeRuntimeError && error.code === 'READINESS_PROBE_REJECTED'; +} + +function requestLoopbackHealth( + input: { + readonly port: number; + readonly timeoutMs: number; + readonly signal: AbortSignal; + }, + token: string | undefined, +): Promise { + if (input.signal.aborted) return Promise.reject(input.signal.reason); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (outcome: { readonly status: number } | { readonly error: unknown }) => { + if (settled) return; + settled = true; + clearTimeout(timer); + input.signal.removeEventListener('abort', onAbort); + if ('status' in outcome) { + resolve(outcome.status); + return; + } + request.destroy(); + reject(outcome.error); + }; + const request = httpRequest( + { + host: '127.0.0.1', + port: input.port, + method: 'GET', + path: MINIAPP_LOOPBACK_HEALTH_PATH, + agent: false, + headers: token ? { [MINIAPP_LOOPBACK_TOKEN_HEADER]: token } : {}, + }, + (response) => { + response.on('error', () => undefined); + response.resume(); + finish({ status: response.statusCode ?? 0 }); + }, + ); + const onAbort = () => finish({ error: input.signal.reason }); + const timer = setTimeout( + () => + finish({ + error: new MiniAppNodeRuntimeError( + 'READINESS_PROBE_TIMEOUT', + 'Loopback auth probe request timed out', + ), + }), + Math.max(1, input.timeoutMs), + ); + timer.unref(); + request.on('error', (error) => finish({ error })); + input.signal.addEventListener('abort', onAbort, { once: true }); + request.end(); + }); +} + +function remaining(input: LoopbackReadinessInput): number { + const value = input.deadline - input.nowMs(); + if (value <= 0) { + throw new MiniAppNodeRuntimeError('READINESS_TIMEOUT', 'Readiness deadline expired'); + } + return value; +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) throw signal.reason; +} diff --git a/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/node-runtime.ts b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/node-runtime.ts index 03297dc59..73c2ccd27 100644 --- a/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/node-runtime.ts +++ b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/node-runtime.ts @@ -1,5 +1,5 @@ import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from 'node:child_process'; -import { createHash, randomUUID } from 'node:crypto'; +import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { lstat, mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; import { createConnection } from 'node:net'; import os from 'node:os'; @@ -14,10 +14,18 @@ import type { MiniAppRuntimeExit, ProcessMiniAppCandidate, } from '../../contracts.js'; +import { MINIAPP_LOOPBACK_HEALTH_PATH, MINIAPP_LOOPBACK_TOKEN_HEADER } from '../../contracts.js'; import { selectFailureAfterCleanup } from '../../supervisor/cleanup-settlement.js'; import { MiniAppCleanupUnprovenError, markMiniAppRootTerminationProven } from '../../errors.js'; import { sanitizeMiniAppRuntimeLogEvent } from '../../runtime-log.js'; import type { MiniAppRunnerInitMessage } from './host-runner.js'; +import { waitForLoopbackReadiness } from './loopback-readiness.js'; +import { probeMiniAppPortOwnership } from './loopback-ownership.js'; +import { + acquireMiniAppOwner, + readMiniAppOwnerIdentity, + type MiniAppOwnerIdentity, +} from './owner-lease.js'; import { createDirectNodeRootTerminator, type DirectNodeRootTerminator, @@ -83,6 +91,9 @@ interface NodeRuntimeOptions { readonly ensureDirectory?: (directory: string) => Promise; readonly resolveRealPath?: (target: string) => Promise; readonly makeStartupToken?: () => string; + readonly makeLoopbackToken?: () => string; + /** Label this client records when it takes ownership of a Mini App. */ + readonly ownerClient?: string; readonly nowMs?: () => number; readonly wait?: (ms: number, signal: AbortSignal) => Promise; readonly runnerStartTimeoutMs?: number; @@ -111,6 +122,8 @@ type ResolvedOptions = Required< | 'ensureDirectory' | 'resolveRealPath' | 'makeStartupToken' + | 'makeLoopbackToken' + | 'ownerClient' | 'nowMs' | 'wait' | 'runnerStartTimeoutMs' @@ -143,6 +156,10 @@ export function createMiniAppNodeRuntimeAdapter( const resolved = resolveOptions(options); const physicalRoots = createPhysicalRootCapacity(MAX_RUNNING_PLUGIN_SERVICES); return { + async ownerClient(pluginId) { + const dataDir = await resolveDataDir(resolved, pluginId); + return (await readMiniAppOwnerIdentity(dataDir))?.client; + }, async prepare(input) { assertCapabilities(resolved, input.candidate); throwIfAborted(input.signal); @@ -223,9 +240,49 @@ async function prepareRuntime(input: { readonly dataDir: string; readonly runtimeDir: string; }): Promise { + // Ownership is taken before the port loop and before any spawn: a second client must never reach + // the point of starting a process over the same plugin data. + const ownership = await acquireMiniAppOwner({ + pluginDataDir: input.dataDir, + pluginId: input.candidate.pluginId, + identity: { client: input.options.ownerClient }, + nowMs: input.options.nowMs, + }); + if (ownership.kind === 'held') { + throw new MiniAppNodeRuntimeError( + 'OWNED_ELSEWHERE', + ownerHeldDetail(ownership.owner, input.candidate.pluginId), + ); + } + try { + return await preparedRuntimeWithOwnership(input, ownership); + } catch (error) { + await ownership.release(); + throw error; + } +} + +function ownerHeldDetail(owner: MiniAppOwnerIdentity | undefined, pluginId: string): string { + return owner + ? `Mini App ${pluginId} is already running in ${owner.client} (pid ${owner.pid})` + : `Mini App ${pluginId} is already running in another client`; +} + +async function preparedRuntimeWithOwnership( + input: Parameters[0], + ownership: { release(): Promise }, +): Promise { + // One credential per process generation: every port attempt reuses it. + const loopbackToken = input.options.makeLoopbackToken(); + const ownershipPort = deriveMiniAppNodePort(input.candidate.pluginId); + const firstCandidatePort = candidatePort(input, 0); + // Probe the plugin's stable port before applying Host reservation policy. A + // reservation may skip that candidate, but it cannot make an existing owner + // safe to run beside on a fallback port. + await refuseIfPortIsOwnedElsewhere(input, ownershipPort); let lastError: unknown; for (let index = 0; index < input.options.maxPortAttempts; index += 1) { - const port = candidatePort(input, index); + const port = index === 0 ? firstCandidatePort : candidatePort(input, index); const reservation = input.options.reservePort?.({ pluginId: input.candidate.pluginId, port, @@ -235,18 +292,22 @@ async function prepareRuntime(input: { const deadline = input.options.nowMs() + input.options.runnerStartTimeoutMs; let attempt: Attempt | undefined; try { - attempt = await spawnAttempt(input, port); + attempt = await spawnAttempt(input, port, loopbackToken); const readinessSignal = AbortSignal.any([input.signal, attempt.fatal.signal]); await waitForStarted( attempt, readinessSignal, Math.min(input.options.runnerStartTimeoutMs, remaining(deadline, input.options.nowMs)), ); - await waitForTcp({ - options: input.options, + await waitForLoopbackReadiness({ port, - signal: readinessSignal, + token: loopbackToken, deadline, + nowMs: input.options.nowMs, + probeTimeoutMs: input.options.tcpConnectTimeoutMs, + retryMs: input.options.tcpRetryMs, + wait: input.options.wait, + signal: readinessSignal, }); attempt.startupErrorCapture.complete(); return preparedRuntime({ @@ -254,6 +315,8 @@ async function prepareRuntime(input: { attempt, port, reservation, + loopbackToken, + ownership, }); } catch (error) { const failure = await selectFailureAfterCleanup(error, () => @@ -262,7 +325,11 @@ async function prepareRuntime(input: { attachStartupFailureDetail(error, failure); attempt?.startupErrorCapture.attachFailure(failure, error); lastError = failure; - if (!retryablePortError(failure) || index + 1 >= input.options.maxPortAttempts) throw failure; + if (!retryablePortError(failure)) throw failure; + // Only this plugin's deterministic port identifies its cross-client owner. A guarded + // listener on any fallback candidate can belong to a different Mini App. + if (port === ownershipPort) await refuseIfPortIsOwnedElsewhere(input, port); + if (index + 1 >= input.options.maxPortAttempts) throw failure; } } throw ( @@ -271,6 +338,31 @@ async function prepareRuntime(input: { ); } +async function refuseIfPortIsOwnedElsewhere( + input: Parameters[0], + port: number, +): Promise { + throwIfAborted(input.signal); + const ownership = await probeMiniAppPortOwnership({ + port, + timeoutMs: input.options.tcpConnectTimeoutMs, + signal: input.signal, + }); + throwIfAborted(input.signal); + if (ownership === 'unknown') { + throw new MiniAppNodeRuntimeError( + 'RUNNER_START_FAILED', + `Unable to verify whether Mini App ${input.candidate.pluginId} is already running in another client`, + ); + } + if (ownership === 'guarded') { + throw new MiniAppNodeRuntimeError( + 'OWNED_ELSEWHERE', + ownerHeldDetail(undefined, input.candidate.pluginId), + ); + } +} + async function cleanupFailedAttempt( attempt: Attempt | undefined, cause: unknown, @@ -298,6 +390,7 @@ async function cleanupFailedPreparation( async function spawnAttempt( input: Parameters[0], port: number, + loopbackToken: string, ): Promise { const init: MiniAppRunnerInitMessage = { type: 'miniapp:init', @@ -312,7 +405,9 @@ async function spawnAttempt( startupToken: input.options.makeStartupToken(), nodeDigest: input.candidate.nodeDigest, }, + loopbackToken, hostConnectorPolicy: input.candidate.hostConnectorPolicy, + agentPolicy: input.candidate.agentPolicy ?? { kind: 'deny-all' }, }; const physicalRoot = input.physicalRoots.acquire(input.candidate.pluginId); let child: ChildProcess; @@ -323,7 +418,7 @@ async function spawnAttempt( windowsHide: true, shell: false, env: runnerEnvironment(input.options.env), - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], }); } catch (error) { physicalRoot.release(); @@ -355,7 +450,6 @@ async function spawnAttempt( processGeneration: input.processGeneration, onLog: input.onLog, fatal, - connectorEnabled: Boolean(connector), }); let shutdownRequested = false; let resolveClosed: (exit: MiniAppRuntimeExit) => void = () => undefined; @@ -483,19 +577,20 @@ function createConnectorSession( child: ChildProcess, fatal: AbortController, ): MiniAppHostConnectorSession | undefined { - if (input.candidate.hostConnectorPolicy.kind !== 'allowlist') return undefined; - if (!child.stdout || !child.stdin || !input.options.hostConnectorSessionFactory) { + const { hostConnectorPolicy, agentPolicy } = input.candidate; + if (!needsConnectorChannel(input.candidate)) return undefined; + if (!child.send || child.connected === false || !input.options.hostConnectorSessionFactory) { throw new MiniAppNodeRuntimeError( 'HOST_CONNECTOR_UNAVAILABLE', - 'Host Connector stdio or session factory is unavailable', + 'Host Connector IPC or session factory is unavailable', ); } return input.options.hostConnectorSessionFactory.create({ pluginId: input.candidate.pluginId, processGeneration: input.processGeneration, - providers: input.candidate.hostConnectorPolicy.providers, - request: child.stdout, - response: child.stdin, + providers: hostConnectorPolicy.kind === 'allowlist' ? hostConnectorPolicy.providers : [], + ...(agentPolicy ? { agentPolicy } : {}), + transport: child, onFatal: (error) => fatal.abort(error), }); } @@ -506,7 +601,6 @@ function captureChildLogs( readonly processGeneration: string; readonly onLog: Parameters[0]['onLog']; readonly fatal: AbortController; - readonly connectorEnabled: boolean; }, ): void { child.on('message', (raw: unknown) => { @@ -527,28 +621,31 @@ function captureChildLogs( }), ); }); - child.stderr?.on('data', (chunk: Buffer | string) => { + // stdout and stderr are the plugin's own logs: bounded, sanitized, and never protocol input. + let logWindowStartedAt = Date.now(); + let remainingLogBytes = 64 * 1024; + const captureStreamLog = (level: 'info' | 'error', chunk: Buffer | string) => { + const now = Date.now(); + if (now - logWindowStartedAt >= 1_000) { + logWindowStartedAt = now; + remainingLogBytes = 64 * 1024; + } + if (remainingLogBytes <= 0) return; const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const accepted = bytes.subarray(0, Math.min(4 * 1024, remainingLogBytes)); + remainingLogBytes -= accepted.byteLength; options.onLog( sanitizeMiniAppRuntimeLogEvent({ processGeneration: options.processGeneration, - level: 'error', - message: bytes.toString('utf8').trim(), + level, + message: accepted.toString('utf8').trim(), byteLength: bytes.byteLength, fieldKeys: [], }), ); - }); - if (!options.connectorEnabled) { - child.stdout?.once('data', () => { - options.fatal.abort( - new MiniAppNodeRuntimeError( - 'HOST_CONNECTOR_PROTOCOL_VIOLATION', - 'Mini App wrote to reserved stdout', - ), - ); - }); - } + }; + child.stderr?.on('data', (chunk: Buffer | string) => captureStreamLog('error', chunk)); + child.stdout?.on('data', (chunk: Buffer | string) => captureStreamLog('info', chunk)); } function waitForStarted(attempt: Attempt, signal: AbortSignal, timeoutMs: number): Promise { @@ -607,47 +704,13 @@ function waitForStarted(attempt: Attempt, signal: AbortSignal, timeoutMs: number }); } -async function waitForTcp(input: { - readonly options: ResolvedOptions; - readonly port: number; - readonly signal: AbortSignal; - readonly deadline: number; -}): Promise { - let lastError: unknown; - for (;;) { - throwIfAborted(input.signal); - try { - await input.options.connectPort({ - host: LOOPBACK_HOST, - port: input.port, - timeoutMs: Math.min( - input.options.tcpConnectTimeoutMs, - remaining(input.deadline, input.options.nowMs), - ), - signal: input.signal, - }); - return; - } catch (error) { - lastError = error; - } - const delay = Math.min( - input.options.tcpRetryMs, - remaining(input.deadline, input.options.nowMs), - ); - await input.options.wait(delay, input.signal); - if (input.options.nowMs() >= input.deadline) { - throw new MiniAppNodeRuntimeError('READINESS_TIMEOUT', 'TCP readiness timed out', { - cause: lastError, - }); - } - } -} - function preparedRuntime( input: Parameters[0] & { readonly attempt: Attempt; readonly port: number; readonly reservation?: { release(): void }; + readonly loopbackToken: string; + readonly ownership: { release(): Promise }; }, ): MiniAppNodeRuntime { let stopPromise: Promise<{ readonly proven: boolean }> | undefined; @@ -681,6 +744,11 @@ function preparedRuntime( } catch (removeError) { postStopErrors.push(removeError); } + try { + await input.ownership.release(); + } catch (releaseError) { + postStopErrors.push(releaseError); + } throwPostStopErrors(postStopErrors); return { proven: true }; })(); @@ -698,6 +766,7 @@ function preparedRuntime( nodeDigest: input.candidate.nodeDigest, port: input.port, origin: `http://${LOOPBACK_HOST}:${input.port}`, + loopbackToken: input.loopbackToken, ...(input.attempt.connector ? { hostConnectorSession: input.attempt.connector } : {}), closed: input.attempt.closed, stop() { @@ -789,6 +858,8 @@ function resolveOptions(options: NodeRuntimeOptions): ResolvedOptions { }, resolveRealPath: realpath, makeStartupToken: randomUUID, + makeLoopbackToken: () => randomBytes(32).toString('base64url'), + ownerClient: 'unknown', nowMs: Date.now, wait: defaultWait, runnerStartTimeoutMs: 10_000, @@ -810,7 +881,7 @@ function resolveOptions(options: NodeRuntimeOptions): ResolvedOptions { } function assertCapabilities(options: ResolvedOptions, candidate: ProcessMiniAppCandidate): void { - if (candidate.hostConnectorPolicy.kind === 'allowlist' && !options.hostConnectorSessionFactory) { + if (needsConnectorChannel(candidate) && !options.hostConnectorSessionFactory) { throw new MiniAppNodeRuntimeError( 'HOST_CONNECTOR_UNAVAILABLE', 'Host Connector session is unavailable', @@ -829,13 +900,16 @@ async function resolveDataDir(options: ResolvedOptions, pluginId: string): Promi return options.resolveRealPath(requested); } +/** Connector and Agent access share one `miniapp:connector` IPC session. */ +function needsConnectorChannel(candidate: ProcessMiniAppCandidate): boolean { + return ( + candidate.hostConnectorPolicy.kind === 'allowlist' || + candidate.agentPolicy?.kind === 'allowlist' + ); +} + function candidatePort(input: Parameters[0], index: number): number { - const preferred = input.preferredPort; - const digest = createHash('sha256').update(input.candidate.pluginId).digest(); - const base = - preferred !== undefined && preferred >= PORT_MIN && preferred <= PORT_MAX - ? preferred - : PORT_MIN + (digest.readUInt32BE(0) % PORT_COUNT); + const base = deterministicPort(input); let available = 0; for (let offset = 0; offset < PORT_COUNT; offset += 1) { const port = PORT_MIN + ((base - PORT_MIN + offset) % PORT_COUNT); @@ -846,6 +920,17 @@ function candidatePort(input: Parameters[0], index: numbe throw new MiniAppNodeRuntimeError('NO_CANDIDATE_PORT', 'No Host-approved port is available'); } +export function deriveMiniAppNodePort(pluginId: string): number { + const digest = createHash('sha256').update(pluginId).digest(); + return PORT_MIN + (digest.readUInt32BE(0) % PORT_COUNT); +} + +function deterministicPort(input: Parameters[0]): number { + const preferred = input.preferredPort; + if (preferred !== undefined && preferred >= PORT_MIN && preferred <= PORT_MAX) return preferred; + return deriveMiniAppNodePort(input.candidate.pluginId); +} + function runnerEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const result: NodeJS.ProcessEnv = { ELECTRON_RUN_AS_NODE: '1' }; for (const key of [ diff --git a/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/owner-lease.ts b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/owner-lease.ts new file mode 100644 index 000000000..0b4abdd0a --- /dev/null +++ b/packages/local-runtime-v2/src/service/miniapp/process/node-runtime/owner-lease.ts @@ -0,0 +1,171 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import net from 'node:net'; +import path from 'node:path'; + +/** + * Who holds a Mini App, recorded beside the endpoint that proves it. + * + * The binding is the truth and this record is the explanation: a live owner holds its endpoint, and the + * operating system releases that binding when the owner dies. So a record whose endpoint nobody holds is + * stale by definition, whatever its timestamp says. + */ +export interface MiniAppOwnerIdentity { + readonly client: string; + readonly pid: number; + readonly startedAtMs: number; +} + +export type MiniAppOwnerAcquisition = + | { readonly kind: 'acquired'; release(): Promise } + | { readonly kind: 'held'; readonly owner?: MiniAppOwnerIdentity }; + +export interface AcquireMiniAppOwnerInput { + /** The Mini App's own directory in the shared data directory. */ + readonly pluginDataDir: string; + readonly pluginId: string; + readonly identity: Omit; + readonly nowMs?: () => number; + readonly platform?: NodeJS.Platform; +} + +const SOCKET_FILE = 'owner.sock'; +const RECORD_FILE = 'owner.json'; +const WINDOWS_PIPE_PREFIX = '\\\\.\\pipe\\mcode-miniapp-owner-'; + +/** A socket file on POSIX, a named pipe on Windows — named from the plugin id, never from the directory. */ +export function resolveMiniAppOwnerEndpoint( + pluginDataDir: string, + pluginId: string, + platform: NodeJS.Platform = process.platform, +): string { + if (platform === 'win32') { + const digest = createHash('sha256').update(pluginId).digest('hex').slice(0, 16); + return `${WINDOWS_PIPE_PREFIX}${digest}`; + } + return path.join(pluginDataDir, SOCKET_FILE); +} + +/** + * Takes ownership of one Mini App, or reports who holds it. + * + * A failed bind means somebody is listening; an unheld socket file means a previous owner died without + * cleaning up. The two are told apart by trying to connect: a refused connection proves the file is a + * leftover, and only then is it removed. Nothing is unlinked before the first bind attempt, because that + * would steal a live owner's endpoint. + */ +export async function acquireMiniAppOwner( + input: AcquireMiniAppOwnerInput, +): Promise { + const nowMs = input.nowMs ?? Date.now; + const platform = input.platform ?? process.platform; + const endpoint = resolveMiniAppOwnerEndpoint(input.pluginDataDir, input.pluginId, platform); + if (platform !== 'win32') await mkdir(input.pluginDataDir, { recursive: true }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const server = net.createServer((socket) => socket.destroy()); + const bound = await bind(server, endpoint); + if (bound) { + const identity: MiniAppOwnerIdentity = { + client: input.identity.client, + pid: process.pid, + startedAtMs: nowMs(), + }; + await writeOwnerRecord(input.pluginDataDir, identity); + let released = false; + return { + kind: 'acquired', + async release() { + if (released) return; + released = true; + await close(server); + await rm(endpoint, { force: true }); + await rm(path.join(input.pluginDataDir, RECORD_FILE), { force: true }); + }, + }; + } + if (attempt === 0 && (await isLeftoverEndpoint(endpoint))) { + await rm(endpoint, { force: true }); + continue; + } + const owner = await readMiniAppOwnerIdentity(input.pluginDataDir); + return { kind: 'held', ...(owner ? { owner } : {}) }; + } + const owner = await readMiniAppOwnerIdentity(input.pluginDataDir); + return { kind: 'held', ...(owner ? { owner } : {}) }; +} + +function bind(server: net.Server, endpoint: string): Promise { + return new Promise((resolve, reject) => { + const onError = (error: NodeJS.ErrnoException) => { + server.removeListener('listening', onListening); + server.close(); + if (error.code === 'EADDRINUSE') { + resolve(false); + return; + } + reject(error); + }; + const onListening = () => { + server.removeListener('error', onError); + resolve(true); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(endpoint); + }); +} + +/** True only when the endpoint exists and nothing answers on it. */ +function isLeftoverEndpoint(endpoint: string): Promise { + return new Promise((resolve) => { + const socket = net.connect(endpoint); + const finish = (leftover: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(leftover); + }; + socket.once('connect', () => finish(false)); + socket.once('error', (error: NodeJS.ErrnoException) => { + finish(error.code === 'ECONNREFUSED' || error.code === 'ENOENT'); + }); + }); +} + +function close(server: net.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +async function writeOwnerRecord( + pluginDataDir: string, + identity: MiniAppOwnerIdentity, +): Promise { + try { + await writeFile( + path.join(pluginDataDir, RECORD_FILE), + `${JSON.stringify(identity)}\n`, + 'utf8', + ); + } catch { + // The binding already proves ownership; a record that cannot be written changes nothing. + } +} + +export async function readMiniAppOwnerIdentity( + pluginDataDir: string, +): Promise { + try { + const raw = JSON.parse(await readFile(path.join(pluginDataDir, RECORD_FILE), 'utf8')) as unknown; + if ( + raw && + typeof raw === 'object' && + typeof (raw as MiniAppOwnerIdentity).client === 'string' && + typeof (raw as MiniAppOwnerIdentity).pid === 'number' + ) { + return raw as MiniAppOwnerIdentity; + } + } catch { + // An absent or unreadable record still leaves the endpoint answering for the owner. + } + return undefined; +} diff --git a/packages/local-runtime-v2/src/service/miniapp/supervisor/supervisor.ts b/packages/local-runtime-v2/src/service/miniapp/supervisor/supervisor.ts index 82513770b..42479c4d1 100644 --- a/packages/local-runtime-v2/src/service/miniapp/supervisor/supervisor.ts +++ b/packages/local-runtime-v2/src/service/miniapp/supervisor/supervisor.ts @@ -684,6 +684,14 @@ export class DefaultMiniAppSupervisor implements MiniAppSupervisor { ); } + launchCredential(pluginId: string): string | undefined { + return this.active.get(pluginId)?.runtime?.loopbackToken; + } + + ownerClient(pluginId: string): Promise { + return this.options.nodeRuntime.ownerClient?.(pluginId) ?? Promise.resolve(undefined); + } + inspect(pluginId?: string): readonly MiniAppStatus[] { const ids = pluginId ? [pluginId] diff --git a/packages/local-runtime-v2/src/service/plugin-system/contracts.ts b/packages/local-runtime-v2/src/service/plugin-system/contracts.ts index 3929eeded..289e00f29 100644 --- a/packages/local-runtime-v2/src/service/plugin-system/contracts.ts +++ b/packages/local-runtime-v2/src/service/plugin-system/contracts.ts @@ -202,6 +202,14 @@ export interface AvailableMiniAppDefinition { readonly displayName?: string; readonly description?: string; readonly iconPath?: string; + /** + * Whether the runtime may start this package. + * + * Absent on `listAvailableMiniApps`, whose entries are enabled by definition; + * set by `listInstalledMiniApps`, which also answers for a disabled local + * package so a management surface can offer to enable it. + */ + readonly enabled?: boolean; } export interface WorkspaceMiniAppInitializationResult { @@ -216,6 +224,14 @@ export interface MiniAppPluginControl { /** True only when the accepted candidate still owns the authoritative active generation. */ isAcceptedMiniAppRunning(pluginId: string): boolean; listAvailableMiniApps(): Promise; + /** + * Every installed Mini App, enabled or not. + * + * A read only: activation, publication and the projection keep reading + * `listAvailableMiniApps`, so a disabled package stays unstartable while still + * being visible to the surface that can enable it. + */ + listInstalledMiniApps(): Promise; activateMiniApp(input: { readonly pluginId: string; readonly signal?: AbortSignal; diff --git a/packages/local-runtime-v2/src/service/plugin-system/initialize.ts b/packages/local-runtime-v2/src/service/plugin-system/initialize.ts index f64f8000c..b9a626dee 100644 --- a/packages/local-runtime-v2/src/service/plugin-system/initialize.ts +++ b/packages/local-runtime-v2/src/service/plugin-system/initialize.ts @@ -167,6 +167,8 @@ export function initializePluginService( system.miniAppPublication.isAcceptedMiniAppRunning(pluginId), listAvailableMiniApps: () => system.miniAppPublication.listAvailableMiniApps(), + listInstalledMiniApps: () => + system.miniAppPublication.listInstalledMiniApps(), activateMiniApp: (input) => system.miniAppPublication.activate(input), initializeWorkspaceMiniApp: (input) => system.miniAppPublication.initializeWorkspace(input), diff --git a/packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.ts b/packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.ts index f7b20515e..a28fa209f 100644 --- a/packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.ts +++ b/packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.ts @@ -1,5 +1,7 @@ import path from 'node:path'; +import { MINIAPP_AGENT_MAX_CONCURRENCY } from '../../../../miniapp/contracts.js'; + import { isRecord, resolvePluginDirectory, @@ -23,10 +25,12 @@ const MINIAPP_FIELDS = new Set([ 'surface', 'mcpEndpoints', 'hostConnectorAccess', + 'agentAccess', + 'agentConcurrency', ]); const ARTIFACT_FIELDS = new Set(['client', 'node']); const PROCESS_RUNTIME_FIELDS = new Set(['kind', 'entry', 'lifecycle']); -const SURFACE_FIELDS = new Set(['path']); +const SURFACE_FIELDS = new Set(['path', 'window']); const MCP_ENDPOINT_FIELDS = new Set(['server', 'path']); const HOST_CONNECTOR_ACCESS_FIELDS = new Set(['providers']); const CONNECTOR_PROVIDER = /^[a-z0-9_-]{1,64}$/u; @@ -46,12 +50,16 @@ export async function readMiniAppManifest( const surface = readSurface(value.surface); const mcpEndpoints = readMcpEndpoints(value.mcpEndpoints, mcpServerNames); const hostConnectorAccess = readHostConnectorAccess(value.hostConnectorAccess); + const agentAccess = readAgentAccess(value.agentAccess); + const agentConcurrency = readAgentConcurrency(value.agentConcurrency); return { artifacts, runtime, surface, mcpEndpoints, ...(hostConnectorAccess ? { hostConnectorAccess } : {}), + ...(agentAccess ? { agentAccess } : {}), + ...(agentConcurrency === undefined ? {} : { agentConcurrency }), }; } @@ -136,8 +144,11 @@ async function readProcessRuntime( function readSurface(value: unknown): { path: string } { if (!isRecord(value) || hasUnknownField(value, SURFACE_FIELDS)) { - miniAppFail('SURFACE_INVALID', 'surface must contain only path'); + miniAppFail('SURFACE_INVALID', 'surface must contain only path and window'); } + // `surface.window` is accepted and ignored: the window block is not part of this + // client's Mini App contract, so declaring it has no effect here. The key stays in + // the allowlist so a package built for the desktop client is not rejected. return { path: normalizeRoutePath(value.path, 'surface.path') }; } @@ -196,6 +207,31 @@ function readHostConnectorAccess(value: unknown): { providers: string[] } | unde return { providers }; } +function readAgentAccess(value: unknown): 'session.run'[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((capability) => capability !== 'session.run')) { + miniAppFail('AGENT_ACCESS_INVALID', 'agentAccess may only contain session.run'); + } + return [...new Set(value as 'session.run'[])]; +} + +/** Allowed without `agentAccess`, where it has no effect. */ +function readAgentConcurrency(value: unknown): number | undefined { + if (value === undefined) return undefined; + if ( + typeof value !== 'number' || + !Number.isInteger(value) || + value < 1 || + value > MINIAPP_AGENT_MAX_CONCURRENCY + ) { + miniAppFail( + 'AGENT_CONCURRENCY_INVALID', + `agentConcurrency must be an integer from 1 to ${MINIAPP_AGENT_MAX_CONCURRENCY}`, + ); + } + return value; +} + async function resolveFileOrDirectory( root: CanonicalPluginRoot, relativePath: string, diff --git a/packages/local-runtime-v2/src/service/plugin-system/plugin/package/types.ts b/packages/local-runtime-v2/src/service/plugin-system/plugin/package/types.ts index 43344aa15..2a46090ce 100644 --- a/packages/local-runtime-v2/src/service/plugin-system/plugin/package/types.ts +++ b/packages/local-runtime-v2/src/service/plugin-system/plugin/package/types.ts @@ -104,6 +104,9 @@ export interface MiniAppContribution { readonly surface: { readonly path: string }; readonly mcpEndpoints: readonly MiniAppMcpEndpoint[]; readonly hostConnectorAccess?: { readonly providers: readonly string[] }; + readonly agentAccess?: readonly 'session.run'[]; + /** Agent runs of this Mini App at once; absent means the Host default. */ + readonly agentConcurrency?: number; } type ScannedMiniAppContribution = MiniAppContribution & { diff --git a/packages/local-runtime-v2/src/service/plugin-system/plugin/runtime/miniapp/candidate.ts b/packages/local-runtime-v2/src/service/plugin-system/plugin/runtime/miniapp/candidate.ts index 8167985eb..805dd5d16 100644 --- a/packages/local-runtime-v2/src/service/plugin-system/plugin/runtime/miniapp/candidate.ts +++ b/packages/local-runtime-v2/src/service/plugin-system/plugin/runtime/miniapp/candidate.ts @@ -1,3 +1,5 @@ +import { MINIAPP_AGENT_DEFAULT_CONCURRENCY, type MiniAppAgentPolicy } from '../../../../miniapp/contracts.js'; + import { PluginSystemError } from '../../../errors.js'; import type { RuntimeEligibleScannedReadPluginPackage } from '../../package/types.js'; import type { PluginSnapshot } from '../snapshot-builder.js'; @@ -16,6 +18,8 @@ export interface PluginMiniAppCandidate { readonly hostConnectorPolicy: | { readonly kind: 'deny-all' } | { readonly kind: 'allowlist'; readonly providers: readonly string[] }; + /** Derived from the parsed manifest's `agentAccess`; deny-all when it declares none. */ + readonly agentPolicy?: MiniAppAgentPolicy; } export interface MiniAppDefinition { @@ -36,18 +40,35 @@ export function sameMiniAppCandidate( return JSON.stringify(left) === JSON.stringify(right); } -/** Enabled and valid authored definitions, independent from Host runtime acceptance. */ -export function listEnabledMiniAppDefinitions( +/** + * Every installed Mini App, enabled or not. + * + * The enabled-only projection below is what activation, publication and the + * runtime's own projection must keep using. This one exists because a management + * surface has to be able to show a Mini App it disabled and offer to turn it back + * on: a listing that drops the package answers "nothing is installed", which is + * the one answer that leaves the person with no action. + * + * The two share this walk, so a package cannot be a Mini App for one and not for + * the other. Official packages carry no per-package switch in this snapshot, so + * they are enabled whenever the package is present — which is what the enabled + * projection has always assumed for them. A local package's `enabled` is the + * projection's own flag, the same one the rest of the plugin system reads. + */ +export function listInstalledMiniAppDefinitions( snapshot: PluginSnapshot, -): readonly MiniAppDefinition[] { +): readonly (MiniAppDefinition & { readonly enabled: boolean })[] { const packages = [ - ...snapshot.officialPackages.flatMap(({ plugin }) => (plugin?.miniapp ? [plugin] : [])), + ...snapshot.officialPackages.flatMap(({ plugin }) => + plugin?.miniapp ? [{ plugin, enabled: true }] : [], + ), ...snapshot.localPlugins - .filter((projection) => projection.enabled && projection.plugin.miniapp !== undefined) - .map(({ plugin }) => plugin), + .filter((projection) => projection.plugin.miniapp !== undefined) + .map((projection) => ({ plugin: projection.plugin, enabled: projection.enabled })), ]; return packages - .map((plugin) => ({ + .map(({ plugin, enabled }) => ({ + enabled, source: plugin.source === 'OFFICIAL' ? 'official' : 'local', ...(plugin.displayName ? { displayName: plugin.displayName } : {}), ...(plugin.description ? { description: plugin.description } : {}), @@ -57,6 +78,13 @@ export function listEnabledMiniAppDefinitions( .sort((left, right) => left.candidate.pluginId.localeCompare(right.candidate.pluginId)); } +/** Enabled and valid authored definitions, independent from Host runtime acceptance. */ +export function listEnabledMiniAppDefinitions( + snapshot: PluginSnapshot, +): readonly MiniAppDefinition[] { + return listInstalledMiniAppDefinitions(snapshot).filter((definition) => definition.enabled); +} + /** Runtime-only projection after a publication snapshot has been admission-filtered. */ export function listAcceptedMiniApps(snapshot: PluginSnapshot): readonly MiniAppDefinition[] { return listEnabledMiniAppDefinitions(snapshot); @@ -82,6 +110,14 @@ export function miniAppCandidateFromPackage(packageEntry: { hostConnectorPolicy: miniapp.hostConnectorAccess?.providers.length ? { kind: 'allowlist', providers: [...miniapp.hostConnectorAccess.providers] } : { kind: 'deny-all' }, + // An absent or empty list denies: a manifest that never asked for agent access admits none. + agentPolicy: miniapp.agentAccess?.length + ? { + kind: 'allowlist', + capabilities: [...miniapp.agentAccess], + maxConcurrent: miniapp.agentConcurrency ?? MINIAPP_AGENT_DEFAULT_CONCURRENCY, + } + : { kind: 'deny-all' }, }; } diff --git a/packages/local-runtime-v2/src/service/plugin-system/plugin/runtime/miniapp/controller.ts b/packages/local-runtime-v2/src/service/plugin-system/plugin/runtime/miniapp/controller.ts index cade543ea..1d17ccac1 100644 --- a/packages/local-runtime-v2/src/service/plugin-system/plugin/runtime/miniapp/controller.ts +++ b/packages/local-runtime-v2/src/service/plugin-system/plugin/runtime/miniapp/controller.ts @@ -23,6 +23,7 @@ import type { import { activateAvailableMiniApp, type MiniAppActivationInput } from './activation.js'; import { listEnabledMiniAppDefinitions, + listInstalledMiniAppDefinitions, listAcceptedMiniApps, miniAppCandidateFromPackage, sameMiniAppCandidate, @@ -253,6 +254,20 @@ export class PluginMiniAppPublicationController { ); } + async listInstalledMiniApps(): Promise { + await this.options.host.initialize(); + this.options.host.assertUsable(); + return this.options.host.runExclusive(async () => + listInstalledMiniAppDefinitions(await this.options.readAvailableSnapshot()).map( + ({ candidate, enabled, ...metadata }) => ({ + pluginId: candidate.pluginId, + enabled, + ...metadata, + }), + ), + ); + } + async activate(input: MiniAppActivationInput): Promise { const participant = this.requireParticipant(); const host = this.options.host; diff --git a/packages/local-runtime-v2/src/service/session-system/sessions/errors.ts b/packages/local-runtime-v2/src/service/session-system/sessions/errors.ts index 895b825a5..be15d8921 100644 --- a/packages/local-runtime-v2/src/service/session-system/sessions/errors.ts +++ b/packages/local-runtime-v2/src/service/session-system/sessions/errors.ts @@ -12,6 +12,7 @@ export type SessionFailureReason = | 'session-id-conflict' | 'run-location-invalid' | 'memory-recall-locked' + | 'purpose-reserved' | 'task-agent-capture-unavailable'; export class SessionServiceError extends Error { diff --git a/packages/local-runtime-v2/src/service/session-system/sessions/lifecycle/record-service.ts b/packages/local-runtime-v2/src/service/session-system/sessions/lifecycle/record-service.ts index 8bfd52316..9eb411965 100644 --- a/packages/local-runtime-v2/src/service/session-system/sessions/lifecycle/record-service.ts +++ b/packages/local-runtime-v2/src/service/session-system/sessions/lifecycle/record-service.ts @@ -28,6 +28,7 @@ import { import { isTaskSession } from '../repo/drizzle/normalization.js'; import { AgentServiceError } from '../../../agent/errors.js'; import { SessionServiceError } from '../errors.js'; +import { MINIAPP_AGENT_PURPOSE_PREFIX } from '../../../miniapp/contracts.js'; import { isDefaultProjectWorkspace } from '../../shared/project-workspace.js'; import type { SessionMutationFields, @@ -411,6 +412,7 @@ export class SessionRecordService } async createSession(input: SessionCreateInput): Promise { + assertPurposeIsCallerWritable(input.purpose); const context = await this.prepareSessionCreate(input); return this.createRecord(toMetadataCreateInput(context)); } @@ -962,6 +964,20 @@ type CurrentSessionAgentDefinitionCreate = ReturnType< typeof withCurrentSessionAgentDefinitionProject >; +/** + * `miniapp:` marks a Session as owned by a Mini App plugin, and that label is what routes the plugin's + * permission asks back to it. Only the runtime's own Mini App session creation may write it, through + * `createInternalSession`; this is the external create, so a reserved label here is an ownership claim. + */ +function assertPurposeIsCallerWritable(purpose: string | undefined): void { + if (purpose === undefined) return; + if (!purpose.trim().startsWith(MINIAPP_AGENT_PURPOSE_PREFIX)) return; + throw new SessionServiceError( + 'purpose-reserved', + 'Session purpose is reserved for runtime-owned Sessions', + ); +} + function toMetadataCreateInput(context: CreateMetadataContext): SessionMetadataCreateInput { const requestedKind = metadataSessionKind(context.input); return { diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts index f07718332..9fccb01c7 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts @@ -8,6 +8,9 @@ import type { LocalTurnExecutionPreparationSource, } from '../execution/executor.js'; import { LocalTurnPermissionGate } from '../runner/policy/local-turn-permission-gate.js'; +import type { LocalTurnSessionLineage } from '../runner/policy/local-turn-permission-gate.js'; +import type { SessionRecord } from '../../../session-system/index.js'; +import { MINIAPP_AGENT_PURPOSE_PREFIX } from '../../../miniapp/contracts.js'; import { LocalTurnInputPreparer } from './local-turn-input-preparation.js'; import { readTrustedBuiltinAgentProfileIdentity } from './local-turn-tool-catalog-source.js'; import { @@ -102,6 +105,7 @@ export class NativeLocalTurnExecutionPreparationSource< toolContext, trustedExactWritePaths: context.trustedExactWritePaths, signal: signal ?? input.execution.lease.signal, + sessionLineage: readMiniAppLineage(input.execution.session), preToolPermission: input.execution.pluginApprovalRequests?.get(toolContext.toolCall.id), ...(prepared.channelContext ? { channelContext: prepared.channelContext } : {}), ...(input.execution.pluginHooks?.length @@ -119,6 +123,18 @@ type PluginPermissionRequestHook = NonNullable< Parameters[0]['beforeApproval'] >; +/** + * Mini App lineage of the asking Session, read from the trusted SessionRecord the turn already holds. + * The ownership label is written only by the runtime's own Mini App session creation, never echoed from + * a client request. + */ +function readMiniAppLineage(session: SessionRecord): LocalTurnSessionLineage { + return { + miniApp: session.purpose?.startsWith(MINIAPP_AGENT_PURPOSE_PREFIX) === true, + parentSessionId: session.parentSessionId ?? null, + }; +} + function createPluginPermissionRequestHook( execution: LocalTurnExecutionInput, ): PluginPermissionRequestHook { diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/index.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/index.ts index bd496ed59..54b9988c6 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/index.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/index.ts @@ -11,7 +11,9 @@ export { type LocalTurnPermissionDecision, type LocalTurnPermissionDecisionSource, type LocalTurnPermissionGateOptions, + type LocalTurnMiniAppApprovalRouter, type LocalTurnPermissionMutationOwner, + type LocalTurnSessionLineage, } from './policy/local-turn-permission-gate.js'; export { LocalOutputSafetyEventWriter, diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/policy/local-turn-permission-gate.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/policy/local-turn-permission-gate.ts index 6997f3778..4097932b8 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/policy/local-turn-permission-gate.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/policy/local-turn-permission-gate.ts @@ -111,6 +111,24 @@ export interface LocalTurnPermissionGateOptions { readonly sideSessions?: { isSideSession(sessionId: string): Promise; }; + /** + * Decides whether a Mini App ask has anybody who could answer it. Consulted only when a tool would + * actually prompt: a Mini App Session created by a plugin has no human behind it unless its running + * turn registered a listener or a host chat shows it in a side panel. + */ + readonly miniAppApprovals?: LocalTurnMiniAppApprovalRouter; +} + +/** The Mini App lineage facts the approval router needs; derived from the turn's session record. */ +export interface LocalTurnSessionLineage { + /** The session itself is a Mini App root. */ + readonly miniApp: boolean; + readonly parentSessionId?: string | null; +} + +/** Implemented by the Mini App agent gateway; absent when the runtime has no Mini App service. */ +export interface LocalTurnMiniAppApprovalRouter { + shouldDenyApproval(sessionId: string, lineage?: LocalTurnSessionLineage): Promise; } type PermissionExecutionPlanPort = NonNullable; @@ -133,6 +151,8 @@ interface BeforeToolCallInput { readonly signal?: AbortSignal; readonly channelContext?: AgentHostChannelContext; readonly trustedExactWritePaths?: readonly string[]; + /** Mini App lineage of the asking Session; absent when the turn could not supply it. */ + readonly sessionLineage?: LocalTurnSessionLineage; /** Compatible-compatible PreToolUse permission request. Policy deny always wins. */ readonly preToolPermission?: { readonly behavior: 'allow' | 'ask'; @@ -374,6 +394,8 @@ export class LocalTurnPermissionGate { if (pluginDecision.updatedInput || pluginDecision.updatedPermissions?.length) { return this.resolveRewrittenPermission(input, pluginDecision); } + const miniAppDenial = await this.resolveMiniAppApproval(input, request); + if (miniAppDenial) return miniAppDenial; return this.resolveApproval({ input, request, @@ -383,6 +405,57 @@ export class LocalTurnPermissionGate { }); } + /** + * Looked up only when a tool would actually prompt. A Mini App ask nobody can answer is denied with + * a reason the model can act on, instead of parking the turn on a prompt with no reader. + */ + private async resolveMiniAppApproval( + input: BeforeToolCallInput, + request: PermissionToolRequest, + ): Promise<{ readonly block: true; readonly reason: string } | undefined> { + const router = this.options.miniAppApprovals; + const lineage = input.sessionLineage; + if (!router || isOrdinaryRootSession(lineage)) return undefined; + try { + if (!(await router.shouldDenyApproval(input.sessionId, lineage))) return undefined; + } catch { + return this.resolveMiniAppLookupFailure(input, request); + } + this.logEnforcement(input, request, { + policy_behavior: 'ask', + gate_result: 'blocked', + decision_source: 'approval_or_abort', + approval_decision: 'deny', + rewrite_applied: false, + miniapp_denied: true, + }); + return permissionBlock(request.toolName, miniAppDeniedApprovalReason(request.toolName), 'policy'); + } + + /** + * A router failure must not deny an ordinary Session's tool, so only a Session already known to be a + * Mini App root fails closed here; everything else continues into the normal approval flow. + */ + private resolveMiniAppLookupFailure( + input: BeforeToolCallInput, + request: PermissionToolRequest, + ): { readonly block: true; readonly reason: string } | undefined { + if (input.sessionLineage?.miniApp !== true) return undefined; + this.logEnforcement(input, request, { + policy_behavior: 'ask', + gate_result: 'blocked', + decision_source: 'approval_or_abort', + approval_decision: 'deny', + rewrite_applied: false, + miniapp_lookup_failed: true, + }); + return permissionBlock( + request.toolName, + miniAppDeniedApprovalReason(request.toolName), + 'policy', + ); + } + private async applyPluginPermissionUpdates( input: BeforeToolCallInput, request: PermissionToolRequest, @@ -570,6 +643,14 @@ function preToolHookAllowsWithoutApproval( ); } +function isOrdinaryRootSession(lineage: LocalTurnSessionLineage | undefined): boolean { + return lineage !== undefined && !lineage.miniApp && !lineage.parentSessionId; +} + +function miniAppDeniedApprovalReason(toolName: string): string { + return `Nobody can answer this permission request: the Mini App that owns this Session registered no event listener for this run and has not opened the Session in a chat side panel. Continue without ${toolName} or explain what you need.`; +} + function shouldSkipPermissionCheck( options: LocalTurnPermissionGateOptions, input: BeforeToolCallInput, diff --git a/packages/local-runtime-v2/src/service/turn-system/index.ts b/packages/local-runtime-v2/src/service/turn-system/index.ts index 4841aafa5..fb18764d9 100644 --- a/packages/local-runtime-v2/src/service/turn-system/index.ts +++ b/packages/local-runtime-v2/src/service/turn-system/index.ts @@ -78,6 +78,8 @@ export type { } from './production-composition.js'; export type { ToolResultCompactionConfig } from './compaction/contracts.js'; export type { AgentHostRuntimeLifecycle } from './agent-host/native-production-dependencies.js'; +/** Mini App approval routing consumed by the turn owner; implemented by the Mini App gateway. */ +export type { LocalTurnMiniAppApprovalRouter } from './agent-host/index.js'; export { ModelRouteAvailabilityError, createSessionModelRepair, diff --git a/packages/local-runtime-v2/src/service/turn-system/production-composition.ts b/packages/local-runtime-v2/src/service/turn-system/production-composition.ts index fccfe3954..d21ee533a 100644 --- a/packages/local-runtime-v2/src/service/turn-system/production-composition.ts +++ b/packages/local-runtime-v2/src/service/turn-system/production-composition.ts @@ -53,6 +53,7 @@ import { type LocalTurnRawToolSources, type LocalTurnInputPreparerOptions, type LocalTurnPermissionGateOptions, + type LocalTurnMiniAppApprovalRouter, type LocalAgentPreparationService, type NativeAgentPreparationOptions, type NativeLocalAgentPreparationOptions, @@ -204,6 +205,11 @@ export interface CreateLocalAgentHostOptions< readonly turnFacts: RequiredAgentEventProjector; readonly pluginHookSessionOwnership: TurnSystemHostCapabilities['pluginHookSessionOwnership']; readonly toolPolicyGuard?: LocalTurnToolPolicyGuard; + /** + * Mini App approval routing: the gateway that knows whether any listener or open panel can answer a + * plugin-owned session's ask. Absent when this runtime has no Mini App service. + */ + readonly miniAppApprovals?: LocalTurnMiniAppApprovalRouter; /** Binds the Goal verifier child's opt-in attempt cap to its provider requests. */ readonly outputTokenCap?: LocalTurnOutputTokenCapResolver; readonly normalExtensions?: readonly AgentExtension[]; @@ -334,6 +340,7 @@ function createHostPermissionPolicy( return session ? session.purpose !== 'peek_btw_session' && isPeekSession(session) : false; }, }, + ...(options.miniAppApprovals ? { miniAppApprovals: options.miniAppApprovals } : {}), }; } diff --git a/packages/local-runtime-v2/src/service/v1-conversation-compat/v1-conversation-compat.ts b/packages/local-runtime-v2/src/service/v1-conversation-compat/v1-conversation-compat.ts index 6a710a504..fde5463eb 100644 --- a/packages/local-runtime-v2/src/service/v1-conversation-compat/v1-conversation-compat.ts +++ b/packages/local-runtime-v2/src/service/v1-conversation-compat/v1-conversation-compat.ts @@ -25,8 +25,26 @@ import { type SessionRepository, } from '../session-system/index.js'; import type { AgentHostTurnOutcome, RequestCompactionResult } from '../turn-system/index.js'; +import { MINIAPP_AGENT_PURPOSE_PREFIX } from '../miniapp/contracts.js'; +import { SessionServiceError } from '../session-system/index.js'; import type { V1ConversationCompatibilityOptions } from './contracts.js'; +/** + * `miniapp:` marks a Session as owned by a Mini App plugin, and the runtime routes that plugin's + * permission asks back by it. This surface is client-facing but its non-root branch reuses + * `createInternalSession`, so the reserved label has to be refused here: only the runtime's own Mini + * App session creation may write it. Thrown as the same typed failure the session owner raises, so both + * client-facing create paths report the same `purpose-reserved` reason. + */ +function assertPurposeIsCallerWritable(purpose: string | undefined): void { + if (purpose === undefined) return; + if (!purpose.trim().startsWith(MINIAPP_AGENT_PURPOSE_PREFIX)) return; + throw new SessionServiceError( + 'purpose-reserved', + 'Session purpose is reserved for runtime-owned Sessions', + ); +} + /** v2 service implementation injected into hosted v1 compatibility consumers. */ export class V1ConversationCompatibilityService implements RuntimeConversation { constructor(private readonly options: V1ConversationCompatibilityOptions) {} @@ -195,6 +213,7 @@ export class V1ConversationCompatibilityService implements RuntimeConversation { }; private async createSession(input: ConversationSessionCreateInput): Promise { + assertPurposeIsCallerWritable(input.purpose); const agentName = await this.options.resolveAgentWriteTarget(input.agentName); if (input.sessionType === 'root') { const workspaceDir = nonEmpty(input.workspaceDir); diff --git a/packages/local-runtime-v2/src/services.test.ts b/packages/local-runtime-v2/src/services.test.ts index f503f3028..1289501cc 100644 --- a/packages/local-runtime-v2/src/services.test.ts +++ b/packages/local-runtime-v2/src/services.test.ts @@ -8,9 +8,10 @@ import { createAgentRuntime, type AgentExtension } from "@mavis/agent-runtime"; import { baseCtx } from "../../agent-runtime/src/testing/fixtures.js"; import type { RuntimeTool } from "@mavis/agent-core/tools"; import { LocalSkillToolDef } from "@mavis/agent-tools/desktop"; -import type { - DatabaseLike, - QuestionnaireOwnedActionHandler, +import { + resolveRuntimeOwnerPolicy, + type DatabaseLike, + type QuestionnaireOwnedActionHandler, } from "@mavis/local-runtime"; import type { GlobalEvent, @@ -1555,18 +1556,33 @@ describe("runtime services composition", () => { eventBus: new EventBus(), compatibility: defaultCompatibility(), agentService: localAgentService, + ownerPolicy: resolveRuntimeOwnerPolicy({ + kind: "webui", + cliEmbedded: false, + electronHost: false, + capabilityProfile: undefined, + }), miniApp: {}, }); expect(services.miniApp).toBe(mocked.miniAppSupervisor); assertProcessLocalMiniAppComposition(); expect(mocked.miniAppOptions).toMatchObject({ + ownerClient: "webui", db: {}, connectorGateway: mocked.hostConnectorGateway, verifyCandidate: expect.any(Function), materializePackage: expect.any(Function), resolvePluginDataDir: expect.any(Function), }); + // The child IPC connector registers its arena against this same gateway, so the Supervisor + // must receive the instance the runtime created rather than a second one. + expect(mocked.miniAppOptions?.["agentGateway"]).toMatchObject({ + registerArena: expect.any(Function), + run: expect.any(Function), + reply: expect.any(Function), + }); + expect(mocked.events).toContain("plugin:attach-miniapp"); expect(mocked.events.indexOf("plugin:attach-miniapp")).toBeLessThan( mocked.events.indexOf("turn:create"), diff --git a/packages/local-runtime-v2/src/services.ts b/packages/local-runtime-v2/src/services.ts index c3365c01c..51d7a138d 100644 --- a/packages/local-runtime-v2/src/services.ts +++ b/packages/local-runtime-v2/src/services.ts @@ -140,7 +140,10 @@ import { import { composeMiniAppRuntimeCapability, createDeferredRuntimeOwnersLifecycle, + createMiniAppAgentGateway, + createMiniAppAgentSessionPorts, resolveRuntimeMiniAppOptions, + type BindableMiniAppAgentGateway, type RuntimeMiniAppServiceOptions, type RuntimeMiniAppServices, } from "./service/miniapp/index.js"; @@ -202,6 +205,7 @@ import { requireAgentRuntime, requireTurnSystem, type AgentHostRuntimeLifecycle, + type LocalTurnMiniAppApprovalRouter, type ToolResultCompactionConfig, type TurnSystemOwner, type SystemPromptOverrides, @@ -391,6 +395,8 @@ interface InitializedRuntimeServiceOwners { readonly cron: InitializedCronService | undefined; readonly plugin: ReturnType; readonly miniAppComposition: RuntimeMiniAppComposition; + /** Mini App Agent admission; absent when the Mini App service is off for this owner. */ + readonly miniAppAgent: BindableMiniAppAgentGateway | undefined; readonly mcp: InitializedMcpService; readonly agentApplication: AgentApplication; readonly runtimeConversation: RuntimeConversation; @@ -480,7 +486,11 @@ export async function createRuntimeServices( ), inspector, internalTurnPromptReads, - miniappAvailable: resolveRuntimeMiniAppOptions(options) !== undefined, + miniappAvailable: + resolveRuntimeMiniAppOptions({ + ...options, + miniAppService: options.ownerPolicy?.wiring.miniAppService, + }) !== undefined, browserUse: browserUseComposition.service, promptSnapshots, globalInstructions, @@ -505,6 +515,7 @@ export async function createRuntimeServices( processStartedAtMs, product, preparation, + modelProviderApplication, sessionSystem, pinService, canvas, @@ -547,6 +558,8 @@ export async function createRuntimeServices( plugins: owners.plugin.plugin, pluginControl: owners.plugin, miniApp: owners.miniAppComposition.services.miniApp, + // The composed facade carries the client-facing agent operations, never the gateway itself. + miniAppAgent: owners.miniAppAgent, planEntryEnabled, sessionReports: sessionSystem.reporting, instructions: { @@ -669,6 +682,12 @@ async function initializeRuntimeServiceOwners( input: RuntimeServiceOwnerInitializationInput, ): Promise { const pluginLogger = resolvePluginServiceLogger(input.options.logger); + // Resolved once: the supervisor and its agent gateway read the same availability answer. + const miniAppOptions = resolveRuntimeMiniAppOptions({ + ...input.options, + miniAppService: input.options.ownerPolicy?.wiring.miniAppService, + }); + const miniAppAgent = miniAppOptions ? createRuntimeMiniAppAgent(input, pluginLogger) : undefined; let plugin: ReturnType | undefined; let mcp: InitializedMcpService | undefined; let turnSystem: TurnSystemOwner | undefined; @@ -699,11 +718,19 @@ async function initializeRuntimeServiceOwners( ); bindTaskAgentBindingCapture(input, { plugin, mcp }, pluginLogger); miniAppComposition = await composeMiniAppRuntimeCapability({ - options: resolveRuntimeMiniAppOptions(input.options), + options: miniAppOptions + ? { + ...miniAppOptions, + // Honor an explicit Mini App option; otherwise report the runtime + // owner that actually holds the process lease (WebUI, Electron, TUI). + ownerClient: miniAppOptions.ownerClient ?? input.options.ownerPolicy.kind, + } + : undefined, db: input.options.db, dataDir: input.options.dataDir, nowMs: input.nowMs, plugin, + ...(miniAppAgent ? { agentGateway: miniAppAgent } : {}), readyOwners: input.readyOwners, closeOwners: input.closeOwners, }); @@ -767,6 +794,7 @@ async function initializeRuntimeServiceOwners( disposeMcpSession: (sessionId) => mcpRuntime.disposeSession(sessionId), goalVerifierExtension: subagentVerification.extension, goalVerifierOutputTokenCap: subagentVerification.outputTokenCap, + ...(miniAppAgent ? { miniAppApprovals: miniAppAgent } : {}), }); const sessionApplications = await initializeSessionApplications( input, @@ -774,6 +802,9 @@ async function initializeRuntimeServiceOwners( plugin, (sessionId) => cron?.isSessionOwnedByDefinition(sessionId) ?? false, ); + // Ports last: they read the session applications the turn owner just built. + if (miniAppAgent) + bindMiniAppAgentSessions(miniAppAgent, input, turnSystem, sessionApplications); cron = input.enableCron ? initializeRuntimeCron( input, @@ -815,6 +846,7 @@ async function initializeRuntimeServiceOwners( cron, plugin, miniAppComposition, + miniAppAgent, mcp, agentApplication: sessionApplications.agentApplication, runtimeConversation: sessionApplications.runtimeConversation, @@ -835,6 +867,65 @@ async function initializeRuntimeServiceOwners( } } +/** Created before the turn system: its permission gate consults the gateway's listeners. */ +function createRuntimeMiniAppAgent( + input: RuntimeServiceOwnerInitializationInput, + logger: ReturnType, +): BindableMiniAppAgentGateway { + return createMiniAppAgentGateway({ dataDir: input.options.dataDir, logger, nowMs: input.nowMs }); +} + +/** + * Binds the gateway to this runtime's own records, turns and questionnaire/permission owners. + * + * Two shapes differ from the shipped wiring: the questionnaire compat entry is a factory + * (`createService()`), and a pending questionnaire is a record whose `.request` is the wire payload. + * Both adapters stay here in composition, so the ports keep their seam-level contract. + */ +function bindMiniAppAgentSessions( + gateway: BindableMiniAppAgentGateway, + input: RuntimeServiceOwnerInitializationInput, + turnSystem: TurnSystemOwner, + sessionApplications: Awaited>, +): void { + const questionnaires = input.options.compatibility.questionnaires.createService(); + const permissions = input.options.compatibility.peripherals.permissions; + gateway.bind( + createMiniAppAgentSessionPorts({ + records: input.sessionSystem.sessions.records, + lifecycle: sessionApplications.sessionApplications.session.lifecycle, + repository: input.sessionSystem.sessions.repository, + turns: turnSystem.turns, + stream: input.sessionSystem.stream, + messages: input.sessionSystem.messages.repository, + questionnaires: { + getPending: async ({ agentName, sessionId }) => + (await questionnaires.getPending({ agentName, sessionId }))?.request, + reply: ({ agentName, requestId, answers, submittedAt }) => + questionnaires.reply({ + agentName, + requestId, + reply: { schemaVersion: 2, requestId, answers: [...answers], submittedAt }, + }), + }, + permissions: { + listPending: async () => + (await permissions.listPending()).map(({ requestId, sessionId }) => ({ + requestId, + sessionId, + })), + reply: ({ requestIds, decision }) => permissions.reply({ requestIds, decision }), + }, + eventBus: input.options.eventBus, + logger: resolvePluginServiceLogger(input.options.logger), + modelConfig: input.product.preparation.configBuilder.config, + models: { list: () => input.modelProviderApplication.list() }, + agents: sessionApplications.agentApplication, + nowMs: input.nowMs, + }), + ); +} + type PromptBoundRuntimeServiceOwnerInput = Omit< RuntimeServiceOwnerInitializationInput, "channelSystem" | "messageDelivery" | "turnLifecycleEvents" @@ -889,6 +980,8 @@ interface RuntimeServiceOwnerInitializationInput readonly product: ProductionSessionComposition["product"]; readonly preparation: ProductionSessionComposition["preparation"]; readonly sessionSystem: SessionSystemOwner; + /** Read lazily by the Mini App Agent ports, so a later model-catalog refresh applies. */ + readonly modelProviderApplication: ModelProviderApplication; readonly pinService: PinService; readonly archiveTitleModel: ProductionSessionComposition["archiveTitleModel"]; readonly facts: ProductionSessionComposition["facts"]; @@ -921,6 +1014,8 @@ interface RuntimeTurnSystemInitializationDeps { readonly goalVerifierExtension: GoalSubagentVerifierRuntime["extension"]; /** Host-clamped attempt cap the verifier child's provider requests must obey. */ readonly goalVerifierOutputTokenCap: GoalSubagentVerifierRuntime["outputTokenCap"]; + /** Mini App approval routing; absent when this runtime has no Mini App service. */ + readonly miniAppApprovals?: LocalTurnMiniAppApprovalRouter; } async function initializeRuntimeTurnSystem( @@ -934,6 +1029,7 @@ async function initializeRuntimeTurnSystem( resumeSessionDeletion, goalVerifierExtension, goalVerifierOutputTokenCap, + miniAppApprovals, } = dependencies; const llmRetryObserver = createSessionLLMRetryEventObserver( input.writeGlobalEvent, @@ -985,6 +1081,7 @@ async function initializeRuntimeTurnSystem( turnFacts, pluginHookSessionOwnership, turnCapabilities: input.turnCapabilities, + ...(miniAppApprovals ? { miniAppApprovals } : {}), // `sessionTitle.enabled: false` disables title generation: wire a // no-op so no title model request is issued. Read from the injected // config source (same as beta/custom_provider above), default on. diff --git a/packages/local-runtime-v2/test/helpers/miniapp-host-runner-child.ts b/packages/local-runtime-v2/test/helpers/miniapp-host-runner-child.ts new file mode 100644 index 000000000..3ecd5f9d8 --- /dev/null +++ b/packages/local-runtime-v2/test/helpers/miniapp-host-runner-child.ts @@ -0,0 +1,11 @@ +/** + * Child entry point for the runner's real-process exit cases. + * + * This file is executed as a child process (`tsx `) by + * `test/unit/miniapp/host-runner.test.ts`; it is deliberately not a Vitest suite, so it lives with + * the package's test helpers rather than under a suite path. It mirrors the shipped package's harness of the same + * path: start the real runner and let it own the parent IPC channel. + */ +import { runMiniAppHostRunner } from '../../src/service/miniapp/process/node-runtime/host-runner.js'; + +runMiniAppHostRunner(); diff --git a/packages/local-runtime-v2/test/integration/miniapp-purpose-reservation.integration.test.ts b/packages/local-runtime-v2/test/integration/miniapp-purpose-reservation.integration.test.ts new file mode 100644 index 000000000..28fc6002d --- /dev/null +++ b/packages/local-runtime-v2/test/integration/miniapp-purpose-reservation.integration.test.ts @@ -0,0 +1,116 @@ +/** + * Mini App ownership is a server-side fact, not something a caller can declare. + * + * A Mini App session is identified by its `purpose` label, and that label is what routes permission + * asks back to the plugin that owns them. The application session-creation request also accepts a + * `purpose`, so without a guard any caller could mint a session that the runtime then treats as + * plugin-owned — claiming another plugin's approval routing. Creating such a session must be refused; + * the runtime's own session ports write the label through the internal path instead. + * + * Real host, real session lifecycle; only the model runner is unused because no turn runs here. + */ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { CreatedLocalRuntimeHost, LocalRuntimeConfig } from '@mavis/local-runtime'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { createLocalRuntimeHostV2ForTest } from '../../src/runtime.js'; + +const hosts: CreatedLocalRuntimeHost[] = []; +const dataDirs: string[] = []; + +afterEach(async () => { + for (const host of hosts.splice(0)) await host.apiHost.close().catch(() => undefined); + for (const dir of dataDirs.splice(0)) await rm(dir, { recursive: true, force: true }); +}); + +async function bootHost(): Promise { + const dataDir = await mkdtemp(join(tmpdir(), 'miniapp-purpose-')); + dataDirs.push(dataDir); + await mkdir(join(dataDir, 'workspace'), { recursive: true }); + const config: LocalRuntimeConfig = { dataDir }; + const host = await createLocalRuntimeHostV2ForTest({ + dataDir, + runtimeOwnerKind: 'webui', + capabilities: { cliEmbedded: true }, + configGetter: () => config, + defaultWorkspaceDir: join(dataDir, 'workspace'), + fetchImpl: async () => Response.json({ errorCode: 0, action: 1 }), + }); + hosts.push(host); + await host.ready; + return host; +} + +describe('Mini App session purpose reservation', () => { + it( + 'refuses a caller-declared Mini App ownership label', + async () => { + const host = await bootHost(); + const conversation = host.apiHost.runtimeConversation!; + + // Reserved for runtime-owned Sessions; a caller-supplied label would be an ownership claim. + await expect( + conversation.lifecycle.createSession({ + agentName: 'mavis', + workspaceDir: join(host.dataDir, 'workspace'), + sessionType: 'root', + purpose: 'miniapp:spoofed.plugin', + }), + ).rejects.toMatchObject({ + reason: 'purpose-reserved', + message: expect.stringMatching(/reserved for runtime-owned/i), + }); + + // A reserved label with surrounding whitespace is the same claim. + await expect( + conversation.lifecycle.createSession({ + agentName: 'mavis', + workspaceDir: join(host.dataDir, 'workspace'), + sessionType: 'root', + purpose: ' miniapp:spoofed.plugin', + }), + ).rejects.toMatchObject({ reason: 'purpose-reserved' }); + }, + 120_000, + ); + + it( + 'refuses the same label on the non-root path that reuses internal session creation', + async () => { + const host = await bootHost(); + const conversation = host.apiHost.runtimeConversation!; + + // This is the branch that used to hand a caller's `purpose` to `createInternalSession`. + await expect( + conversation.lifecycle.createSession({ + agentName: 'mavis', + sessionType: 'branch', + purpose: 'miniapp:spoofed.plugin', + }), + ).rejects.toMatchObject({ + reason: 'purpose-reserved', + message: expect.stringMatching(/reserved for runtime-owned/i), + }); + }, + 120_000, + ); + + it( + 'still creates an ordinary session without a purpose', + async () => { + const host = await bootHost(); + const conversation = host.apiHost.runtimeConversation!; + + const plain = await conversation.lifecycle.createSession({ + agentName: 'mavis', + workspaceDir: join(host.dataDir, 'workspace'), + sessionType: 'root', + }); + expect(plain.sessionId).toBeTruthy(); + }, + 120_000, + ); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/cli-service-miniapp.test.ts b/packages/local-runtime-v2/test/unit/miniapp/cli-service-miniapp.test.ts new file mode 100644 index 000000000..5ce2d129f --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/cli-service-miniapp.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CliService, type CliServiceOptions } from '../../../src/local/cli-service.js'; + +/** + * The service is a pure forwarder here: it must not grow Mini App lifecycle logic of its own, and the + * only thing worth pinning is that it reaches the application and does not swallow the launch object. + */ +function serviceWith(miniApps: unknown): CliService { + return new CliService({ + applications: {}, + application: { miniApps }, + conversation: {}, + management: {}, + } as unknown as CliServiceOptions); +} + +describe('CliService Mini App forwarding', () => { + it('lists through the application', async () => { + const list = vi.fn(async () => ({ miniApps: [] })); + + await expect(serviceWith({ list }).listMiniApps()).resolves.toEqual({ miniApps: [] }); + expect(list).toHaveBeenCalledTimes(1); + }); + + it('opens by plugin and returns the launch coordinates untouched', async () => { + const launch = { launch: { origin: 'http://127.0.0.1:49999', surfacePath: '/dashboard', runId: 'run' } }; + const open = vi.fn(async () => launch); + + await expect(serviceWith({ open }).openMiniApp({ pluginId: 'fixture.miniapp' })).resolves.toBe( + launch, + ); + expect(open).toHaveBeenCalledWith({ pluginId: 'fixture.miniapp' }); + }); + + it('stops by plugin', async () => { + const stop = vi.fn(async () => undefined); + + await serviceWith({ stop }).stopMiniApp({ pluginId: 'fixture.miniapp' }); + expect(stop).toHaveBeenCalledWith({ pluginId: 'fixture.miniapp' }); + }); + + it('fails loudly when the host has no Mini App capability', () => { + expect(() => serviceWith(undefined).listMiniApps()).toThrow(); + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/host-connector.test.ts b/packages/local-runtime-v2/test/unit/miniapp/host-connector.test.ts new file mode 100644 index 000000000..a600a6377 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/host-connector.test.ts @@ -0,0 +1,3454 @@ +import { EventEmitter } from 'node:events'; + +import { describe, expect, it, vi } from 'vitest'; + +import type { GlobalEvent } from '@mavis/shared/global-events'; +import type { AskQuestionnaireRequest } from '@mavis/shared/questionnaire'; + +import type { + ConnectorToolHandle, + HostProcessConnectorCloudClient, + HostProcessConnectorGateway, +} from '../../../src/service/host-connector-system/index.js'; +import { ConnectorGatewayError } from '../../../src/service/host-connector-system/index.js'; +import { HostConnectorGateway } from '../../../src/service/host-connector-system/gateway.js'; +import type { SessionFrame } from '../../../src/service/session-system/index.js'; +import { + LocalTurnPermissionGate, + type LocalTurnPermissionApprovalOwner, +} from '../../../src/service/turn-system/agent-host/runner/policy/local-turn-permission-gate.js'; +import { + MiniAppAgentError, + type MiniAppAgentGateway, + type MiniAppAgentRunEvent, + type MiniAppAgentRunOptions, + type MiniAppAgentRunResult, +} from '../../../src/service/miniapp/agent/contracts.js'; +import { createMiniAppAgentGateway } from '../../../src/service/miniapp/agent/gateway.js'; +import { + createMiniAppAgentSessionPorts, + type MiniAppAgentSessionPortsOptions, +} from '../../../src/service/miniapp/agent/session-ports.js'; +import type { + MiniAppAgentPolicy, + MiniAppIpcMessage, +} from '../../../src/service/miniapp/contracts.js'; +import { + createMiniAppHostConnectorRuntimeClient, + createMiniAppHostConnectorSessionFactory, + hasHostConnectorRequestCapacity, + RESPONSE_MAX_BYTES, +} from '../../../src/service/miniapp/process/host-connector/host-connector.js'; + +const NODE_PENDING_LIMIT = 16; +const NODE_REPLY_PENDING_LIMIT = 16; + +type HostGatewayFixture = Omit & + Partial>; + +class MemoryIpc extends EventEmitter { + peer?: MemoryIpc; + connected = true; + + send(message: MiniAppIpcMessage, callback: (error: Error | null) => void): boolean { + const copy = structuredClone(message); + queueMicrotask(() => { + this.peer?.emit('message', copy); + callback(null); + }); + return true; + } + + receive(payload: unknown): void { + this.emit('message', { type: 'miniapp:connector', payload }); + } +} + +function bridge( + gateway: HostGatewayFixture, + options: { + readonly requestTimeoutMs?: number; + readonly startHandshake?: boolean; + readonly makeToolRef?: () => string; + readonly makeInvocationId?: () => string; + readonly graceTimeoutMs?: number; + readonly settleTimeoutMs?: number; + readonly agentGateway?: MiniAppAgentGateway; + readonly providers?: readonly string[]; + readonly agentPolicy?: MiniAppAgentPolicy; + readonly logger?: { warn(fields: Record, message: string): void }; + } = {}, +) { + const { + startHandshake = true, + providers = ['weather'], + agentPolicy, + ...factoryOptions + } = options; + const hostRequests = new MemoryIpc(); + const childResponses = new MemoryIpc(); + childResponses.peer = hostRequests; + const hostResponses = hostRequests; + hostResponses.peer = childResponses; + const fatal = vi.fn(); + const registerHostProcess = gateway.registerHostProcess?.bind(gateway) ?? vi.fn(); + const registeredGateway: HostProcessConnectorGateway = gateway.registerHostProcess + ? (gateway as HostProcessConnectorGateway) + : { ...gateway, registerHostProcess }; + const session = createMiniAppHostConnectorSessionFactory({ + gateway: registeredGateway, + makeToolRef: factoryOptions.makeToolRef ?? (() => 'tool-ref-1'), + makeInvocationId: factoryOptions.makeInvocationId ?? (() => 'invocation-real-1'), + graceTimeoutMs: factoryOptions.graceTimeoutMs ?? 5, + settleTimeoutMs: factoryOptions.settleTimeoutMs ?? 5, + ...factoryOptions, + }).create({ + pluginId: 'weather-board', + processGeneration: 'process-1', + providers, + ...(agentPolicy ? { agentPolicy } : {}), + transport: hostRequests, + onFatal: fatal, + }); + const runtime = createMiniAppHostConnectorRuntimeClient({ + transport: childResponses, + }); + if (startHandshake) session.startHandshake(); + return { + childResponses, + fatal, + hostRequests, + hostResponses, + registerHostProcess, + runtime, + session, + }; +} + +async function expectPendingAfterEventLoopTurn(operation: Promise): Promise { + await expect( + Promise.race([ + operation.then( + () => 'settled', + () => 'settled', + ), + new Promise<'pending'>((resolve) => setImmediate(() => resolve('pending'))), + ]), + ).resolves.toBe('pending'); +} + +async function expectSynchronousGatewayFailureBeforePromise(): Promise { + const handle = Object.freeze({}) as ConnectorToolHandle; + const synchronous = bridge({ + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }), + call: vi.fn(() => { + throw new Error('gateway call failed before returning a Promise'); + }), + releaseHostProcess: vi.fn(), + }); + const tool = (await synchronous.runtime.client.list()).tools[0]; + if (!tool) throw new Error('Expected synchronous failure inventory tool'); + synchronous.session.activate(); + await expect(synchronous.runtime.client.call(tool.toolRef, {})).rejects.toMatchObject({ + code: 'CONNECTOR_UNAVAILABLE', + disposition: 'not_dispatched', + retryable: true, + invocationId: 'invocation-real-1', + }); + synchronous.runtime.close(); + await synchronous.session.close(); +} + +function invalidArgumentsResponse(overrides: Record = {}): unknown { + return { + v: 1, + id: '1', + ok: false, + error: { + code: 'INVALID_ARGUMENTS', + message: 'Connector arguments are invalid', + disposition: 'not_dispatched', + retryable: false, + ...overrides, + }, + }; +} + +function malformedDiagnosticResponses(): readonly unknown[] { + const response = (diagnostic: unknown, overrides: Record = {}) => + invalidArgumentsResponse({ diagnostic, ...overrides }); + const issue = { path: '/page_size', constraint: 'maximum', limit: 100 }; + return [ + response({}), + response({ issues: [], extra: true }), + response({ issues: [] }), + response({ issues: Array.from({ length: 5 }, () => issue) }), + response({ issues: [{ ...issue, value: 'secret' }] }), + response({ issues: [{ constraint: 'maximum' }] }), + response({ issues: [{ ...issue, path: 'page_size' }] }), + response({ issues: [{ ...issue, path: '/page\nsize' }] }), + response({ issues: [{ ...issue, path: '/page~2size' }] }), + response({ issues: [{ ...issue, path: `/${'界'.repeat(86)}` }] }), + response({ issues: [{ path: '/page_size', constraint: '' }] }), + response({ issues: [{ path: '/page_size', constraint: '最大值' }] }), + response({ issues: [{ path: '/page_size', constraint: 'x'.repeat(65) }] }), + response({ issues: [{ ...issue, limit: '100' }] }), + response( + { issues: [issue] }, + { code: 'CONNECTOR_PROVIDER_ERROR', disposition: 'provider_reported' }, + ), + ]; +} + +function malformedInvalidArgumentsResponses(): readonly unknown[] { + return [ + invalidArgumentsResponse({ arguments: { pageSize: 100 } }), + invalidArgumentsResponse({ message: 'provider supplied message' }), + invalidArgumentsResponse({ disposition: 'provider_reported' }), + invalidArgumentsResponse({ retryable: true }), + ]; +} + +describe('Mini App Host Connector bridge', () => { + it('routes only connector responses and ignores lifecycle and log messages', async () => { + const input = new MemoryIpc(); + const runtime = createMiniAppHostConnectorRuntimeClient({ transport: input }); + const pending = runtime.client.list(); + input.emit('message', { type: 'miniapp:started' }); + input.emit('message', { type: 'miniapp:log', message: 'ignored' }); + await expectPendingAfterEventLoopTurn(pending); + input.receive({ v: 1, id: '1', ok: true, result: { tools: [], partial: false } }); + await expect(pending).resolves.toEqual({ tools: [], partial: false }); + runtime.close(); + }); + + it('fails the generation when a connector IPC envelope is malformed', async () => { + const gateway: HostGatewayFixture = { + list: vi.fn(), + call: vi.fn(), + releaseHostProcess: vi.fn(), + }; + const { fatal, hostRequests, registerHostProcess, runtime, session } = bridge(gateway); + + expect(registerHostProcess).toHaveBeenCalledOnce(); + expect(registerHostProcess).toHaveBeenCalledWith({ + pluginId: 'weather-board', + processGeneration: 'process-1', + }); + + hostRequests.emit('message', { type: 'miniapp:connector', unexpected: true }); + + expect(fatal).toHaveBeenCalledOnce(); + expect(gateway.releaseHostProcess).toHaveBeenCalledWith({ + pluginId: 'weather-board', + processGeneration: 'process-1', + }); + runtime.close(); + await session.close(); + }); + + it('allows candidate inventory, gates calls until activation, and maps auth epoch revocation', async () => { + const handle = Object.freeze({}) as ConnectorToolHandle; + let authChanged = false; + const call = vi + .fn() + .mockImplementation(async (input: Parameters[0]) => { + if (authChanged) { + throw new ConnectorGatewayError( + 'CONNECTOR_PRINCIPAL_CHANGED', + 'principal changed', + 'not_dispatched', + ); + } + expect(input.caller).toEqual({ + kind: 'host-process', + pluginId: 'weather-board', + processGeneration: 'process-1', + }); + return { resultJson: '{"temperature":21}' }; + }); + const releaseHostProcess = vi.fn(); + const gateway: HostGatewayFixture = { + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{"type":"object"}', + }, + ], + providerFailures: [], + partial: false, + }), + call, + releaseHostProcess, + }; + const { runtime, session } = bridge(gateway); + + const inventory = await runtime.client.list(); + expect(inventory).toMatchObject({ + partial: false, + tools: [{ toolRef: 'tool-ref-1', provider: 'weather', name: 'forecast' }], + }); + const tool = inventory.tools[0]; + if (!tool) throw new Error('Expected Host Connector inventory tool'); + await expect(runtime.client.call(tool.toolRef, {})).rejects.toMatchObject({ + code: 'CONNECTOR_UNAVAILABLE', + disposition: 'not_dispatched', + }); + expect(call).not.toHaveBeenCalled(); + + session.activate(); + await expect(runtime.client.call(tool.toolRef, {})).resolves.toEqual({ + invocationId: 'invocation-real-1', + value: { temperature: 21 }, + }); + expect(gateway.list).toHaveBeenCalledWith({ + providerAllowlist: ['weather'], + caller: { + kind: 'host-process', + pluginId: 'weather-board', + processGeneration: 'process-1', + }, + signal: expect.any(AbortSignal), + }); + expect(call).toHaveBeenCalledWith( + expect.objectContaining({ + hostRequest: { + clientRequestId: '3', + invocationId: 'invocation-real-1', + }, + }), + ); + authChanged = true; + await expect(runtime.client.call(tool.toolRef, {})).rejects.toMatchObject({ + code: 'TOOL_REF_STALE', + disposition: 'not_dispatched', + }); + + runtime.close(); + await session.close(); + expect(releaseHostProcess).toHaveBeenCalledWith({ + pluginId: 'weather-board', + processGeneration: 'process-1', + }); + }); + + it('reuses wire refs for surviving handles and isolates syntax-invalid tool schemas', async () => { + const stable = Object.freeze({}) as ConnectorToolHandle; + const broken = Object.freeze({}) as ConnectorToolHandle; + const gateway: HostGatewayFixture = { + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle: broken, + provider: 'weather', + providerToolName: 'broken', + runtimeToolName: 'weather_broken', + inputSchemaJson: '{broken', + }, + { + handle: stable, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: 'true', + outputSchemaJson: '["any-json"]', + }, + ], + providerFailures: [], + partial: false, + }), + call: vi.fn(), + releaseHostProcess: vi.fn(), + }; + const { runtime, session } = bridge(gateway); + + const first = await runtime.client.list(); + const second = await runtime.client.list(); + + expect(first).toEqual({ + tools: [ + expect.objectContaining({ + toolRef: 'tool-ref-1', + inputSchema: true, + outputSchema: ['any-json'], + }), + ], + partial: true, + }); + expect(second.tools[0]?.toolRef).toBe(first.tools[0]?.toolRef); + + runtime.close(); + await session.close(); + }); +}); + +describe('Mini App Host Connector stalled sends', () => { + it.each([false, true])( + 'bounds a cancelled IPC send and preserves the admission identity (receipt=%s)', + async (admitted) => { + vi.useFakeTimers(); + const input = new MemoryIpc(); + let completeSend: (error: Error | null) => void = () => undefined; + const write = vi.spyOn(input, 'send').mockImplementation((_message, callback) => { + completeSend = callback; + return false; + }); + const runtime = createMiniAppHostConnectorRuntimeClient({ transport: input }); + const controller = new AbortController(); + let outcome: unknown; + const pending = runtime.client + .call('opaque-tool' as never, {}, { signal: controller.signal }) + .catch((error: unknown) => { + outcome = error; + }); + try { + await vi.advanceTimersByTimeAsync(0); + expect(write).toHaveBeenCalledOnce(); + if (admitted) { + input.receive({ v: 1, id: '1', receipt: 'admitted', invocationId: 'real-invocation' }); + } + controller.abort(); + await vi.advanceTimersByTimeAsync(30_000); + expect(outcome).toMatchObject({ + code: 'CONNECTOR_OUTCOME_UNKNOWN', + disposition: 'unknown_after_dispatch', + retryable: false, + invocationId: admitted ? 'real-invocation' : '1', + }); + expect(write).toHaveBeenCalledOnce(); + expect(input.eventNames()).toEqual([]); + completeSend(null); + expect(vi.getTimerCount()).toBe(0); + } finally { + runtime.close(); + await pending; + vi.useRealTimers(); + } + }, + ); +}); + +describe('Mini App Host Connector transport bounds', () => { + it('waits for IPC backpressure and treats disconnect after send as an unknown outcome', async () => { + const input = new MemoryIpc(); + const write = vi.spyOn(input, 'send').mockReturnValueOnce(false); + const runtime = createMiniAppHostConnectorRuntimeClient({ transport: input }); + + const pending = runtime.client.call('opaque-tool' as never, {}); + await vi.waitFor(() => expect(write).toHaveBeenCalledOnce()); + input.emit('disconnect'); + + await expect(pending).rejects.toMatchObject({ + code: 'CONNECTOR_OUTCOME_UNKNOWN', + disposition: 'unknown_after_dispatch', + invocationId: '1', + }); + expect(input.listenerCount('disconnect')).toBe(0); + }); + + it('rejects over-budget child arguments without dispatching them', async () => { + const gateway: HostGatewayFixture = { + list: vi.fn(), + call: vi.fn(), + releaseHostProcess: vi.fn(), + }; + const { runtime, session } = bridge(gateway); + session.activate(); + + await expect( + runtime.client.call('tool-ref-1' as never, { value: 'x'.repeat(300 * 1024) }), + ).rejects.toMatchObject({ + name: 'HostConnectorError', + code: 'INVALID_ARGUMENTS', + disposition: 'not_dispatched', + }); + expect(gateway.call).not.toHaveBeenCalled(); + + runtime.close(); + await session.close(); + }); + + it('propagates child cancellation and bounds retirement of an uncooperative call', async () => { + const handle = Object.freeze({}) as ConnectorToolHandle; + let callSignal: AbortSignal | undefined; + let markStarted: () => void = () => undefined; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const gateway: HostGatewayFixture = { + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }), + call: vi + .fn() + .mockImplementation((input: Parameters[0]) => { + callSignal = input.signal; + markStarted(); + return new Promise(() => undefined); + }), + releaseHostProcess: vi.fn(), + }; + const { runtime, session } = bridge(gateway); + const inventory = await runtime.client.list(); + session.activate(); + const tool = inventory.tools[0]; + if (!tool) throw new Error('Expected Host Connector inventory tool'); + const pending = runtime.client.call(tool.toolRef, {}); + await started; + + await session.retire(); + expect(callSignal?.aborted).toBe(true); + runtime.close(); + await expect(pending).rejects.toMatchObject({ + code: 'CONNECTOR_OUTCOME_UNKNOWN', + disposition: 'unknown_after_dispatch', + }); + }); + + it('uses Gateway Promise acquisition, not invocation identity, as the dispatch boundary', async () => { + const handle = Object.freeze({}) as ConnectorToolHandle; + const gateway: HostGatewayFixture = { + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }), + call: vi + .fn() + .mockImplementation((input: Parameters[0]) => + rejectWithAbortReason(input.signal), + ), + releaseHostProcess: vi.fn(), + }; + const { runtime, session } = bridge(gateway, { requestTimeoutMs: 5 }); + const tool = (await runtime.client.list()).tools[0]; + if (!tool) throw new Error('Expected Host Connector inventory tool'); + session.activate(); + + await expect(runtime.client.call(tool.toolRef, {})).rejects.toMatchObject({ + code: 'CONNECTOR_OUTCOME_UNKNOWN', + disposition: 'unknown_after_dispatch', + retryable: false, + invocationId: 'invocation-real-1', + }); + + runtime.close(); + await session.close(); + }); + + it('reports an invalid provider result after admission as an unknown outcome', async () => { + const handle = Object.freeze({}) as ConnectorToolHandle; + const gateway: HostGatewayFixture = { + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }), + call: vi.fn().mockResolvedValue({ resultJson: '{invalid' }), + releaseHostProcess: vi.fn(), + }; + const { runtime, session } = bridge(gateway); + const tool = (await runtime.client.list()).tools[0]; + if (!tool) throw new Error('Expected Host Connector inventory tool'); + session.activate(); + + await expect(runtime.client.call(tool.toolRef, {})).rejects.toMatchObject({ + code: 'CONNECTOR_OUTCOME_UNKNOWN', + disposition: 'unknown_after_dispatch', + invocationId: 'invocation-real-1', + }); + + runtime.close(); + await session.close(); + }); + + it('reports disconnect after admission as unknown with the real invocation identity', async () => { + const handle = Object.freeze({}) as ConnectorToolHandle; + const call = vi.fn().mockReturnValue(new Promise(() => undefined)); + const gateway: HostGatewayFixture = { + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }), + call, + releaseHostProcess: vi.fn(), + }; + const { childResponses, runtime, session } = bridge(gateway); + const tool = (await runtime.client.list()).tools[0]; + if (!tool) throw new Error('Expected Host Connector inventory tool'); + session.activate(); + const pending = runtime.client.call(tool.toolRef, {}); + await vi.waitFor(() => expect(call).toHaveBeenCalledOnce()); + childResponses.emit('disconnect'); + + await expect(pending).rejects.toMatchObject({ + code: 'CONNECTOR_OUTCOME_UNKNOWN', + disposition: 'unknown_after_dispatch', + invocationId: 'invocation-real-1', + }); + + await session.close(); + }); +}); + +describe('Mini App Host Connector lifecycle guards', () => { + it('bounds concurrent child requests and drains every pending caller on close', async () => { + const input = new MemoryIpc(); + const runtime = createMiniAppHostConnectorRuntimeClient({ transport: input }); + const pending = Array.from({ length: 16 }, () => runtime.client.list()); + + await expect(runtime.client.list()).rejects.toMatchObject({ + code: 'CONNECTOR_UNAVAILABLE', + disposition: 'not_dispatched', + retryable: true, + }); + runtime.close(); + const settled = await Promise.allSettled(pending); + expect(settled).toHaveLength(16); + expect(settled.every((result) => result.status === 'rejected')).toBe(true); + }); + + it('fails and releases a generation whose output precedes the Host handshake', async () => { + const gateway: HostGatewayFixture = { + list: vi.fn(), + call: vi.fn(), + releaseHostProcess: vi.fn(), + }; + const { fatal, hostRequests, runtime, session } = bridge(gateway, { + startHandshake: false, + }); + + hostRequests.receive({ v: 1, id: '1', method: 'connector.list', params: {} }); + + expect(fatal).toHaveBeenCalledOnce(); + expect(gateway.releaseHostProcess).toHaveBeenCalledOnce(); + session.startHandshake(); + runtime.close(); + await session.close(); + }); + + it('keeps activation single-use and retirement idempotent', async () => { + const gateway: HostGatewayFixture = { + list: vi.fn(), + call: vi.fn(), + releaseHostProcess: vi.fn(), + }; + const { runtime, session } = bridge(gateway); + session.activate(); + + expect(() => session.activate()).toThrow('Host Connector cannot activate'); + const retiring = session.retire(); + expect(session.retire()).toBe(retiring); + await retiring; + session.startHandshake(); + runtime.close(); + await session.close(); + expect(gateway.releaseHostProcess).toHaveBeenCalledOnce(); + }); + + it('preserves a provider-reported call outcome with its real invocation identity', async () => { + const handle = Object.freeze({}) as ConnectorToolHandle; + const gateway: HostGatewayFixture = { + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }), + call: vi.fn().mockResolvedValue({ isError: true, resultJson: '{"ignored":true}' }), + releaseHostProcess: vi.fn(), + }; + const { runtime, session } = bridge(gateway); + const tool = (await runtime.client.list()).tools[0]; + if (!tool) throw new Error('Expected Host Connector inventory tool'); + session.activate(); + + await expect(runtime.client.call(tool.toolRef, {})).rejects.toMatchObject({ + code: 'CONNECTOR_PROVIDER_ERROR', + disposition: 'provider_reported', + invocationId: 'invocation-real-1', + }); + + runtime.close(); + await session.close(); + }); +}); + +describe('Mini App Host Connector error mapping', () => { + it('maps an unavailable gateway inventory without admitting a call', async () => { + const gateway: HostGatewayFixture = { + list: vi.fn().mockRejectedValue(new Error('gateway offline')), + call: vi.fn(), + releaseHostProcess: vi.fn(), + }; + const { runtime, session } = bridge(gateway); + + await expect(runtime.client.list()).rejects.toMatchObject({ + code: 'CONNECTOR_UNAVAILABLE', + disposition: 'not_dispatched', + retryable: false, + }); + + runtime.close(); + await session.close(); + }); + + it('maps a gateway abort after admission to the real invocation identity', async () => { + const handle = Object.freeze({}) as ConnectorToolHandle; + const gateway: HostGatewayFixture = { + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }), + call: vi + .fn() + .mockRejectedValue( + new ConnectorGatewayError('REQUEST_ABORTED', 'request aborted', 'not_dispatched'), + ), + releaseHostProcess: vi.fn(), + }; + const { runtime, session } = bridge(gateway); + const tool = (await runtime.client.list()).tools[0]; + if (!tool) throw new Error('Expected Host Connector inventory tool'); + session.activate(); + + await expect(runtime.client.call(tool.toolRef, {})).rejects.toMatchObject({ + code: 'REQUEST_CANCELLED', + disposition: 'not_dispatched', + invocationId: 'invocation-real-1', + }); + + runtime.close(); + await session.close(); + }); + + it('projects a bounded canonical argument diagnostic', async () => { + const handle = Object.freeze({}) as ConnectorToolHandle; + const invalidArguments = new ConnectorGatewayError( + 'CONNECTOR_ARGUMENTS_INVALID', + 'provider validation failed', + 'not_dispatched', + { + diagnostic: { + issues: [ + { path: '', constraint: 'type' }, + { path: '/分页/条目~1每页', constraint: 'maximum', limit: 100 }, + { path: '/filter~0raw', constraint: 'minLength', limit: 1 }, + { path: '/items/0/active', constraint: 'const' }, + { path: '/ignored', constraint: 'required' }, + ], + }, + }, + ); + const gateway: HostGatewayFixture = { + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }), + call: vi.fn().mockRejectedValue(invalidArguments), + releaseHostProcess: vi.fn(), + }; + const { runtime, session } = bridge(gateway); + const tool = (await runtime.client.list()).tools[0]; + if (!tool) throw new Error('Expected Host Connector inventory tool'); + session.activate(); + + try { + await runtime.client.call(tool.toolRef, {}); + throw new Error('Expected invalid arguments'); + } catch (error) { + expect(error).toMatchObject({ + code: 'INVALID_ARGUMENTS', + message: 'Connector arguments are invalid', + disposition: 'not_dispatched', + retryable: false, + invocationId: 'invocation-real-1', + diagnostic: { + issues: invalidArguments.diagnostic?.issues.slice(0, 4), + }, + }); + } + + runtime.close(); + await session.close(); + }); + + it('rejects an active call whose wire reference was never listed', async () => { + const gateway: HostGatewayFixture = { + list: vi.fn(), + call: vi.fn(), + releaseHostProcess: vi.fn(), + }; + const { runtime, session } = bridge(gateway); + session.activate(); + + await expect(runtime.client.call('unknown-ref' as never, {})).rejects.toMatchObject({ + code: 'TOOL_REF_STALE', + disposition: 'not_dispatched', + }); + expect(gateway.call).not.toHaveBeenCalled(); + + runtime.close(); + await session.close(); + }); + + it('fails the pending caller when a response result is malformed', async () => { + const input = new MemoryIpc(); + const onFatal = vi.fn(); + const runtime = createMiniAppHostConnectorRuntimeClient({ transport: input, onFatal }); + const pending = runtime.client.list(); + + input.receive({ v: 1, id: '1', ok: true, result: { tools: [] } }); + + await expect(pending).rejects.toMatchObject({ + code: 'CONNECTOR_UNAVAILABLE', + disposition: 'not_dispatched', + }); + expect(onFatal).toHaveBeenCalledOnce(); + runtime.close(); + }); +}); + +describe('Mini App Host Connector public protocol matrices', () => { + it('fails closed for malformed child responses and preserves cancellation disposition', async () => { + const malformedResponses = [ + null, + { v: 2, id: '1', ok: true, result: { tools: [], partial: false } }, + { v: 1, id: '', ok: true, result: { tools: [], partial: false } }, + { v: 1, id: '18446744073709551616', ok: true, result: { tools: [], partial: false } }, + { v: 1, id: '2', ok: true, result: { tools: [], partial: false } }, + { v: 1, id: '1', ok: true, result: { tools: [], partial: false }, extra: true }, + { v: 1, id: '1', ok: true, result: null }, + { v: 1, id: '1', ok: true, result: { tools: 'invalid', partial: false } }, + { v: 1, id: '1', ok: true, result: { tools: [], partial: 'false' } }, + { v: 1, id: '1', ok: false, error: null }, + { + v: 1, + id: '1', + ok: false, + error: { code: 1, message: 'bad', disposition: 'not_dispatched', retryable: false }, + }, + { + v: 1, + id: '1', + ok: false, + error: { code: 'BAD', message: 1, disposition: 'not_dispatched', retryable: false }, + }, + { + v: 1, + id: '1', + ok: false, + error: { code: 'BAD', message: 'bad', disposition: 'invalid', retryable: false }, + }, + { + v: 1, + id: '1', + ok: false, + error: { code: 'BAD', message: 'bad', disposition: 'not_dispatched', retryable: 'no' }, + }, + ...malformedInvalidArgumentsResponses(), + ...malformedDiagnosticResponses(), + ]; + for (const response of malformedResponses) { + const input = new MemoryIpc(); + const onFatal = vi.fn(); + const runtime = createMiniAppHostConnectorRuntimeClient({ transport: input, onFatal }); + const pending = runtime.client.list(); + input.receive(response); + await expect(pending).rejects.toMatchObject({ code: 'CONNECTOR_UNAVAILABLE' }); + expect(onFatal).toHaveBeenCalledOnce(); + runtime.close(); + } + + for (const invalidReceipt of [ + { v: 1, id: '1', receipt: 'admitted', invocationId: '' }, + { v: 1, id: '1', receipt: 'admitted', invocationId: 'inv-1', extra: true }, + ]) { + const input = new MemoryIpc(); + const onFatal = vi.fn(); + const write = vi.spyOn(input, 'send'); + const runtime = createMiniAppHostConnectorRuntimeClient({ transport: input, onFatal }); + const pending = runtime.client.call('tool-ref' as never, {}); + await vi.waitFor(() => expect(write).toHaveBeenCalledOnce()); + input.receive(invalidReceipt); + await expect(pending).rejects.toMatchObject({ code: 'CONNECTOR_OUTCOME_UNKNOWN' }); + expect(onFatal).toHaveBeenCalledOnce(); + } + + const mismatchedInput = new MemoryIpc(); + const mismatchedWrite = vi.spyOn(mismatchedInput, 'send'); + const mismatched = createMiniAppHostConnectorRuntimeClient({ + transport: mismatchedInput, + }); + const mismatchedCall = mismatched.client.call('tool-ref' as never, {}); + await vi.waitFor(() => expect(mismatchedWrite).toHaveBeenCalledOnce()); + mismatchedInput.receive({ v: 1, id: '1', receipt: 'admitted', invocationId: 'inv-1' }); + mismatchedInput.receive({ + v: 1, + id: '1', + ok: true, + result: { invocationId: 'inv-2', value: null }, + }); + await expect(mismatchedCall).rejects.toMatchObject({ + code: 'CONNECTOR_OUTCOME_UNKNOWN', + invocationId: 'inv-1', + }); + + const preAborted = new AbortController(); + preAborted.abort(new Error('cancelled before call')); + const abortRuntime = createMiniAppHostConnectorRuntimeClient({ + transport: new MemoryIpc(), + }); + await expect(abortRuntime.client.list({ signal: preAborted.signal })).rejects.toMatchObject({ + code: 'REQUEST_CANCELLED', + disposition: 'not_dispatched', + }); + await expect(abortRuntime.client.call('' as never, {})).rejects.toMatchObject({ + code: 'INVALID_ARGUMENTS', + }); + await expect(abortRuntime.client.call('tool-ref' as never, [] as never)).rejects.toMatchObject({ + code: 'INVALID_ARGUMENTS', + }); + + const beforeWriteController = new AbortController(); + const beforeWrite = abortRuntime.client.list({ signal: beforeWriteController.signal }); + beforeWriteController.abort(new Error('cancelled before write')); + await expect(beforeWrite).rejects.toMatchObject({ code: 'REQUEST_CANCELLED' }); + + const afterWriteInput = new MemoryIpc(); + const write = vi.spyOn(afterWriteInput, 'send'); + const afterWriteRuntime = createMiniAppHostConnectorRuntimeClient({ + transport: afterWriteInput, + }); + const afterWriteController = new AbortController(); + const afterWrite = afterWriteRuntime.client.call( + 'tool-ref' as never, + {}, + { + signal: afterWriteController.signal, + }, + ); + await vi.waitFor(() => expect(write).toHaveBeenCalledOnce()); + afterWriteController.abort(new Error('cancelled after write')); + await vi.waitFor(() => expect(write).toHaveBeenCalledTimes(2)); + afterWriteInput.emit('error', new Error('transport ended')); + await expect(afterWrite).rejects.toMatchObject({ code: 'CONNECTOR_OUTCOME_UNKNOWN' }); + + const disconnectedInput = new MemoryIpc(); + const disconnectedRuntime = createMiniAppHostConnectorRuntimeClient({ + transport: disconnectedInput, + }); + const disconnected = disconnectedRuntime.client.list(); + disconnectedInput.emit('disconnect'); + await expect(disconnected).rejects.toMatchObject({ code: 'CONNECTOR_UNAVAILABLE' }); + + abortRuntime.close(); + abortRuntime.close(); + await expect(abortRuntime.client.list()).rejects.toMatchObject({ + code: 'CONNECTOR_UNAVAILABLE', + }); + }); + + it('rejects a non-finite diagnostic limit', async () => { + const input = new MemoryIpc(); + const onFatal = vi.fn(); + const runtime = createMiniAppHostConnectorRuntimeClient({ transport: input, onFatal }); + const pending = runtime.client.list(); + input.receive( + invalidArgumentsResponse({ + diagnostic: { issues: [{ path: '/page_size', constraint: 'maximum', limit: Infinity }] }, + }), + ); + await expect(pending).rejects.toMatchObject({ code: 'CONNECTOR_UNAVAILABLE' }); + expect(onFatal).toHaveBeenCalledOnce(); + runtime.close(); + }); +}); + +describe('Mini App Host Connector Host protocol matrices', () => { + it('enforces Host request envelopes, ordering, cancellation ids, and IPC disconnection', async () => { + const invalidRequests = [ + null, + { v: 2, id: '1', method: 'connector.list', params: {} }, + { v: 1, id: '0', method: 'connector.list', params: {} }, + { v: 1, id: '18446744073709551616', method: 'connector.list', params: {} }, + { v: 1, id: '1', method: 'connector.cancel', extra: true }, + { v: 1, id: '1', method: 'unknown', params: {} }, + { v: 1, id: '1', method: 'connector.list' }, + { v: 1, id: '1', method: 'connector.list', params: [] }, + { v: 1, id: '1', method: 'connector.list', params: { extra: true } }, + { v: 1, id: '1', method: 'connector.call', params: {} }, + { v: 1, id: '1', method: 'connector.call', params: { toolRef: '', arguments: {} } }, + { v: 1, id: '1', method: 'connector.call', params: { toolRef: 'ref', arguments: [] } }, + ]; + for (const request of invalidRequests) { + const gateway: HostGatewayFixture = { + list: vi.fn(), + call: vi.fn(), + releaseHostProcess: vi.fn(), + }; + const { fatal, hostRequests, runtime, session } = bridge(gateway); + hostRequests.receive(request); + await vi.waitFor(() => expect(fatal).toHaveBeenCalledOnce()); + expect(gateway.releaseHostProcess).toHaveBeenCalledOnce(); + runtime.close(); + await session.close(); + } + + const pendingGateway: HostGatewayFixture = { + list: vi.fn().mockReturnValue(new Promise(() => undefined)), + call: vi.fn(), + releaseHostProcess: vi.fn(), + }; + const ordered = bridge(pendingGateway, { requestTimeoutMs: 1_000 }); + ordered.hostRequests.receive({ v: 1, id: '2', method: 'connector.list', params: {} }); + ordered.hostRequests.receive({ v: 1, id: '1', method: 'connector.list', params: {} }); + await vi.waitFor(() => expect(ordered.fatal).toHaveBeenCalledOnce()); + ordered.runtime.close(); + await ordered.session.close(); + + const future = bridge({ + list: vi.fn(), + call: vi.fn(), + releaseHostProcess: vi.fn(), + }); + future.hostRequests.receive({ v: 1, id: '1', method: 'connector.cancel' }); + await vi.waitFor(() => expect(future.fatal).toHaveBeenCalledOnce()); + future.runtime.close(); + await future.session.close(); + + for (const terminal of ['disconnect', 'error'] as const) { + const current = bridge({ + list: vi.fn(), + call: vi.fn(), + releaseHostProcess: vi.fn(), + }); + if (terminal === 'error') current.hostRequests.emit('error', new Error('child failed')); + else current.hostRequests.emit('disconnect'); + await vi.waitFor(() => expect(current.fatal).toHaveBeenCalledOnce()); + current.runtime.close(); + await current.session.close(); + } + }); +}); + +describe('Mini App Host Connector inventory matrices', () => { + it('isolates inventory failures while preserving stable, collision-free opaque references', async () => { + const stable = Object.freeze({}) as ConnectorToolHandle; + const second = Object.freeze({}) as ConnectorToolHandle; + const third = Object.freeze({}) as ConnectorToolHandle; + const list = vi + .fn() + .mockResolvedValueOnce({ + tools: [ + { + handle: stable, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + description: 'Weather forecast', + inputSchemaJson: '{}', + outputSchemaJson: 'null', + }, + { + handle: second, + provider: 'weather', + providerToolName: 'oversized', + runtimeToolName: 'weather_oversized', + inputSchemaJson: `"${'x'.repeat(8 * 1024 * 1024)}"`, + }, + ], + providerFailures: [], + partial: true, + }) + .mockResolvedValueOnce({ + tools: [ + { + handle: stable, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + { + handle: stable, + provider: 'weather', + providerToolName: 'duplicate', + runtimeToolName: 'weather_duplicate', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }) + .mockResolvedValueOnce({ + tools: [ + { + handle: second, + provider: 'weather', + providerToolName: 'second', + runtimeToolName: 'weather_second', + inputSchemaJson: '{}', + }, + { + handle: third, + provider: 'weather', + providerToolName: 'third', + runtimeToolName: 'weather_third', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }); + const gateway: HostGatewayFixture = { + list, + call: vi.fn().mockResolvedValue({}), + releaseHostProcess: vi.fn(), + }; + const refs = ['', 'x'.repeat(257), 'stable-ref']; + const { runtime, session } = bridge(gateway, { + makeToolRef: () => refs.shift() ?? 'stable-ref', + }); + const first = await runtime.client.list(); + expect(first).toEqual({ + tools: [ + expect.objectContaining({ + toolRef: 'stable-ref', + description: 'Weather forecast', + outputSchema: null, + }), + ], + partial: true, + }); + const duplicate = await runtime.client.list(); + expect(duplicate.tools).toHaveLength(1); + expect(duplicate.partial).toBe(true); + const exhausted = await runtime.client.list(); + expect(exhausted.tools).toHaveLength(1); + expect(exhausted.partial).toBe(true); + + session.activate(); + const exhaustedTool = exhausted.tools[0]; + if (!exhaustedTool) throw new Error('Expected one surviving inventory tool'); + await expect(runtime.client.call(exhaustedTool.toolRef, {})).resolves.toEqual({ + invocationId: 'invocation-real-1', + value: null, + }); + runtime.close(); + await session.close(); + + const unexpected: HostGatewayFixture = { + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle: stable, + provider: 'weather', + providerToolName: 'broken', + runtimeToolName: 'weather_broken', + get inputSchemaJson(): string { + throw new Error('schema getter failed'); + }, + }, + ], + providerFailures: [], + partial: false, + }), + call: vi.fn(), + releaseHostProcess: vi.fn(), + }; + const failed = bridge(unexpected); + await expect(failed.runtime.client.list()).rejects.toMatchObject({ + code: 'CONNECTOR_UNAVAILABLE', + }); + failed.runtime.close(); + await failed.session.close(); + }); +}); + +describe('Mini App Host Connector request outcome matrices', () => { + it('maps timeouts, cancellation, typed gateway outcomes, draining, and response loss', async () => { + vi.useFakeTimers(); + try { + const timeout = bridge( + { + list: vi.fn().mockReturnValue(new Promise(() => undefined)), + call: vi.fn(), + releaseHostProcess: vi.fn(), + }, + { requestTimeoutMs: 20 }, + ); + const timedOut = timeout.runtime.client.list(); + const timeoutRejected = expect(timedOut).rejects.toMatchObject({ + code: 'CONNECTOR_TIMEOUT', + disposition: 'not_dispatched', + retryable: true, + }); + await vi.advanceTimersByTimeAsync(20); + await timeoutRejected; + timeout.runtime.close(); + await timeout.session.close(); + } finally { + vi.useRealTimers(); + } + + const outcomes = [ + { + error: new ConnectorGatewayError( + 'PROVIDER_TIMEOUT', + 'unknown', + 'unknown_after_dispatch', + 'invocation-real-1', + ), + code: 'CONNECTOR_OUTCOME_UNKNOWN', + disposition: 'unknown_after_dispatch', + }, + { + error: new ConnectorGatewayError('PROVIDER_ERROR', 'reported', 'provider_reported', { + invocationId: 'invocation-real-1', + diagnostic: { + issues: [{ path: '/page_size', constraint: 'maximum', limit: 100 }], + }, + }), + code: 'CONNECTOR_PROVIDER_ERROR', + disposition: 'provider_reported', + }, + { + error: new ConnectorGatewayError('OTHER', 'unavailable', 'not_dispatched'), + code: 'CONNECTOR_UNAVAILABLE', + disposition: 'not_dispatched', + }, + { + error: new Error('unexpected provider failure'), + code: 'CONNECTOR_OUTCOME_UNKNOWN', + disposition: 'unknown_after_dispatch', + }, + ] as const; + for (const outcome of outcomes) { + const handle = Object.freeze({}) as ConnectorToolHandle; + const current = bridge({ + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }), + call: vi.fn().mockRejectedValue(outcome.error), + releaseHostProcess: vi.fn(), + }); + const tool = (await current.runtime.client.list()).tools[0]; + if (!tool) throw new Error('Expected outcome matrix inventory tool'); + current.session.activate(); + const pendingCall = current.runtime.client.call(tool.toolRef, {}); + await expect(pendingCall).rejects.toMatchObject({ + code: outcome.code, + disposition: outcome.disposition, + invocationId: 'invocation-real-1', + }); + if (outcome.disposition === 'provider_reported') { + await expect(pendingCall).rejects.not.toHaveProperty('diagnostic'); + } + current.runtime.close(); + await current.session.close(); + } + + await expectSynchronousGatewayFailureBeforePromise(); + + let callSignal: AbortSignal | undefined; + const handle = Object.freeze({}) as ConnectorToolHandle; + const cancelled = bridge({ + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }), + call: vi.fn().mockImplementation(({ signal }) => { + callSignal = signal; + return rejectWithGatewayOutcomeOnAbort(signal, 'invocation-real-1'); + }), + releaseHostProcess: vi.fn(), + }); + const tool = (await cancelled.runtime.client.list()).tools[0]; + if (!tool) throw new Error('Expected cancellation matrix inventory tool'); + cancelled.session.activate(); + const controller = new AbortController(); + const pending = cancelled.runtime.client.call(tool.toolRef, {}, { signal: controller.signal }); + await vi.waitFor(() => expect(callSignal).toBeDefined()); + controller.abort(new Error('caller cancelled')); + await expect(pending).rejects.toMatchObject({ + code: 'CONNECTOR_OUTCOME_UNKNOWN', + disposition: 'unknown_after_dispatch', + invocationId: 'invocation-real-1', + }); + expect(callSignal?.aborted).toBe(true); + cancelled.runtime.close(); + await cancelled.session.close(); + + vi.useFakeTimers(); + try { + const list = vi.fn().mockReturnValue(new Promise(() => undefined)); + const draining = bridge( + { + list, + call: vi.fn(), + releaseHostProcess: vi.fn(), + }, + { graceTimeoutMs: 20 }, + ); + const first = draining.runtime.client.list(); + const firstRejected = expect(first).rejects.toMatchObject({ + code: 'CONNECTOR_UNAVAILABLE', + }); + await vi.waitFor(() => expect(list).toHaveBeenCalledOnce()); + const retiring = draining.session.retire(); + const second = draining.runtime.client.list(); + await expect(second).rejects.toMatchObject({ code: 'CONNECTOR_UNAVAILABLE' }); + await vi.advanceTimersByTimeAsync(20); + await retiring; + draining.runtime.close(); + await firstRejected; + } finally { + vi.useRealTimers(); + } + + const responseLostGateway: HostGatewayFixture = { + list: vi.fn().mockResolvedValue({ tools: [], providerFailures: [], partial: false }), + call: vi.fn(), + releaseHostProcess: vi.fn(), + }; + const lost = bridge(responseLostGateway); + vi.spyOn(lost.hostResponses, 'send').mockImplementation(() => { + throw new Error('response stream closed'); + }); + const lostPending = lost.runtime.client.list(); + await vi.waitFor(() => expect(lost.fatal).toHaveBeenCalledOnce()); + lost.runtime.close(); + await expect(lostPending).rejects.toMatchObject({ code: 'CONNECTOR_UNAVAILABLE' }); + await lost.session.close(); + + const destroyed = bridge(responseLostGateway); + destroyed.hostResponses.connected = false; + const destroyedPending = destroyed.runtime.client.list(); + await vi.waitFor(() => expect(responseLostGateway.list).toHaveBeenCalledTimes(2)); + destroyed.runtime.close(); + await expect(destroyedPending).rejects.toMatchObject({ code: 'CONNECTOR_UNAVAILABLE' }); + await destroyed.session.close(); + }); +}); + +describe('Mini App Host Connector inventory quiesce', () => { + it('waits for inventory already entered into Gateway list before draining', async () => { + let finishInventory: (() => void) | undefined; + const releaseHostProcess = vi.fn(); + const list = vi.fn( + () => + new Promise>>((resolve) => { + finishInventory = () => resolve({ tools: [], providerFailures: [], partial: false }); + }), + ); + const { runtime, session } = bridge({ + list, + call: vi.fn(), + releaseHostProcess, + }); + session.activate(); + const pendingInventory = runtime.client.list(); + await vi.waitFor(() => expect(list).toHaveBeenCalledOnce()); + + const quiesce = session.beginQuiesce({ deadlineMs: Date.now() + 10_000 }); + await expectPendingAfterEventLoopTurn(quiesce.result); + await expect(runtime.client.list()).rejects.toMatchObject({ + code: 'SERVICE_RESTARTED', + disposition: 'not_dispatched', + }); + expect(list).toHaveBeenCalledOnce(); + + finishInventory?.(); + await expect(pendingInventory).resolves.toEqual({ tools: [], partial: false }); + await expect(quiesce.result).resolves.toBe('drained'); + await quiesce.commit(); + expect(releaseHostProcess).toHaveBeenCalledOnce(); + await expect(runtime.client.list()).rejects.toMatchObject({ + code: 'SERVICE_RESTARTED', + disposition: 'not_dispatched', + }); + + runtime.close(); + await session.close(); + expect(releaseHostProcess).toHaveBeenCalledOnce(); + }); +}); + +describe('Mini App Host Connector reversible quiesce', () => { + it('gates new calls without aborting dispatched work and reopens on resume', async () => { + const handle = Object.freeze({}) as ConnectorToolHandle; + let finishFirst: (() => void) | undefined; + let firstSignal: AbortSignal | undefined; + const releaseHostProcess = vi.fn(); + const call = vi + .fn() + .mockImplementationOnce(({ signal }) => { + firstSignal = signal; + return new Promise<{ readonly resultJson: string }>((resolve) => { + finishFirst = () => resolve({ resultJson: '{"temperature":21}' }); + }); + }) + .mockResolvedValue({ resultJson: '{"temperature":22}' }); + const { runtime, session } = bridge({ + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }), + call, + releaseHostProcess, + }); + const tool = (await runtime.client.list()).tools[0]; + if (!tool) throw new Error('Expected Host Connector inventory tool'); + session.activate(); + const first = runtime.client.call(tool.toolRef, {}); + await vi.waitFor(() => expect(call).toHaveBeenCalledOnce()); + + const controller = new AbortController(); + const quiesce = session.beginQuiesce({ + deadlineMs: Date.now() + 10_000, + signal: controller.signal, + }); + await expect(runtime.client.call(tool.toolRef, {})).rejects.toMatchObject({ + code: 'SERVICE_RESTARTED', + disposition: 'not_dispatched', + retryable: true, + }); + expect(call).toHaveBeenCalledOnce(); + + controller.abort(new Error('cutover cancelled')); + await expect(quiesce.result).resolves.toBe('busy'); + expect(firstSignal?.aborted).toBe(false); + expect(releaseHostProcess).not.toHaveBeenCalled(); + expect(quiesce.resume()).toBe(true); + + finishFirst?.(); + await expect(first).resolves.toMatchObject({ value: { temperature: 21 } }); + await expect(runtime.client.call(tool.toolRef, {})).resolves.toMatchObject({ + value: { temperature: 22 }, + }); + expect(call).toHaveBeenCalledTimes(2); + + const expired = session.beginQuiesce({ deadlineMs: Date.now() }); + await expect(expired.result).resolves.toBe('busy'); + await expect(expired.commit()).rejects.toThrow('Host Connector quiesce is not drained'); + expect(expired.resume()).toBe(true); + expect(releaseHostProcess).not.toHaveBeenCalled(); + + runtime.close(); + await session.close(); + }); + + it('does not wait for a call still queued behind the admission receipt write', async () => { + const handle = Object.freeze({}) as ConnectorToolHandle; + const releaseHostProcess = vi.fn(); + const gatewayCall = vi.fn().mockResolvedValue({ resultJson: '{"temperature":21}' }); + const { hostResponses, runtime, session } = bridge({ + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }), + call: gatewayCall, + releaseHostProcess, + }); + const tool = (await runtime.client.list()).tools[0]; + if (!tool) throw new Error('Expected Host Connector inventory tool'); + session.activate(); + let completeReceipt: (error: Error | null) => void = () => undefined; + const responseWrite = vi + .spyOn(hostResponses, 'send') + .mockImplementationOnce((message, callback) => { + hostResponses.peer?.emit('message', message); + completeReceipt = callback; + return false; + }); + + const pending = runtime.client.call(tool.toolRef, {}); + await vi.waitFor(() => expect(responseWrite).toHaveBeenCalledOnce()); + const quiesce = session.beginQuiesce({ deadlineMs: Date.now() + 10_000 }); + await expect(quiesce.result).resolves.toBe('drained'); + await quiesce.commit(); + expect(releaseHostProcess).toHaveBeenCalledOnce(); + + completeReceipt(null); + await expect(pending).rejects.toMatchObject({ + code: 'SERVICE_RESTARTED', + disposition: 'not_dispatched', + }); + expect(gatewayCall).not.toHaveBeenCalled(); + + runtime.close(); + await session.close(); + expect(releaseHostProcess).toHaveBeenCalledOnce(); + }); + + it('waits for dispatched Gateway work and commits a terminal stale gate once', async () => { + const handle = Object.freeze({}) as ConnectorToolHandle; + let finishCall: (() => void) | undefined; + const releaseHostProcess = vi.fn(); + const gatewayCall = vi.fn().mockImplementation( + () => + new Promise<{ readonly resultJson: string }>((resolve) => { + finishCall = () => resolve({ resultJson: '{"temperature":21}' }); + }), + ); + const { runtime, session } = bridge({ + list: vi.fn().mockResolvedValue({ + tools: [ + { + handle, + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + }), + call: gatewayCall, + releaseHostProcess, + }); + const tool = (await runtime.client.list()).tools[0]; + if (!tool) throw new Error('Expected Host Connector inventory tool'); + session.activate(); + const pending = runtime.client.call(tool.toolRef, {}); + await vi.waitFor(() => expect(gatewayCall).toHaveBeenCalledOnce()); + + const quiesce = session.beginQuiesce({ deadlineMs: Date.now() + 10_000 }); + await expectPendingAfterEventLoopTurn(quiesce.result); + finishCall?.(); + await expect(pending).resolves.toMatchObject({ value: { temperature: 21 } }); + await expect(quiesce.result).resolves.toBe('drained'); + await quiesce.commit(); + expect(releaseHostProcess).toHaveBeenCalledOnce(); + expect(quiesce.resume()).toBe(false); + + await expect(runtime.client.call(tool.toolRef, {})).rejects.toMatchObject({ + code: 'SERVICE_RESTARTED', + disposition: 'not_dispatched', + }); + expect(gatewayCall).toHaveBeenCalledOnce(); + await session.retire(); + await session.close(); + expect(releaseHostProcess).toHaveBeenCalledOnce(); + runtime.close(); + }); + + it('invalidates a pending quiesce when retirement starts', async () => { + const releaseHostProcess = vi.fn(); + const { runtime, session } = bridge({ + list: vi.fn(), + call: vi.fn(), + releaseHostProcess, + }); + session.activate(); + + const quiesce = session.beginQuiesce({ deadlineMs: Date.now() + 10_000 }); + const retiring = session.retire(); + expect(quiesce.resume()).toBe(false); + await expect(quiesce.result).resolves.toBe('busy'); + await retiring; + expect(releaseHostProcess).toHaveBeenCalledOnce(); + runtime.close(); + await session.close(); + }); +}); + +describe('Mini App Host Connector Gateway dispatch integration', () => { + it.each([ + { + label: 'provider preflight', + audit: undefined, + callTool: (() => { + throw new Error('provider preflight failed synchronously'); + }) as HostProcessConnectorCloudClient['callTool'], + }, + { + label: 'admission audit', + audit: { + record: () => { + throw new Error('audit failed synchronously'); + }, + }, + callTool: vi.fn(), + }, + ])('keeps a synchronous $label failure retryable and not dispatched', async (fixture) => { + const gateway = new HostConnectorGateway({ + client: { + listConnectedTools: vi.fn(async () => ({ + tools: [ + { + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + })), + callTool: fixture.callTool, + }, + credentialGetter: () => ({ accessToken: 'token', realUserID: 'user' }), + ...(fixture.audit ? { audit: fixture.audit } : {}), + }); + const { runtime, session } = bridge(gateway); + const tool = (await runtime.client.list()).tools[0]; + if (!tool) throw new Error('Expected a Host Connector tool'); + session.activate(); + + await expect(runtime.client.call(tool.toolRef, {})).rejects.toMatchObject({ + code: 'CONNECTOR_UNAVAILABLE', + disposition: 'not_dispatched', + retryable: true, + invocationId: 'invocation-real-1', + }); + + runtime.close(); + await session.close(); + await gateway.close(); + }); + + it('preserves the Gateway unknown outcome when timeout follows provider dispatch', async () => { + const gateway = new HostConnectorGateway({ + client: { + listConnectedTools: vi.fn(async () => ({ + tools: [ + { + provider: 'weather', + providerToolName: 'forecast', + runtimeToolName: 'weather_forecast', + inputSchemaJson: '{}', + }, + ], + providerFailures: [], + partial: false, + })), + callTool: vi.fn((input) => { + input.onDispatch(); + return rejectWithAbortReason(input.signal); + }), + }, + credentialGetter: () => ({ accessToken: 'token', realUserID: 'user' }), + }); + const { runtime, session } = bridge(gateway, { requestTimeoutMs: 5 }); + const tool = (await runtime.client.list()).tools[0]; + if (!tool) throw new Error('Expected a Host Connector tool'); + session.activate(); + + await expect(runtime.client.call(tool.toolRef, {})).rejects.toMatchObject({ + code: 'CONNECTOR_OUTCOME_UNKNOWN', + disposition: 'unknown_after_dispatch', + retryable: false, + invocationId: 'invocation-real-1', + }); + + runtime.close(); + await session.close(); + await gateway.close(); + }); +}); +function rejectWithAbortReason(signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(signal.reason); + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); +} + +function rejectWithGatewayOutcomeOnAbort( + signal: AbortSignal | undefined, + invocationId: string, +): Promise { + if (signal?.aborted) { + return Promise.reject( + new ConnectorGatewayError( + 'CONNECTOR_CALL_FAILED', + 'provider outcome unknown', + 'unknown_after_dispatch', + invocationId, + ), + ); + } + return new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => + reject( + new ConnectorGatewayError( + 'CONNECTOR_CALL_FAILED', + 'provider outcome unknown', + 'unknown_after_dispatch', + invocationId, + ), + ), + { once: true }, + ); + }); +} + + +const AGENT_ALLOWLIST: MiniAppAgentPolicy = { + kind: 'allowlist', + capabilities: ['session.run'], + maxConcurrent: 6, +}; + +const AGENT_EVENT = { + type: 'text.delta', + sessionId: 'session-1', + turnId: 'turn-1', + text: 'hel', +} as const satisfies MiniAppAgentRunEvent; + +const TOOL_FINISHED = { + type: 'tool.finished', + sessionId: 'session-1', + turnId: 'turn-1', + name: 'read', + ok: true, +} as const satisfies MiniAppAgentRunEvent; + +const TURN_FINISHED = { + type: 'turn.finished', + sessionId: 'session-1', + turnId: 'turn-1', + status: 'finished', + text: 'hello', +} as const satisfies MiniAppAgentRunEvent; + +const AGENT_RESULT: MiniAppAgentRunResult = { + sessionId: 'session-1', + turnId: 'turn-1', + status: 'finished', + text: 'hello', +}; + +const PERMISSION_REQUESTED = { + type: 'permission.requested', + sessionId: 'session-1', + turnId: 'turn-2', + requestId: 'permission-1', + toolName: 'bash', + toolInput: '{"command":"ls"}', + reason: 'Run a shell command', +} as const satisfies MiniAppAgentRunEvent; + +const QUESTIONNAIRE = { + schemaVersion: 2, + id: 'question-2', + presentation: { replaceComposer: true, showProgress: true, allowBackNavigation: true }, + steps: [ + { + id: 'step-1', + question: 'Which city?', + selectionMode: 'single', + options: [{ id: 'sh', label: 'Shanghai' }], + allowOther: true, + otherPlaceholder: 'Others...', + required: true, + }, + ], +} as const; + +const QUESTION_REQUESTED = { + type: 'question.requested', + sessionId: 'session-1', + turnId: 'turn-2', + requestId: 'question-2', + mode: 'questionnaire', + request: QUESTIONNAIRE, +} as unknown as MiniAppAgentRunEvent; + +const ANSWER_RESULT: MiniAppAgentRunResult = { + sessionId: 'session-1', + turnId: 'turn-2', + status: 'finished', + pendingQuestion: { + requestId: 'question-2', + mode: 'questionnaire', + request: QUESTIONNAIRE as unknown as AskQuestionnaireRequest, + }, +}; + +const ANSWERS = [ + { stepId: 'step-0', selectedOptionIds: ['yes'], selectedOther: false }, + { stepId: 'step-1', selectedOptionIds: [], selectedOther: true, otherText: 'Hangzhou' }, + { stepId: 'step-2', selectedOptionIds: [], selectedOther: false, skipped: true }, +]; + +type AgentRunImplementation = ( + options: MiniAppAgentRunOptions | undefined, +) => Promise; + +function agentBridge( + options: { + readonly run?: AgentRunImplementation; + readonly answer?: AgentRunImplementation; + readonly policy?: MiniAppAgentPolicy; + readonly withoutGateway?: boolean; + readonly requestTimeoutMs?: number; + readonly logger?: { warn(fields: Record, message: string): void }; + } = {}, +) { + const releaseArena = vi.fn(); + const agentGateway = { + registerArena: vi.fn(() => releaseArena), + run: vi.fn( + ( + _caller: Parameters[0], + _input: Parameters[1], + runOptions?: MiniAppAgentRunOptions, + ) => (options.run ?? (async () => AGENT_RESULT))(runOptions), + ), + reply: vi.fn( + async ( + _caller: Parameters[0], + _input: Parameters[1], + ): Promise => undefined, + ), + answer: vi.fn( + ( + _caller: Parameters[0], + _input: Parameters[1], + runOptions?: MiniAppAgentRunOptions, + ) => (options.answer ?? (async () => ANSWER_RESULT))(runOptions), + ), + models: vi.fn(), + agents: vi.fn(), + create: vi.fn(), + open: vi.fn(), + panelOpened: vi.fn(), + panelClosed: vi.fn(), + } satisfies MiniAppAgentGateway; + const connection = bridge( + { list: vi.fn(), call: vi.fn(), releaseHostProcess: vi.fn() }, + { + ...(options.withoutGateway ? {} : { agentGateway }), + makeInvocationId: () => 'invocation-agent-1', + providers: [], + agentPolicy: options.policy ?? AGENT_ALLOWLIST, + ...(options.requestTimeoutMs ? { requestTimeoutMs: options.requestTimeoutMs } : {}), + ...(options.logger ? { logger: options.logger } : {}), + }, + ); + return { ...connection, agentGateway, releaseArena }; +} + +function resolveAbortedOnSignal(options: MiniAppAgentRunOptions | undefined) { + return new Promise((resolve) => { + options?.signal?.addEventListener( + 'abort', + () => resolve({ sessionId: 'session-1', turnId: 'turn-1', status: 'aborted' }), + { once: true }, + ); + }); +} + +function sentPayloads(transport: MemoryIpc) { + const payloads: Record[] = []; + const send = transport.send.bind(transport); + vi.spyOn(transport, 'send').mockImplementation((message, callback) => { + payloads.push(message.payload as Record); + return send(message, callback); + }); + return payloads; +} + +/** Controlled session frames buffer before/after admission and release a pending reader on close. */ +function controlledSessionFrames() { + const frames: SessionFrame[] = []; + let waiting: ((result: IteratorResult) => void) | undefined; + let closed = false; + const iterator: AsyncIterableIterator = { + [Symbol.asyncIterator]() { + return iterator; + }, + next() { + if (closed) return Promise.resolve({ done: true, value: undefined }); + const frame = frames.shift(); + return frame + ? Promise.resolve({ done: false, value: frame }) + : new Promise((resolve) => { + waiting = resolve; + }); + }, + return() { + closed = true; + waiting?.({ done: true, value: undefined }); + waiting = undefined; + return Promise.resolve({ done: true, value: undefined }); + }, + }; + return { + iterator, + push(frame: SessionFrame) { + if (waiting) { + const next = waiting; + waiting = undefined; + next({ done: false, value: frame }); + } else frames.push(frame); + }, + }; +} + +/** Real admission, streaming and permission gate with controlled Turn/storage/approval ports. */ +function controlledApprovalBridge() { + const subscribers = new Set<{ next(event: GlobalEvent): void }>(); + const streams = new Map>(); + const terminated = new Set(); + const approvalRuns: Promise[] = []; + let automaticApprovals = false; + const gateway = createMiniAppAgentGateway({ + dataDir: '/unused-test-data', + nowMs: Date.now, + logger: { info: vi.fn(), warn: vi.fn() }, + }); + const pending = new Map< + string, + { sessionId: string; resolve(decision: 'allowOnce' | 'deny'): void } + >(); + const approval = { + listPending: () => [...pending].map(([requestId, { sessionId }]) => ({ requestId, sessionId })), + request(input: Parameters[0]) { + const requestId = `permission-${input.sessionId}`; + return new Promise<'allowOnce' | 'deny'>((resolve) => { + pending.set(requestId, { sessionId: input.sessionId, resolve }); + const event = { + type: 'permission.ask', + payload: { + ...input, + requestId, + toolInput: JSON.stringify(input.toolInput), + createdAt: Date.now(), + }, + } as unknown as GlobalEvent; + for (const subscriber of subscribers) subscriber.next(event); + }); + }, + async reply(requestIds: readonly string[], decision: 'allowOnce' | 'deny') { + const processed: string[] = []; + for (const requestId of requestIds) { + const request = pending.get(requestId); + if (!request) continue; + pending.delete(requestId); + request.resolve(decision); + processed.push(requestId); + } + return { processed }; + }, + abortSession(sessionId: string) { + for (const [requestId, request] of pending) { + if (request.sessionId !== sessionId) continue; + pending.delete(requestId); + request.resolve('deny'); + } + }, + }; + const gate = new LocalTurnPermissionGate({ + decisions: { + check: async () => ({ + behavior: 'ask', + reason: 'Approve controlled work', + ruleContents: ['review-command'], + }), + }, + approval, + miniAppApprovals: gateway, + }); + const terminal = (sessionId: string, status: 'completed' | 'aborted') => { + if (terminated.has(sessionId)) return; + terminated.add(sessionId); + const turnId = `turn-${sessionId}`; + streams.get(sessionId)?.push({ + identity: `terminal-${turnId}`, + sessionId, + turnId, + kind: 'turn-terminal', + data: { status }, + cursor: `cursor-${turnId}`, + createdAtMs: Date.now(), + }); + }; + const ask = (sessionId: string) => { + const run = gate + .beforeToolCall({ + sessionId, + turnId: `turn-${sessionId}`, + agentName: 'mavis', + model: 'test/model', + sessionLineage: { miniApp: true, parentSessionId: null }, + toolContext: { + assistantMessage: {}, + toolCall: { id: `tool-${sessionId}`, name: 'bash', source: 'configured' }, + args: { command: 'review-command' }, + context: {}, + } as unknown as Parameters[0]['toolContext'], + }) + .then((result) => { + terminal(sessionId, 'completed'); + return result; + }); + approvalRuns.push(run); + }; + const steer = vi.fn(async ({ sessionId }) => { + if (automaticApprovals) queueMicrotask(() => ask(sessionId)); + return { + delivered: true, + mode: 'activated', + turnId: `turn-${sessionId}`, + completion: new Promise(() => undefined), + }; + }); + gateway.bind( + createMiniAppAgentSessionPorts({ + records: { + createInternalSession: async () => { + throw new Error('Tests continue existing sessions'); + }, + }, + lifecycle: { mutateSession: async () => ({}) }, + repository: { + get: async () => ({ + agentName: 'mavis', + workspaceDir: '/unused-test-data', + isDefaultWorkspace: false, + parentSessionId: null, + // This build persists Mini App ownership as the `purpose` label, not `miniAppOrigin`. + purpose: 'miniapp:weather-board', + effectiveModel: 'minimax/M3', + }), + }, + turns: { + steer, + abort: async ({ sessionId, turnId }) => { + approval.abortSession(sessionId); + terminal(sessionId, 'aborted'); + return { status: 'aborted', turnId: turnId ?? `turn-${sessionId}` }; + }, + }, + stream: { + resume({ sessionId }) { + const frames = controlledSessionFrames(); + streams.set(sessionId, frames); + return frames.iterator; + }, + }, + messages: { listTurn: async () => [] }, + questionnaires: { getPending: async () => undefined, reply: async () => ({ ok: true }) }, + permissions: { + listPending: async () => approval.listPending(), + reply: ({ requestIds, decision }) => approval.reply(requestIds, decision), + }, + eventBus: { + subscribe(subscriber) { + subscribers.add(subscriber); + return () => subscribers.delete(subscriber); + }, + }, + logger: { warn: vi.fn() }, + models: { list: async () => [] }, + agents: { list: async () => [] }, + modelConfig: () => ({ dataDir: '/unused-test-data', provider: {} }), + nowMs: Date.now, + }), + ); + const current = bridge( + { list: vi.fn(), call: vi.fn(), releaseHostProcess: vi.fn() }, + { + agentGateway: gateway, + agentPolicy: { kind: 'allowlist', capabilities: ['session.run'], maxConcurrent: 4 }, + providers: [], + }, + ); + current.session.activate(); + const started = () => steer.mock.calls.map(([input]) => input.sessionId); + return { + ...current, + approval, + approvalRuns, + steer, + started, + startApprovals() { + automaticApprovals = true; + for (const sessionId of started()) ask(sessionId); + }, + }; +} + +describe('Mini App Agent over the Host Connector channel', () => { + it('streams admitted, event, and terminal frames and registers the arena on activate', async () => { + const { agentGateway, hostRequests, releaseArena, runtime, session } = agentBridge({ + run: async (options) => { + options?.onEvent?.(AGENT_EVENT); + // Unrecognised or malformed events never leave the Host. + options?.onEvent?.({ type: 'future.event', turnId: 'turn-1' } as never); + options?.onEvent?.({ type: 'tool.finished', turnId: 'turn-1', name: 'read' } as never); + options?.onEvent?.({ ...TOOL_FINISHED, extra: true } as never); + options?.onEvent?.(TOOL_FINISHED); + options?.onEvent?.(TURN_FINISHED); + return AGENT_RESULT; + }, + }); + const hostFrames = sentPayloads(hostRequests); + session.activate(); + expect(agentGateway.registerArena).toHaveBeenCalledWith({ + pluginId: 'weather-board', + processGeneration: 'process-1', + policy: AGENT_ALLOWLIST, + }); + const events: MiniAppAgentRunEvent[] = []; + + await expect( + runtime.agent.run( + { text: 'hi', sessionId: 'session-1' }, + { onEvent: (event) => events.push(event) }, + ), + ).resolves.toEqual(AGENT_RESULT); + + expect(agentGateway.run).toHaveBeenCalledWith( + { pluginId: 'weather-board', processGeneration: 'process-1', source: 'process' }, + { text: 'hi', sessionId: 'session-1' }, + expect.objectContaining({ signal: expect.any(AbortSignal), onEvent: expect.any(Function) }), + ); + expect(events).toEqual([AGENT_EVENT, TOOL_FINISHED, TURN_FINISHED]); + expect(hostFrames).toEqual([ + { v: 1, id: '1', receipt: 'admitted', invocationId: 'invocation-agent-1' }, + { v: 1, id: '1', event: AGENT_EVENT }, + { v: 1, id: '1', event: TOOL_FINISHED }, + { v: 1, id: '1', event: TURN_FINISHED }, + { v: 1, id: '1', ok: true, result: AGENT_RESULT }, + ]); + + await session.retire(); + expect(releaseArena).toHaveBeenCalledOnce(); + await session.close(); + expect(releaseArena).toHaveBeenCalledOnce(); + runtime.close(); + }); + + it('sends agent.cancel after the request was written and aborts the Gateway signal', async () => { + let signal: AbortSignal | undefined; + const { agentGateway, childResponses, runtime, session } = agentBridge({ + run: (options) => { + signal = options?.signal; + return resolveAbortedOnSignal(options); + }, + }); + const childFrames = sentPayloads(childResponses); + session.activate(); + const controller = new AbortController(); + + const pending = runtime.agent.run({ text: 'hi' }, { signal: controller.signal }); + await vi.waitFor(() => expect(agentGateway.run).toHaveBeenCalledOnce()); + controller.abort(); + + await expect(pending).resolves.toMatchObject({ status: 'aborted' }); + expect(signal?.aborted).toBe(true); + expect(childFrames).toEqual([ + { v: 1, id: '1', method: 'agent.run', params: { text: 'hi' } }, + { v: 1, id: '1', method: 'agent.cancel' }, + ]); + runtime.close(); + await session.close(); + }); + + it('denies runs without an allowlist or Gateway and gates runs before activation', async () => { + const denied = agentBridge({ policy: { kind: 'deny-all' } }); + denied.session.activate(); + await expect(denied.runtime.agent.run({ text: 'hi' })).rejects.toMatchObject({ + name: 'MiniAppAgentError', + code: 'NOT_PERMITTED', + retryable: false, + }); + expect(denied.agentGateway.registerArena).not.toHaveBeenCalled(); + expect(denied.agentGateway.run).not.toHaveBeenCalled(); + denied.runtime.close(); + await denied.session.close(); + + const unwired = agentBridge({ withoutGateway: true }); + unwired.session.activate(); + await expect(unwired.runtime.agent.run({ text: 'hi' })).rejects.toMatchObject({ + code: 'NOT_PERMITTED', + }); + unwired.runtime.close(); + await unwired.session.close(); + + const candidate = agentBridge(); + await expect(candidate.runtime.agent.run({ text: 'hi' })).rejects.toMatchObject({ + code: 'AGENT_UNAVAILABLE', + }); + await expect(candidate.runtime.agent.run({ text: 1 } as never)).rejects.toMatchObject({ + code: 'INVALID_ARGUMENTS', + }); + expect(candidate.agentGateway.run).not.toHaveBeenCalled(); + candidate.runtime.close(); + await candidate.session.close(); + }); + + it('forwards typed Gateway errors and exempts agent runs from the connector request timeout', async () => { + const { agentGateway, runtime, session } = agentBridge({ requestTimeoutMs: 5 }); + session.activate(); + agentGateway.run.mockImplementationOnce(async () => { + throw new MiniAppAgentError('BUSY', 'internal detail xyz', true); + }); + await expect(runtime.agent.run({ text: 'hi' })).rejects.toMatchObject({ + code: 'BUSY', + message: 'Mini App Agent is busy', + retryable: true, + }); + + agentGateway.run.mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve(AGENT_RESULT), 30)), + ); + await expect(runtime.agent.run({ text: 'slow' })).resolves.toEqual(AGENT_RESULT); + runtime.close(); + await session.close(); + }); + + it('releases the arena on Host failure and reports a written run as outcome unknown', async () => { + let signal: AbortSignal | undefined; + const { agentGateway, childResponses, fatal, hostRequests, releaseArena, runtime, session } = + agentBridge({ + run: (options) => { + signal = options?.signal; + return new Promise(() => undefined); + }, + }); + session.activate(); + const pending = runtime.agent.run({ text: 'hi' }); + await vi.waitFor(() => expect(agentGateway.run).toHaveBeenCalledOnce()); + + hostRequests.emit('message', { type: 'miniapp:connector', unexpected: true }); + expect(fatal).toHaveBeenCalledOnce(); + expect(signal?.aborted).toBe(true); + expect(releaseArena).toHaveBeenCalledOnce(); + childResponses.emit('disconnect'); + + await expect(pending).rejects.toMatchObject({ + name: 'MiniAppAgentError', + code: 'AGENT_OUTCOME_UNKNOWN', + retryable: false, + }); + await session.close(); + expect(releaseArena).toHaveBeenCalledOnce(); + }); + + it('ignores agent events of an unknown type from a newer Host', async () => { + const input = new MemoryIpc(); + const runtime = createMiniAppHostConnectorRuntimeClient({ transport: input }); + const events: MiniAppAgentRunEvent[] = []; + const pending = runtime.agent.run({ text: 'hi' }, { onEvent: (event) => events.push(event) }); + input.receive({ v: 1, id: '1', receipt: 'admitted', invocationId: 'invocation-agent-1' }); + input.receive({ v: 1, id: '1', event: { type: 'future.event', turnId: 'turn-1', x: 1 } }); + input.receive({ v: 1, id: '1', event: AGENT_EVENT }); + input.receive({ v: 1, id: '1', ok: true, result: AGENT_RESULT }); + + await expect(pending).resolves.toEqual(AGENT_RESULT); + expect(events).toEqual([AGENT_EVENT]); + runtime.close(); + }); + + it('ignores notifications addressed to connector requests', async () => { + const input = new MemoryIpc(); + const runtime = createMiniAppHostConnectorRuntimeClient({ transport: input }); + const pending = runtime.client.list(); + input.receive({ v: 1, id: '1', event: AGENT_EVENT }); + input.receive({ v: 1, id: '1', ok: true, result: { tools: [], partial: false } }); + await expect(pending).resolves.toEqual({ tools: [], partial: false }); + runtime.close(); + }); +}); + +describe('Mini App Agent approval capacity over the Host Connector channel', () => { + it('answers approvals through real admission with a full run queue and completes the batch in FIFO order', async () => { + const current = controlledApprovalBridge(); + const events: MiniAppAgentRunEvent[] = []; + const replies: Promise[] = []; + const calls = Array.from({ length: NODE_PENDING_LIMIT }, (_, index) => + current.runtime.agent.run( + { sessionId: `session-${index + 1}`, text: 'Run controlled work' }, + { + onEvent(event) { + events.push(event); + if (event.type === 'permission.requested') { + replies.push( + current.runtime.agent + .reply({ + sessionId: event.sessionId, + requestId: event.requestId, + decision: 'allowOnce', + }) + .catch((error: unknown) => error), + ); + } + }, + }, + ), + ); + const settled = Promise.allSettled(calls); + const outcomes: unknown[] = []; + calls.forEach( + (call) => + void call.then( + (result) => outcomes.push(result), + (error: unknown) => outcomes.push(error), + ), + ); + try { + await vi.waitFor(() => { + expect(current.started()).toEqual(['session-1', 'session-2', 'session-3', 'session-4']); + expect(events.filter((event) => event.type === 'run.queued')).toHaveLength(12); + }); + current.startApprovals(); + await vi.waitFor(() => expect(outcomes).toHaveLength(NODE_PENDING_LIMIT)); + await expect(Promise.all(replies)).resolves.toEqual( + Array.from({ length: NODE_PENDING_LIMIT }, () => undefined), + ); + expect(current.started()).toEqual( + Array.from({ length: NODE_PENDING_LIMIT }, (_, index) => `session-${index + 1}`), + ); + await expect(Promise.all(calls)).resolves.toEqual( + Array.from({ length: NODE_PENDING_LIMIT }, () => + expect.objectContaining({ status: 'finished' }), + ), + ); + expect(current.approval.listPending()).toEqual([]); + expect(current.fatal).not.toHaveBeenCalled(); + } finally { + current.runtime.close(); + await current.session.close(); + await settled; + await Promise.allSettled(current.approvalRuns); + } + expect(current.approval.listPending()).toEqual([]); + }); + + it('cancels a Node call in the full queue and closes the remaining calls', async () => { + const current = controlledApprovalBridge(); + const controller = new AbortController(); + const calls = Array.from({ length: NODE_PENDING_LIMIT }, (_, index) => + current.runtime.agent.run( + { sessionId: `session-${index + 1}`, text: 'Work' }, + index === NODE_PENDING_LIMIT - 1 ? { signal: controller.signal } : undefined, + ), + ); + const settled = Promise.allSettled(calls); + try { + await vi.waitFor(() => expect(current.started()).toHaveLength(4)); + controller.abort(); + await expect(calls[NODE_PENDING_LIMIT - 1]).rejects.toMatchObject({ + code: 'REQUEST_CANCELLED', + }); + expect(current.started()).not.toContain(`session-${NODE_PENDING_LIMIT}`); + expect(current.fatal).not.toHaveBeenCalled(); + } finally { + current.runtime.close(); + await current.session.close(); + } + expect((await settled).every((outcome) => outcome.status === 'rejected')).toBe(true); + expect(current.approval.listPending()).toEqual([]); + }); + + it('carries session_busy from the real session ports when the Host already runs the session', async () => { + const current = controlledApprovalBridge(); + current.steer.mockResolvedValueOnce({ + delivered: true, + mode: 'steered', + turnId: 'host-turn', + }); + try { + await expect( + current.runtime.agent.run({ sessionId: 'session-1', text: 'Continue' }), + ).rejects.toMatchObject({ code: 'BUSY', reason: 'session_busy', retryable: true }); + expect(current.fatal).not.toHaveBeenCalled(); + } finally { + current.runtime.close(); + await current.session.close(); + } + }); + + it('bounds replies separately without taking run or Connector capacity, and releases both on close', async () => { + const input = new MemoryIpc(); + const runtime = createMiniAppHostConnectorRuntimeClient({ transport: input }); + const replies = Array.from({ length: NODE_REPLY_PENDING_LIMIT }, () => + runtime.agent.reply({ sessionId: 'session-1', requestId: 'permission-1', decision: 'deny' }), + ); + const normal = [ + ...Array.from({ length: 8 }, () => runtime.client.list()), + ...Array.from({ length: 4 }, () => runtime.agent.run({ text: 'Work' })), + ...Array.from({ length: 4 }, () => + runtime.agent.answer({ sessionId: 'session-1', requestId: 'question-1', answers: [] }), + ), + ]; + const settled = Promise.allSettled([...replies, ...normal]); + await expect( + runtime.agent.reply({ sessionId: 's', requestId: 'p', decision: 'deny' }), + ).rejects.toMatchObject({ code: 'AGENT_UNAVAILABLE', retryable: true }); + await expect(runtime.client.list()).rejects.toMatchObject({ code: 'CONNECTOR_UNAVAILABLE' }); + await expect(runtime.agent.run({ text: 'Overflow' })).rejects.toMatchObject({ + code: 'AGENT_UNAVAILABLE', + }); + input.receive({ v: 1, id: '1', ok: true, result: null }); + input.receive({ + v: 1, + id: String(NODE_REPLY_PENDING_LIMIT + 1), + ok: true, + result: { tools: [], partial: false }, + }); + await expect(replies[0]).resolves.toBeUndefined(); + await expect(normal[0]).resolves.toEqual({ tools: [], partial: false }); + const replacement = Promise.allSettled([ + runtime.agent.reply({ sessionId: 's', requestId: 'p', decision: 'deny' }), + runtime.client.list(), + ]); + runtime.close(); + expect((await settled).filter((outcome) => outcome.status === 'fulfilled')).toHaveLength(2); + expect((await replacement).every((outcome) => outcome.status === 'rejected')).toBe(true); + }); + + it.each(['agent.reply', 'agent.run'] as const)( + 'fails a raw Host request beyond the independent %s budget', + async (overflowMethod) => { + const current = agentBridge({ run: resolveAbortedOnSignal }); + current.agentGateway.reply.mockImplementation(() => new Promise(() => undefined)); + current.session.activate(); + current.runtime.close(); + const request = (id: number, method: 'agent.reply' | 'agent.run') => ({ + v: 1, + id: String(id), + method, + params: + method === 'agent.reply' + ? { sessionId: 'session-1', requestId: `permission-${id}`, decision: 'deny' } + : { text: 'Work' }, + }); + for (let index = 1; index <= NODE_PENDING_LIMIT; index += 1) { + current.hostRequests.receive(request(index, 'agent.run')); + } + for (let index = 1; index <= NODE_REPLY_PENDING_LIMIT; index += 1) { + current.hostRequests.receive(request(NODE_PENDING_LIMIT + index, 'agent.reply')); + } + await vi.waitFor(() => { + expect(current.agentGateway.run).toHaveBeenCalledTimes(NODE_PENDING_LIMIT); + expect(current.agentGateway.reply).toHaveBeenCalledTimes(NODE_REPLY_PENDING_LIMIT); + }); + expect(current.fatal).not.toHaveBeenCalled(); + current.hostRequests.receive( + request(NODE_PENDING_LIMIT + NODE_REPLY_PENDING_LIMIT + 1, overflowMethod), + ); + expect(current.fatal).toHaveBeenCalledOnce(); + await current.session.close(); + expect(current.releaseArena).toHaveBeenCalledOnce(); + }, + ); +}); + +describe('Mini App Agent run queue over the Host Connector channel', () => { + it('carries the BUSY reason to the child and never a reason on another code', async () => { + const { agentGateway, hostRequests, runtime, session } = agentBridge(); + const hostFrames = sentPayloads(hostRequests); + session.activate(); + agentGateway.run.mockRejectedValueOnce( + new MiniAppAgentError('BUSY', 'internal detail', true, 'queue_full'), + ); + agentGateway.answer.mockRejectedValueOnce( + new MiniAppAgentError('REQUEST_CANCELLED', 'internal detail', false, 'session_busy'), + ); + + await expect(runtime.agent.run({ text: 'hi' })).rejects.toMatchObject({ + name: 'MiniAppAgentError', + code: 'BUSY', + message: 'Mini App Agent is busy', + retryable: true, + reason: 'queue_full', + }); + const cancelled = runtime.agent.answer({ sessionId: 's', requestId: 'q', answers: [] }); + await expect(cancelled).rejects.toMatchObject({ code: 'REQUEST_CANCELLED' }); + await expect(cancelled).rejects.not.toHaveProperty('reason'); + expect(hostFrames.filter((frame) => frame.ok === false).map((frame) => frame.error)).toEqual([ + { code: 'BUSY', message: 'Mini App Agent is busy', retryable: true, reason: 'queue_full' }, + { code: 'REQUEST_CANCELLED', message: 'Mini App Agent run was cancelled', retryable: false }, + ]); + runtime.close(); + await session.close(); + }); + + it('fails the generation on a reason the child cannot read', async () => { + const busy = { code: 'BUSY', message: 'Mini App Agent is busy', retryable: true }; + for (const error of [ + { ...busy, reason: 'later' }, + { ...busy, code: 'AGENT_UNAVAILABLE', reason: 'queue_full' }, + ]) { + const input = new MemoryIpc(); + const onFatal = vi.fn(); + const client = createMiniAppHostConnectorRuntimeClient({ transport: input, onFatal }); + const run = client.agent.run({ text: 'hi' }); + input.receive({ v: 1, id: '1', ok: false, error }); + await expect(run).rejects.toMatchObject({ name: 'MiniAppAgentError' }); + expect(onFatal).toHaveBeenCalledOnce(); + client.close(); + } + }); + + it('delivers run.queued to the child with and without a sessionId', async () => { + const queued = { type: 'run.queued', position: 2 } as const; + const continued = { type: 'run.queued', sessionId: 'session-1', position: 1 } as const; + const { hostRequests, runtime, session } = agentBridge({ + run: async (options) => { + options?.onEvent?.(queued); + options?.onEvent?.(continued); + // Malformed ones never leave the Host. + options?.onEvent?.({ ...queued, position: 0 }); + options?.onEvent?.({ ...continued, turnId: 'turn-1' } as never); + options?.onEvent?.(AGENT_EVENT); + return AGENT_RESULT; + }, + }); + const hostFrames = sentPayloads(hostRequests); + session.activate(); + const events: MiniAppAgentRunEvent[] = []; + + await runtime.agent.run({ text: 'hi' }, { onEvent: (event) => events.push(event) }); + + expect(events).toEqual([queued, continued, AGENT_EVENT]); + expect(hostFrames.filter((frame) => 'event' in frame)).toHaveLength(3); + runtime.close(); + await session.close(); + + const input = new MemoryIpc(); + const onFatal = vi.fn(); + const client = createMiniAppHostConnectorRuntimeClient({ transport: input, onFatal }); + const run = client.agent.run({ text: 'hi' }, { onEvent: vi.fn() }); + input.receive({ v: 1, id: '1', event: { ...queued, position: 1.5 } }); + await expect(run).rejects.toMatchObject({ name: 'MiniAppAgentError' }); + expect(onFatal).toHaveBeenCalledOnce(); + client.close(); + }); +}); + +describe('Mini App Agent runs across a cutover', () => { + it('keeps in-flight runs through a quiesce and aborts them only when the cutover commits', async () => { + let signal: AbortSignal | undefined; + const { agentGateway, releaseArena, runtime, session } = agentBridge({ + run: (options) => { + signal = options?.signal; + return resolveAbortedOnSignal(options); + }, + }); + session.activate(); + const pending = runtime.agent.run({ text: 'hi' }); + await vi.waitFor(() => expect(agentGateway.run).toHaveBeenCalledOnce()); + + const quiesce = session.beginQuiesce({ deadlineMs: Date.now() + 10_000 }); + await expect(quiesce.result).resolves.toBe('drained'); + expect(signal?.aborted).toBe(false); + expect(releaseArena).not.toHaveBeenCalled(); + await quiesce.commit(); + expect(signal?.aborted).toBe(true); + await expect(pending).resolves.toMatchObject({ status: 'aborted' }); + expect(releaseArena).toHaveBeenCalledOnce(); + await expect(runtime.agent.run({ text: 'again' })).rejects.toMatchObject({ + code: 'AGENT_UNAVAILABLE', + retryable: true, + }); + runtime.close(); + await session.close(); + }); + + it('lets an in-flight run finish normally when a quiesce is busy or its cutover is cancelled', async () => { + let signal: AbortSignal | undefined; + let finishRun: (() => void) | undefined; + const { agentGateway, releaseArena, runtime, session } = agentBridge({ + run: (options) => { + signal = options?.signal; + return new Promise((resolve) => { + finishRun = () => resolve(AGENT_RESULT); + }); + }, + }); + session.activate(); + const pending = runtime.agent.run({ text: 'hi' }); + await vi.waitFor(() => expect(agentGateway.run).toHaveBeenCalledOnce()); + + const expired = session.beginQuiesce({ deadlineMs: Date.now() }); + await expect(expired.result).resolves.toBe('busy'); + expect(expired.resume()).toBe(true); + const cancelled = session.beginQuiesce({ deadlineMs: Date.now() + 10_000 }); + await expect(cancelled.result).resolves.toBe('drained'); + expect(cancelled.resume()).toBe(true); + + expect(signal?.aborted).toBe(false); + finishRun?.(); + await expect(pending).resolves.toEqual(AGENT_RESULT); + expect(releaseArena).not.toHaveBeenCalled(); + runtime.close(); + await session.close(); + }); +}); + +describe('Mini App Agent child-side event delivery', () => { + it('reports a throwing onEvent handler on stderr and keeps the run going', async () => { + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const input = new MemoryIpc(); + const onFatal = vi.fn(); + const runtime = createMiniAppHostConnectorRuntimeClient({ transport: input, onFatal }); + const events: MiniAppAgentRunEvent[] = []; + const pending = runtime.agent.run( + { text: 'hi' }, + { + onEvent: (event) => { + events.push(event); + if (events.length === 1) throw new Error('author handler bug'); + }, + }, + ); + input.receive({ v: 1, id: '1', receipt: 'admitted', invocationId: 'invocation-agent-1' }); + input.receive({ v: 1, id: '1', event: AGENT_EVENT }); + input.receive({ v: 1, id: '1', event: TOOL_FINISHED }); + input.receive({ v: 1, id: '1', ok: true, result: AGENT_RESULT }); + + try { + await expect(pending).resolves.toEqual(AGENT_RESULT); + expect(events).toEqual([AGENT_EVENT, TOOL_FINISHED]); + expect(onFatal).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledOnce(); + expect(String(stderr.mock.calls[0]?.[0])).toMatch( + /^\[miniapp\] agent onEvent handler threw Error: author handler bug/u, + ); + } finally { + stderr.mockRestore(); + runtime.close(); + } + }); +}); + +describe('Mini App Agent observation and turn identity over the Host Connector channel', () => { + it('hands the Gateway a listener only when the child observes the run', async () => { + const { agentGateway, childResponses, hostRequests, runtime, session } = agentBridge({ + run: async (options) => { + options?.onEvent?.(AGENT_EVENT); + return AGENT_RESULT; + }, + }); + const childFrames = sentPayloads(childResponses); + const hostFrames = sentPayloads(hostRequests); + session.activate(); + const answer = { sessionId: 'session-1', requestId: 'question-1', answers: [] }; + + await runtime.agent.run({ text: 'unobserved' }); + await runtime.agent.answer(answer); + expect(agentGateway.run.mock.calls[0]?.[2]).not.toHaveProperty('onEvent'); + expect(agentGateway.answer.mock.calls[0]?.[2]).not.toHaveProperty('onEvent'); + expect(hostFrames.filter((frame) => 'event' in frame)).toEqual([]); + + const events: MiniAppAgentRunEvent[] = []; + const onEvent = (event: MiniAppAgentRunEvent) => events.push(event); + await runtime.agent.run({ text: 'observed' }, { onEvent }); + await runtime.agent.answer(answer, { onEvent }); + expect(agentGateway.run.mock.calls[1]?.[2]?.onEvent).toEqual(expect.any(Function)); + expect(agentGateway.answer.mock.calls[1]?.[2]?.onEvent).toEqual(expect.any(Function)); + expect(events).toEqual([AGENT_EVENT]); + + expect( + childFrames.filter((frame) => frame.method !== 'agent.cancel').map((frame) => frame.params), + ).toEqual([ + { text: 'unobserved' }, + answer, + { text: 'observed', observe: true }, + { ...answer, observe: true }, + ]); + runtime.close(); + await session.close(); + }); + + it('fails the generation when a child sends a non-boolean observe flag', async () => { + for (const request of [ + { method: 'agent.run', params: { text: 'hi', observe: 'yes' } }, + { + method: 'agent.answer', + params: { sessionId: 'session-1', requestId: 'question-1', answers: [], observe: 1 }, + }, + ]) { + const { agentGateway, fatal, hostRequests, runtime, session } = agentBridge(); + session.activate(); + hostRequests.receive({ v: 1, id: '1', ...request }); + await vi.waitFor(() => expect(fatal).toHaveBeenCalledOnce()); + expect(agentGateway.run).not.toHaveBeenCalled(); + expect(agentGateway.answer).not.toHaveBeenCalled(); + runtime.close(); + await session.close(); + } + }); + + it('accepts a missing turnId on a permission request and a result, but never an empty one', async () => { + const input = new MemoryIpc(); + const runtime = createMiniAppHostConnectorRuntimeClient({ transport: input }); + const events: MiniAppAgentRunEvent[] = []; + const permission: Record = { ...PERMISSION_REQUESTED }; + delete permission.turnId; + const result = { sessionId: 'session-1', status: 'aborted' }; + const pending = runtime.agent.run({ text: 'hi' }, { onEvent: (event) => events.push(event) }); + input.receive({ v: 1, id: '1', receipt: 'admitted', invocationId: 'invocation-agent-1' }); + input.receive({ v: 1, id: '1', event: permission }); + input.receive({ v: 1, id: '1', ok: true, result }); + await expect(pending).resolves.toEqual(result); + expect(events).toEqual([permission]); + runtime.close(); + + for (const frame of [ + { event: { ...PERMISSION_REQUESTED, turnId: '' } }, + { ok: true, result: { ...result, turnId: '' } }, + ]) { + const current = new MemoryIpc(); + const onFatal = vi.fn(); + const client = createMiniAppHostConnectorRuntimeClient({ transport: current, onFatal }); + const run = client.agent.run({ text: 'hi' }, { onEvent: vi.fn() }); + current.receive({ v: 1, id: '1', ...frame }); + await expect(run).rejects.toMatchObject({ name: 'MiniAppAgentError' }); + expect(onFatal).toHaveBeenCalledOnce(); + client.close(); + } + }); + + it('never forwards an empty Gateway turnId in the result frame', async () => { + const { hostRequests, runtime, session } = agentBridge({ + run: async () => ({ sessionId: 'session-1', turnId: '', status: 'aborted' }), + }); + const hostFrames = sentPayloads(hostRequests); + session.activate(); + await expect(runtime.agent.run({ text: 'hi' })).resolves.toEqual({ + sessionId: 'session-1', + status: 'aborted', + }); + expect(hostFrames.at(-1)).toEqual({ + v: 1, + id: '1', + ok: true, + result: { sessionId: 'session-1', status: 'aborted' }, + }); + runtime.close(); + await session.close(); + }); +}); + +describe('Mini App Agent interaction over the Host Connector channel', () => { + it('replies to a permission request with a short request settled by a null result', async () => { + const { agentGateway, childResponses, hostRequests, runtime, session } = agentBridge(); + const childFrames = sentPayloads(childResponses); + const hostFrames = sentPayloads(hostRequests); + session.activate(); + + await expect( + runtime.agent.reply({ sessionId: 'session-1', requestId: 'permission-1', decision: 'deny' }), + ).resolves.toBeUndefined(); + + expect(agentGateway.reply).toHaveBeenCalledWith( + { pluginId: 'weather-board', processGeneration: 'process-1', source: 'process' }, + { sessionId: 'session-1', requestId: 'permission-1', decision: 'deny' }, + ); + expect(childFrames).toEqual([ + { + v: 1, + id: '1', + method: 'agent.reply', + params: { sessionId: 'session-1', requestId: 'permission-1', decision: 'deny' }, + }, + ]); + expect(hostFrames).toEqual([{ v: 1, id: '1', ok: true, result: null }]); + + agentGateway.reply.mockRejectedValueOnce( + new MiniAppAgentError('REQUEST_NOT_FOUND', 'internal permission-1 detail'), + ); + await expect( + runtime.agent.reply({ sessionId: 'session-1', requestId: 'gone', decision: 'allowOnce' }), + ).rejects.toMatchObject({ + name: 'MiniAppAgentError', + code: 'REQUEST_NOT_FOUND', + message: 'Mini App Agent request was not found or already settled', + retryable: false, + }); + runtime.close(); + await session.close(); + }); + + it('reports a reply the Gateway did not settle within the request timeout as outcome unknown', async () => { + const { agentGateway, runtime, session } = agentBridge({ requestTimeoutMs: 5 }); + session.activate(); + agentGateway.reply.mockImplementationOnce(() => new Promise(() => undefined)); + + await expect( + runtime.agent.reply({ sessionId: 'session-1', requestId: 'permission-1', decision: 'deny' }), + ).rejects.toMatchObject({ code: 'AGENT_OUTCOME_UNKNOWN' }); + runtime.close(); + await session.close(); + }); + + it('rejects malformed reply and answer arguments without dispatching them', async () => { + const { agentGateway, childResponses, runtime, session } = agentBridge(); + const childFrames = sentPayloads(childResponses); + session.activate(); + const reply = { sessionId: 'session-1', requestId: 'permission-1', decision: 'allowOnce' }; + const answer = { sessionId: 'session-1', requestId: 'question-1', answers: ANSWERS }; + const step = { stepId: 'step-1', selectedOptionIds: [], selectedOther: false }; + + for (const input of [ + { ...reply, decision: 'allowAlways' }, + { ...reply, decision: undefined }, + { ...reply, requestId: '' }, + ]) { + await expect(runtime.agent.reply(input as never)).rejects.toMatchObject({ + code: 'INVALID_ARGUMENTS', + }); + } + for (const answers of [ + 'step-1', + [{ ...step, extra: true }], + [{ ...step, selectedOptionIds: [1] }], + [{ ...step, selectedOther: 'no' }], + [{ ...step, otherText: 3 }], + [{ stepId: 'step-1', selectedOptionIds: [] }], + [{ ...step, stepId: '' }], + [{ ...step, otherText: 'x'.repeat(4_097) }], + Array.from({ length: 65 }, () => step), + ]) { + await expect(runtime.agent.answer({ ...answer, answers } as never)).rejects.toMatchObject({ + code: 'INVALID_ARGUMENTS', + }); + } + await expect( + runtime.agent.answer({ ...answer, sessionId: undefined } as never), + ).rejects.toMatchObject({ code: 'INVALID_ARGUMENTS' }); + + expect(childFrames).toEqual([]); + expect(agentGateway.reply).not.toHaveBeenCalled(); + expect(agentGateway.answer).not.toHaveBeenCalled(); + runtime.close(); + await session.close(); + }); + + it('streams an answer continuation like a run, including interaction events', async () => { + const { agentGateway, hostRequests, runtime, session } = agentBridge({ + answer: async (options) => { + options?.onEvent?.(PERMISSION_REQUESTED); + options?.onEvent?.(QUESTION_REQUESTED); + return ANSWER_RESULT; + }, + }); + const hostFrames = sentPayloads(hostRequests); + session.activate(); + const events: MiniAppAgentRunEvent[] = []; + + await expect( + runtime.agent.answer( + { sessionId: 'session-1', requestId: 'question-1', answers: ANSWERS }, + { onEvent: (event) => events.push(event) }, + ), + ).resolves.toEqual(ANSWER_RESULT); + + expect(agentGateway.answer).toHaveBeenCalledWith( + { pluginId: 'weather-board', processGeneration: 'process-1', source: 'process' }, + { sessionId: 'session-1', requestId: 'question-1', answers: ANSWERS }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(events).toEqual([PERMISSION_REQUESTED, QUESTION_REQUESTED]); + expect(hostFrames).toEqual([ + { v: 1, id: '1', receipt: 'admitted', invocationId: 'invocation-agent-1' }, + { v: 1, id: '1', event: PERMISSION_REQUESTED }, + { v: 1, id: '1', event: QUESTION_REQUESTED }, + { v: 1, id: '1', ok: true, result: ANSWER_RESULT }, + ]); + runtime.close(); + await session.close(); + }); + + it('cancels an answer continuation with agent.cancel and exempts it from the request timeout', async () => { + let signal: AbortSignal | undefined; + const { agentGateway, childResponses, runtime, session } = agentBridge({ + requestTimeoutMs: 5, + answer: (options) => { + signal = options?.signal; + return resolveAbortedOnSignal(options); + }, + }); + const childFrames = sentPayloads(childResponses); + session.activate(); + const controller = new AbortController(); + + const pending = runtime.agent.answer( + { sessionId: 'session-1', requestId: 'question-1', answers: [] }, + { signal: controller.signal }, + ); + await vi.waitFor(() => expect(agentGateway.answer).toHaveBeenCalledOnce()); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(signal?.aborted).toBe(false); + controller.abort(); + + await expect(pending).resolves.toMatchObject({ status: 'aborted' }); + expect(signal?.aborted).toBe(true); + expect(childFrames.map((frame) => frame.method)).toEqual(['agent.answer', 'agent.cancel']); + runtime.close(); + await session.close(); + }); + + it('never sends a malformed interaction event', async () => { + const malformed = [ + { ...QUESTION_REQUESTED, request: { ...QUESTIONNAIRE, schemaVersion: 1 } }, + { ...QUESTION_REQUESTED, request: { ...QUESTIONNAIRE, steps: undefined } }, + { ...QUESTION_REQUESTED, request: { ...QUESTIONNAIRE, id: '' } }, + { ...QUESTION_REQUESTED, request: 'question-2' }, + { ...QUESTION_REQUESTED, mode: 'goal' }, + { ...QUESTION_REQUESTED, extra: true }, + { ...QUESTION_REQUESTED, turnId: '' }, + { ...PERMISSION_REQUESTED, reason: undefined }, + { ...PERMISSION_REQUESTED, turnId: '' }, + { ...PERMISSION_REQUESTED, sessionId: '' }, + { ...TOOL_FINISHED, sessionId: undefined }, + { ...TOOL_FINISHED, turnId: '' }, + ]; + const withoutTurnId: Record = { ...QUESTION_REQUESTED }; + delete withoutTurnId.turnId; + const permissionWithoutTurnId: Record = { ...PERMISSION_REQUESTED }; + delete permissionWithoutTurnId.turnId; + const { hostRequests, runtime, session } = agentBridge({ + run: async (options) => { + for (const event of malformed) options?.onEvent?.(event as never); + options?.onEvent?.(withoutTurnId as never); + options?.onEvent?.({ ...PERMISSION_REQUESTED, turnId: undefined } as never); + return AGENT_RESULT; + }, + }); + const hostFrames = sentPayloads(hostRequests); + session.activate(); + const events: MiniAppAgentRunEvent[] = []; + + await runtime.agent.run({ text: 'hi' }, { onEvent: (event) => events.push(event) }); + + expect(events).toEqual([withoutTurnId, permissionWithoutTurnId]); + expect(hostFrames.filter((frame) => 'event' in frame)).toEqual([ + { v: 1, id: '1', event: withoutTurnId }, + { v: 1, id: '1', event: permissionWithoutTurnId }, + ]); + runtime.close(); + await session.close(); + }); +}); + +describe('Mini App Agent event backpressure', () => { + it('drops progress frames beyond the queue bound but still delivers interaction and terminal frames', async () => { + const logger = { warn: vi.fn() }; + let gatewayReturned = false; + const { hostRequests, runtime, session } = agentBridge({ + logger, + run: async (options) => { + for (let index = 0; index < 300; index += 1) { + options?.onEvent?.({ ...AGENT_EVENT, text: String(index) }); + } + options?.onEvent?.(PERMISSION_REQUESTED); + options?.onEvent?.(QUESTION_REQUESTED); + gatewayReturned = true; + return AGENT_RESULT; + }, + }); + const held: Array<() => void> = []; + let stalled = true; + const send = hostRequests.send.bind(hostRequests); + vi.spyOn(hostRequests, 'send').mockImplementation((message, callback) => { + const payload = message.payload as Record; + if (stalled && 'event' in payload) { + held.push(() => send(message, callback)); + return true; + } + return send(message, callback); + }); + session.activate(); + const events: MiniAppAgentRunEvent[] = []; + + const pending = runtime.agent.run({ text: 'hi' }, { onEvent: (event) => events.push(event) }); + await vi.waitFor(() => expect(gatewayReturned).toBe(true)); + expect(held).toHaveLength(1); + expect(logger.warn).not.toHaveBeenCalled(); + stalled = false; + for (const release of held.splice(0)) release(); + + await expect(pending).resolves.toEqual(AGENT_RESULT); + expect(logger.warn).toHaveBeenCalledWith( + { pluginId: 'weather-board', processGeneration: 'process-1', droppedEvents: 44 }, + 'Mini App Agent run dropped event frames', + ); + expect(events).toHaveLength(258); + expect(events.slice(-2)).toEqual([PERMISSION_REQUESTED, QUESTION_REQUESTED]); + runtime.close(); + await session.close(); + }); +}); + +describe('Mini App Agent interaction frames the Host cannot send', () => { + it('warns at once and denies an approval whose frame is dropped, so the turn does not hang', async () => { + const logger = { warn: vi.fn() }; + let warnedBeforeReturn: unknown[][] = []; + const permission = { + ...PERMISSION_REQUESTED, + toolInput: 'x'.repeat(RESPONSE_MAX_BYTES + 1), + } satisfies MiniAppAgentRunEvent; + const question = { + ...QUESTION_REQUESTED, + request: { ...QUESTIONNAIRE, steps: [{ ...QUESTIONNAIRE.steps[0], weight: 1n }] }, + } as unknown as MiniAppAgentRunEvent; + let denied: (() => void) | undefined; + const deniedOnce = new Promise((resolve) => { + denied = resolve; + }); + const { agentGateway, hostRequests, runtime, session } = agentBridge({ + logger, + run: async (options) => { + options?.onEvent?.(question); + options?.onEvent?.(permission); + // The turn only continues once the dropped approval is settled. + await deniedOnce; + warnedBeforeReturn = [...logger.warn.mock.calls]; + return AGENT_RESULT; + }, + }); + agentGateway.reply.mockImplementationOnce(async () => { + denied?.(); + }); + const hostFrames = sentPayloads(hostRequests); + session.activate(); + const events: MiniAppAgentRunEvent[] = []; + + await expect( + runtime.agent.run({ text: 'hi' }, { onEvent: (event) => events.push(event) }), + ).resolves.toEqual(AGENT_RESULT); + + const identity = { pluginId: 'weather-board', processGeneration: 'process-1' }; + const message = 'Mini App Agent run dropped an interaction event frame'; + expect(warnedBeforeReturn).toEqual([ + [ + { + ...identity, + sessionId: 'session-1', + requestId: 'question-2', + type: 'question.requested', + reason: 'serialize_failed', + }, + message, + ], + [ + { + ...identity, + sessionId: 'session-1', + requestId: 'permission-1', + type: 'permission.requested', + reason: 'oversized', + }, + message, + ], + ]); + expect(agentGateway.reply).toHaveBeenCalledOnce(); + expect(agentGateway.reply).toHaveBeenCalledWith( + { ...identity, source: 'process' }, + { sessionId: 'session-1', requestId: 'permission-1', decision: 'deny' }, + ); + expect(events).toEqual([]); + expect(hostFrames.filter((frame) => 'event' in frame)).toEqual([]); + expect(logger.warn).toHaveBeenLastCalledWith( + { ...identity, droppedEvents: 2 }, + 'Mini App Agent run dropped event frames', + ); + runtime.close(); + await session.close(); + }); + + it('warns when the Host cannot deny a dropped approval', async () => { + const logger = { warn: vi.fn() }; + const { agentGateway, runtime, session } = agentBridge({ + logger, + run: async (options) => { + options?.onEvent?.({ ...PERMISSION_REQUESTED, toolInput: 'x'.repeat(RESPONSE_MAX_BYTES) }); + return AGENT_RESULT; + }, + }); + agentGateway.reply.mockRejectedValueOnce(new MiniAppAgentError('REQUEST_NOT_FOUND', 'gone')); + session.activate(); + + await runtime.agent.run({ text: 'hi' }, { onEvent: vi.fn() }); + + await vi.waitFor(() => + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'permission-1', + reason: 'oversized', + errorName: 'MiniAppAgentError', + }), + 'Mini App Agent could not deny a dropped permission request', + ), + ); + runtime.close(); + await session.close(); + }); +}); + +describe('Mini App Agent thinking-off model over the Host Connector channel', () => { + const TURN_STARTED = { type: 'turn.started', sessionId: 'session-1', turnId: 'turn-1' } as const; + + it('round-trips variant "" (thinking off) in turn.started, results, run input and models', async () => { + const off = { providerId: 'p', modelId: 'm', variant: '' }; + const { agentGateway, runtime, session } = agentBridge({ + run: async (options) => { + options?.onEvent?.({ ...TURN_STARTED, model: off } as never); + return { ...AGENT_RESULT, model: off }; + }, + }); + agentGateway.models.mockResolvedValue({ + models: [{ providerId: 'p', modelId: 'm', displayName: 'M' }], + default: off, + host: off, + }); + session.activate(); + const events: MiniAppAgentRunEvent[] = []; + + const result = await runtime.agent.run( + { text: 'hi', model: off }, + { onEvent: (event) => events.push(event) }, + ); + + expect(agentGateway.run.mock.calls[0]?.[1]).toMatchObject({ model: off }); + expect(events).toEqual([{ ...TURN_STARTED, model: off }]); + expect(result.model).toEqual(off); + await expect(runtime.agent.models()).resolves.toMatchObject({ default: off, host: off }); + runtime.close(); + await session.close(); + }); +}); + +describe('Mini App Agent model, agents and create over the Host Connector channel', () => { + const MODEL = { providerId: 'p', modelId: 'm', variant: 'fast', thinking: { effort: 'high' } }; + const MODELS = { + models: [{ providerId: 'p', modelId: 'm', displayName: 'M', variants: ['fast'] }], + default: { providerId: 'p', modelId: 'm' }, + }; + const AGENTS = { agents: [{ name: 'mavis', displayName: 'Mavis', primary: true }] }; + const TURN_STARTED = { type: 'turn.started', sessionId: 'session-1', turnId: 'turn-1' } as const; + const CALLER = { pluginId: 'weather-board', processGeneration: 'process-1', source: 'process' }; + + it('hands run and answer model and agent to the Gateway and returns the dispatched model', async () => { + const result = { ...AGENT_RESULT, model: MODEL }; + const { agentGateway, runtime, session } = agentBridge({ run: async () => result }); + session.activate(); + const answer = { sessionId: 'session-1', requestId: 'question-1', answers: [] }; + + await expect(runtime.agent.run({ text: 'hi', model: MODEL, agent: 'writer' })).resolves.toEqual( + result, + ); + await runtime.agent.answer({ ...answer, model: MODEL }); + + expect(agentGateway.run.mock.calls[0]?.[1]).toEqual({ + text: 'hi', + model: MODEL, + agent: 'writer', + }); + expect(agentGateway.answer.mock.calls[0]?.[1]).toEqual({ ...answer, model: MODEL }); + runtime.close(); + await session.close(); + }); + + it('rejects a malformed model or agent before sending and fails the generation on a raw one', async () => { + const { agentGateway, childResponses, runtime, session } = agentBridge(); + const childFrames = sentPayloads(childResponses); + session.activate(); + for (const input of [ + { text: 'hi', model: { providerId: 'p' } }, + { text: 'hi', model: 'p/m' }, + { text: 'hi', agent: '' }, + { text: 'hi', agent: 3 }, + ]) { + await expect(runtime.agent.run(input as never)).rejects.toMatchObject({ + code: 'INVALID_ARGUMENTS', + }); + } + await expect( + runtime.agent.answer({ sessionId: 's', requestId: 'q', answers: [], model: {} } as never), + ).rejects.toMatchObject({ code: 'INVALID_ARGUMENTS' }); + await expect(runtime.agent.create({ agent: '' })).rejects.toMatchObject({ + code: 'INVALID_ARGUMENTS', + }); + expect(childFrames).toEqual([]); + runtime.close(); + await session.close(); + + for (const request of [ + { method: 'agent.run', params: { text: 'hi', model: { providerId: 'p' } } }, + { method: 'agent.run', params: { text: 'hi', agent: 1 } }, + { method: 'agent.create', params: { agent: '' } }, + { method: 'agent.models', params: { extra: true } }, + ]) { + const raw = agentBridge(); + raw.session.activate(); + raw.hostRequests.receive({ v: 1, id: '1', ...request }); + await vi.waitFor(() => expect(raw.fatal).toHaveBeenCalledOnce()); + expect(raw.agentGateway.run).not.toHaveBeenCalled(); + raw.runtime.close(); + await raw.session.close(); + } + expect(agentGateway.run).not.toHaveBeenCalled(); + }); + + it('serves models, agents and create as short requests without a receipt', async () => { + const { agentGateway, hostRequests, runtime, session } = agentBridge(); + agentGateway.models.mockResolvedValue(MODELS); + agentGateway.agents.mockResolvedValue(AGENTS); + agentGateway.create.mockResolvedValue({ sessionId: 'session-9' }); + const hostFrames = sentPayloads(hostRequests); + session.activate(); + + await expect(runtime.agent.models()).resolves.toEqual(MODELS); + await expect(runtime.agent.agents()).resolves.toEqual(AGENTS); + await expect(runtime.agent.create()).resolves.toEqual({ sessionId: 'session-9' }); + await expect(runtime.agent.create({ agent: 'writer' })).resolves.toEqual({ + sessionId: 'session-9', + }); + + expect(agentGateway.models).toHaveBeenCalledWith(CALLER); + expect(agentGateway.agents).toHaveBeenCalledWith(CALLER); + expect(agentGateway.create.mock.calls.map((call) => call[1])).toEqual([ + {}, + { agent: 'writer' }, + ]); + expect(hostFrames.every((frame) => frame.receipt === undefined && !('event' in frame))).toBe( + true, + ); + runtime.close(); + await session.close(); + }); + + it('projects models, agents and create results to the contract before they reach the child', async () => { + const { agentGateway, runtime, session } = agentBridge(); + const extra = { internal: 'secret' }; + agentGateway.models.mockResolvedValue({ + models: [ + { + providerId: 'p', + modelId: 'm', + displayName: 'M', + variants: ['fast'], + thinking: { efforts: ['low', 'high'], default: 'low', ...extra }, + ...extra, + }, + ], + default: { providerId: 'p', modelId: 'm', ...extra }, + host: { providerId: 'p', modelId: 'm', variant: 'fast', ...extra }, + ...extra, + } as never); + agentGateway.agents.mockResolvedValue({ + agents: [{ name: 'mavis', displayName: 'Mavis', primary: true, ...extra }], + ...extra, + } as never); + agentGateway.create.mockResolvedValue({ sessionId: 'session-9', ...extra } as never); + session.activate(); + + await expect(runtime.agent.models()).resolves.toEqual({ + models: [ + { + providerId: 'p', + modelId: 'm', + displayName: 'M', + variants: ['fast'], + thinking: { efforts: ['low', 'high'], default: 'low' }, + }, + ], + default: { providerId: 'p', modelId: 'm' }, + host: { providerId: 'p', modelId: 'm', variant: 'fast' }, + }); + await expect(runtime.agent.agents()).resolves.toEqual(AGENTS); + await expect(runtime.agent.create()).resolves.toEqual({ sessionId: 'session-9' }); + runtime.close(); + await session.close(); + }); + + it('keeps the model on turn.started events and drops one without a valid model', async () => { + const { runtime, session } = agentBridge({ + run: async (options) => { + options?.onEvent?.(TURN_STARTED as never); + options?.onEvent?.({ ...TURN_STARTED, model: { providerId: 'p' } } as never); + options?.onEvent?.({ ...TURN_STARTED, model: { ...MODEL, extra: 1 } } as never); + return { ...AGENT_RESULT, model: MODEL }; + }, + }); + session.activate(); + const events: MiniAppAgentRunEvent[] = []; + + const result = await runtime.agent.run( + { text: 'hi' }, + { onEvent: (event) => events.push(event) }, + ); + + expect(events).toEqual([{ ...TURN_STARTED, model: MODEL }]); + expect(result.model).toEqual(MODEL); + runtime.close(); + await session.close(); + }); + + it('rejects a turn.started or result the Host sends with a malformed model', async () => { + for (const frame of [ + { event: { ...TURN_STARTED, model: { modelId: 'm' } } }, + { event: TURN_STARTED }, + { ok: true, result: { ...AGENT_RESULT, model: { providerId: 'p' } } }, + ]) { + const input = new MemoryIpc(); + const onFatal = vi.fn(); + const client = createMiniAppHostConnectorRuntimeClient({ transport: input, onFatal }); + const pending = client.agent.run({ text: 'hi' }, { onEvent: vi.fn() }); + pending.catch(() => undefined); + input.receive({ v: 1, id: '1', receipt: 'admitted', invocationId: 'invocation-agent-1' }); + input.receive({ v: 1, id: '1', ...frame }); + await vi.waitFor(() => expect(onFatal).toHaveBeenCalledOnce()); + } + }); + + it('refuses models, agents and create without an allowlist policy', async () => { + const { agentGateway, runtime, session } = agentBridge({ policy: { kind: 'deny-all' } }); + session.activate(); + + for (const call of [ + () => runtime.agent.models(), + () => runtime.agent.agents(), + () => runtime.agent.create({ agent: 'writer' }), + ]) { + await expect(call()).rejects.toMatchObject({ code: 'NOT_PERMITTED', retryable: false }); + } + expect(agentGateway.models).not.toHaveBeenCalled(); + expect(agentGateway.create).not.toHaveBeenCalled(); + runtime.close(); + await session.close(); + }); +}); + +describe('Mini App Host Connector admission budget', () => { + it('reserves the permission-reply budget so ordinary connector traffic cannot consume it', () => { + const connectorPending = Array.from({ length: 16 }, () => ({ + method: 'connector.call' as const, + })); + expect(hasHostConnectorRequestCapacity('connector.call', connectorPending)).toBe(false); + expect(hasHostConnectorRequestCapacity('connector.list', connectorPending)).toBe(false); + // Ordinary requests fill only their own bucket; the reserved reply slot stays open. + expect(hasHostConnectorRequestCapacity('agent.reply', connectorPending)).toBe(true); + + const replyPending = Array.from({ length: 16 }, () => ({ method: 'agent.reply' as const })); + expect(hasHostConnectorRequestCapacity('agent.reply', replyPending)).toBe(false); + expect(hasHostConnectorRequestCapacity('connector.call', replyPending)).toBe(true); + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/host-runner.test.ts b/packages/local-runtime-v2/test/unit/miniapp/host-runner.test.ts new file mode 100644 index 000000000..9bb43c48a --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/host-runner.test.ts @@ -0,0 +1,590 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it, vi } from 'vitest'; + +import type { MiniAppIpcMessage } from '../../../src/service/miniapp/contracts.js'; +import { MINIAPP_RUNTIME_ERROR_TEXT_MAX_BYTES } from '../../../src/service/miniapp/errors.js'; +import type { HostConnectorClient } from '../../../src/service/miniapp/process/host-connector/host-connector.js'; +import { + isMiniAppRunnerInitMessage, + MiniAppRunnerError, + runMiniAppHostRunner, + startMiniAppModule, + type MiniAppRunnerChildMessage, + type MiniAppRunnerInitMessage, +} from '../../../src/service/miniapp/process/node-runtime/host-runner.js'; + +class FakeRunnerProcess extends EventEmitter { + connected = true; + exitCode?: number; + readonly messages: Array = []; + readonly disconnect = vi.fn(() => { + if (!this.connected) return; + this.connected = false; + this.emit('disconnect'); + }); + private readonly flushCallbacks: Array<(error: Error | null) => void> = []; + + constructor(private readonly autoFlush = true) { + super(); + } + + send( + message: MiniAppRunnerChildMessage | MiniAppIpcMessage, + callback?: (error: Error | null) => void, + ): boolean { + this.messages.push(message); + if (callback) { + if (this.autoFlush) queueMicrotask(() => callback(null)); + else this.flushCallbacks.push(callback); + } + return true; + } + + flushNext(): void { + this.flushCallbacks.shift()?.(null); + } +} + +const INIT: MiniAppRunnerInitMessage = { + type: 'miniapp:init', + protocolVersion: 1, + pluginId: 'weather-board', + processGeneration: 'process-1', + packageRoot: '/real/weather-board', + nodeEntry: 'dist/server.mjs', + dataDir: '/data/weather-board', + listen: { host: '127.0.0.1', port: 51_234 }, + readiness: { startupToken: 'startup-token', nodeDigest: 'node-digest' }, + loopbackToken: 'loopback-token', + hostConnectorPolicy: { kind: 'deny-all' }, + agentPolicy: { kind: 'deny-all' }, +}; +const allowlist = (maxConcurrent: unknown, capabilities = ['session.run']) => ({ + kind: 'allowlist', + capabilities, + maxConcurrent, +}); + +describe('Mini App runner init validation', () => { + it.each([ + { policy: null, valid: false }, + { policy: { kind: 'deny-all', providers: [] }, valid: false }, + { policy: { kind: 'deny-all' }, valid: true }, + { policy: { kind: 'allowlist', providers: 'weather' }, valid: false }, + { policy: { kind: 'allowlist', providers: [42] }, valid: false }, + { policy: { kind: 'allowlist', providers: ['weather'] }, valid: true }, + ])('accepts only an exact Host Connector policy envelope', ({ policy, valid }) => { + expect(isMiniAppRunnerInitMessage({ ...INIT, hostConnectorPolicy: policy })).toBe(valid); + }); + + it('requires a non-empty string loopback token', () => { + const withoutToken: Record = { ...INIT }; + delete withoutToken.loopbackToken; + expect(isMiniAppRunnerInitMessage(withoutToken)).toBe(false); + expect(isMiniAppRunnerInitMessage({ ...INIT, loopbackToken: '' })).toBe(false); + expect(isMiniAppRunnerInitMessage({ ...INIT, loopbackToken: 42 })).toBe(false); + expect(isMiniAppRunnerInitMessage({ ...INIT, loopbackToken: ['token'] })).toBe(false); + expect(isMiniAppRunnerInitMessage(INIT)).toBe(true); + }); + + it.each([ + { policy: undefined, valid: false }, + { policy: { kind: 'deny-all', capabilities: [] }, valid: false }, + { policy: { kind: 'deny-all' }, valid: true }, + { policy: allowlist(4, ['session.write']), valid: false }, + { policy: { kind: 'allowlist', capabilities: ['session.run'] }, valid: false }, + ...[0, 33, 2.5, '4', null].map((maxConcurrent) => ({ + policy: allowlist(maxConcurrent), + valid: false, + })), + ...[1, 4, 32].map((maxConcurrent) => ({ policy: allowlist(maxConcurrent), valid: true })), + ])('accepts only an exact Agent policy envelope', ({ policy, valid }) => { + expect(isMiniAppRunnerInitMessage({ ...INIT, agentPolicy: policy })).toBe(valid); + }); + + it('rejects an init message without the agentPolicy key', () => { + const legacy: Record = { ...INIT }; + delete legacy.agentPolicy; + expect(isMiniAppRunnerInitMessage(legacy)).toBe(false); + }); +}); + +describe('Mini App child runner Agent context', () => { + it.each([ + { + name: 'agent-only access', + hostConnectorPolicy: { kind: 'deny-all' }, + agentPolicy: { kind: 'allowlist', capabilities: ['session.run'], maxConcurrent: 4 }, + expected: { agent: true, hostConnector: false, channel: true }, + }, + { + name: 'no access', + hostConnectorPolicy: { kind: 'deny-all' }, + agentPolicy: { kind: 'deny-all' }, + expected: { agent: false, hostConnector: false, channel: false }, + }, + ] as const)( + 'injects only the allowed clients for $name', + async ({ hostConnectorPolicy, agentPolicy, expected }) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'miniapp-runner-agent-')); + const contextKey = `__miniappRunnerAgent_${path.basename(root)}`; + await writeFile( + path.join(root, 'server.mjs'), + `export async function start(context) { globalThis[${JSON.stringify(contextKey)}] = context; }\n`, + ); + const processPort = new FakeRunnerProcess(); + try { + runMiniAppHostRunner({ process: processPort }); + processPort.emit('message', { + ...INIT, + packageRoot: root, + nodeEntry: 'server.mjs', + hostConnectorPolicy, + agentPolicy, + }); + await vi.waitFor(() => + expect(processPort.messages).toContainEqual( + expect.objectContaining({ type: 'miniapp:started' }), + ), + ); + const context = (globalThis as Record)[contextKey] as { + readonly agent?: { run(input: { readonly text: string }): Promise }; + }; + expect('agent' in context).toBe(expected.agent); + expect('hostConnector' in context).toBe(expected.hostConnector); + expect(processPort.listenerCount('error')).toBe(expected.channel ? 1 : 0); + const pending = context.agent?.run({ text: 'hi' }); + const settled = pending?.catch((error: unknown) => error); + if (pending) { + await vi.waitFor(() => + expect(processPort.messages).toContainEqual({ + type: 'miniapp:connector', + payload: { v: 1, id: '1', method: 'agent.run', params: { text: 'hi' } }, + }), + ); + } + processPort.emit('message', { type: 'miniapp:stop' }); + await vi.waitFor(() => expect(processPort.disconnect).toHaveBeenCalledOnce()); + if (settled) { + await expect(settled).resolves.toMatchObject({ code: 'AGENT_OUTCOME_UNKNOWN' }); + } + } finally { + delete (globalThis as Record)[contextKey]; + await rm(root, { recursive: true, force: true }); + } + }, + ); + + it('fails closed when Agent access has no Host-owned client', async () => { + await expect( + startMiniAppModule( + { + ...INIT, + agentPolicy: { kind: 'allowlist', capabilities: ['session.run'], maxConcurrent: 4 }, + }, + { + resolveRealPath: async (target) => target, + loadModule: vi.fn().mockResolvedValue({ start: vi.fn() }), + createHostConnector: vi.fn(), + }, + ), + ).rejects.toMatchObject({ code: 'HOST_CONNECTOR_UNAVAILABLE' }); + }); +}); + +describe('Mini App child host runner', () => { + it('disconnects only after module cleanup and the stopped frame flush', async () => { + const processPort = new FakeRunnerProcess(false); + const stop = vi.fn().mockResolvedValue(undefined); + runMiniAppHostRunner({ process: processPort, start: async () => ({ stop }) }); + processPort.emit('message', INIT); + await vi.waitFor(() => { + expect(processPort.messages).toContainEqual( + expect.objectContaining({ type: 'miniapp:started' }), + ); + }); + + processPort.emit('message', { type: 'miniapp:stop' }); + await vi.waitFor(() => { + expect(stop).toHaveBeenCalledOnce(); + expect(processPort.messages).toContainEqual({ type: 'miniapp:stopped' }); + }); + expect(processPort.exitCode).toBeUndefined(); + expect(processPort.disconnect).not.toHaveBeenCalled(); + + processPort.flushNext(); + await vi.waitFor(() => expect(processPort.disconnect).toHaveBeenCalledOnce()); + expect(processPort.exitCode).toBe(0); + }); + + it('starts the named Node entry with a frozen Host context and disposes it once', async () => { + const messages: MiniAppRunnerChildMessage[] = []; + const dispose = vi.fn().mockResolvedValue(undefined); + const connector = { list: vi.fn(), call: vi.fn() } satisfies HostConnectorClient; + let observedSignal: AbortSignal | undefined; + const start = vi + .fn() + .mockImplementation( + (context: { + readonly signal: AbortSignal; + readonly logger: { info(message: string, fields?: Record): void }; + }) => { + expect(Object.isFrozen(context)).toBe(true); + observedSignal = context.signal; + context.logger.info('board.ready', { toolCount: 2 }); + return { dispose, metadata: { framework: 'custom' } }; + }, + ); + + const running = await startMiniAppModule(INIT, { + resolveRealPath: vi + .fn() + .mockImplementation(async (target: string) => + target.endsWith('server.mjs') + ? '/real/weather-board/dist/server.mjs' + : '/real/weather-board', + ), + loadModule: vi.fn().mockResolvedValue({ start }), + createHostConnector: vi.fn().mockReturnValue(connector), + emit: (message) => messages.push(message), + }); + + const context = start.mock.calls[0]?.[0] as Record; + expect(context).toEqual( + expect.objectContaining({ + pluginId: 'weather-board', + pluginRoot: '/real/weather-board', + dataDir: '/data/weather-board', + generationId: 'process-1', + listen: { host: '127.0.0.1', port: 51_234 }, + hostConnector: connector, + }), + ); + expect(context).not.toHaveProperty('startupToken'); + expect(context).not.toHaveProperty('nodeDigest'); + expect(context).not.toHaveProperty('loopbackToken'); + expect(JSON.stringify(context)).not.toContain(INIT.loopbackToken); + expect(messages).toContainEqual({ + type: 'miniapp:log', + level: 'info', + message: 'board.ready', + fieldKeys: ['toolCount'], + }); + expect(observedSignal?.aborted).toBe(false); + + await running.stop(); + await running.stop(); + expect(observedSignal?.aborted).toBe(true); + expect(dispose).toHaveBeenCalledOnce(); + }); + + it('fails closed when the resolved Node entry escapes the package artifact', async () => { + await expect( + startMiniAppModule(INIT, { + resolveRealPath: vi + .fn() + .mockResolvedValueOnce('/real/weather-board') + .mockResolvedValueOnce('/real/outside/server.mjs'), + loadModule: vi.fn(), + }), + ).rejects.toMatchObject({ code: 'ENTRY_OUTSIDE_ARTIFACT' }); + }); + + it.each<{ + readonly name: string; + readonly error: () => Error; + readonly expectedCode: string; + }>([ + { + name: 'an arbitrary plugin code', + error: () => Object.assign(new Error('plugin start failed'), { code: 'PLUGIN_PRIVATE_CODE' }), + expectedCode: 'RUNNER_START_FAILED', + }, + { + name: 'an address collision used by Host port retry', + error: () => Object.assign(new Error('address in use'), { code: 'EADDRINUSE' }), + expectedCode: 'EADDRINUSE', + }, + { + name: 'a Host-owned runner code', + error: () => + new MiniAppRunnerError('START_EXPORT_MISSING', 'Mini App Node entry must export start'), + expectedCode: 'START_EXPORT_MISSING', + }, + ])('reports $name as $expectedCode', async ({ error, expectedCode }) => { + const processPort = new FakeRunnerProcess(); + runMiniAppHostRunner({ + process: processPort, + start: async () => { + throw error(); + }, + }); + + processPort.emit('message', INIT); + + await vi.waitFor(() => { + expect(processPort.messages).toContainEqual( + expect.objectContaining({ type: 'miniapp:error', code: expectedCode }), + ); + }); + }); +}); + +/** The child harness is TypeScript here, so the real child runs it through the repo's tsx. */ +function tsxCli(): string { + return fileURLToPath(new URL('../../../../../node_modules/tsx/dist/cli.mjs', import.meta.url)); +} + +/** Workspace `@mavis/*` imports resolve through the standalone path map, not built `dist`. */ +function standaloneTsconfig(): string { + return fileURLToPath(new URL('../../../../../tsconfig.standalone.json', import.meta.url)); +} + +function runnerHarness(): string { + return fileURLToPath( + new URL('../../helpers/miniapp-host-runner-child.ts', import.meta.url), + ); +} + +describe('Mini App child host runner process exit', () => { + // A real child under tsx compiles the runtime graph before it can answer, so this case needs a + // budget larger than the default one; the assertions themselves stay the shipped ones. + it('naturally exits a Connector-enabled real child after stopped is flushed', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'miniapp-runner-connector-exit-')); + const packageRoot = path.join(root, 'package'); + await mkdir(packageRoot, { recursive: true }); + await writeFile( + path.join(packageRoot, 'server.mjs'), + 'export async function start() { return { async dispose() {} }; }\n', + ); + const child = spawn(process.execPath, [tsxCli(), '--tsconfig', standaloneTsconfig(), runnerHarness()], { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + }); + child.stderr?.on('data', (chunk: Buffer) => process.stderr.write(`[child] ${chunk}`)); + const originalKill = child.kill.bind(child); + const hostTermination = vi.spyOn(child, 'kill'); + + try { + child.send({ ...INIT, packageRoot, nodeEntry: 'server.mjs' }); + await waitForChildMessage(child, (message) => message.type === 'miniapp:started'); + child.send({ type: 'miniapp:stop' }); + await waitForChildMessage(child, (message) => message.type === 'miniapp:stopped'); + + await expect(waitForChildExit(child, 750)).resolves.toMatchObject({ code: 0, signal: null }); + expect(hostTermination).not.toHaveBeenCalled(); + } finally { + if (child.exitCode === null && child.signalCode === null) originalKill('SIGKILL'); + await new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) resolve(); + else child.once('exit', () => resolve()); + }); + await rm(root, { recursive: true, force: true }); + } + }, 30_000); + + it('naturally exits a real child after stopped is flushed without Host termination', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'miniapp-runner-exit-')); + const packageRoot = path.join(root, 'package'); + await mkdir(packageRoot, { recursive: true }); + await writeFile( + path.join(packageRoot, 'server.mjs'), + 'export async function start() { return { async dispose() {} }; }\n', + ); + const child = spawn(process.execPath, [tsxCli(), '--tsconfig', standaloneTsconfig(), runnerHarness()], { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + }); + child.stderr?.on('data', (chunk: Buffer) => process.stderr.write(`[child] ${chunk}`)); + const originalKill = child.kill.bind(child); + const hostTermination = vi.spyOn(child, 'kill'); + + try { + child.send({ + ...INIT, + packageRoot, + nodeEntry: 'server.mjs', + hostConnectorPolicy: { kind: 'deny-all' }, + }); + await waitForChildMessage(child, (message) => message.type === 'miniapp:started'); + child.send({ type: 'miniapp:stop' }); + await waitForChildMessage(child, (message) => message.type === 'miniapp:stopped'); + + await expect(waitForChildExit(child, 750)).resolves.toMatchObject({ code: 0, signal: null }); + expect(hostTermination).not.toHaveBeenCalled(); + } finally { + if (child.exitCode === null && child.signalCode === null) originalKill('SIGKILL'); + await new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) resolve(); + else child.once('exit', () => resolve()); + }); + await rm(root, { recursive: true, force: true }); + } + }, 30_000); +}); + +describe('Mini App child host runner diagnostics', () => { + it('reports a complete stack larger than 4 KiB without truncation', async () => { + const processPort = new FakeRunnerProcess(); + const error = new Error('plugin start failed'); + const stack = `Error: plugin start failed\n${' at plugin (server.mjs:1:1)\n'.repeat(256)}`; + error.stack = stack; + runMiniAppHostRunner({ + process: processPort, + start: async () => { + throw error; + }, + }); + + processPort.emit('message', INIT); + + await vi.waitFor(() => { + expect(processPort.messages).toContainEqual({ + type: 'miniapp:error', + code: 'RUNNER_START_FAILED', + errorText: stack, + }); + }); + expect(stack.length).toBeGreaterThan(4 * 1024); + }); + + it('bounds an oversized stack before sending it over child IPC', async () => { + const processPort = new FakeRunnerProcess(); + const error = new Error('plugin start failed'); + const stack = `Error: plugin start failed\n${' at plugin (server.mjs:1:1)\n'.repeat(4_096)}deepest-frame`; + error.stack = stack; + runMiniAppHostRunner({ + process: processPort, + start: async () => { + throw error; + }, + }); + + processPort.emit('message', INIT); + + await vi.waitFor(() => { + const failure = processPort.messages.find((message) => message.type === 'miniapp:error'); + expect(failure).toBeDefined(); + if (!failure || failure.type !== 'miniapp:error') return; + expect(Buffer.byteLength(failure.errorText, 'utf8')).toBeLessThanOrEqual( + MINIAPP_RUNTIME_ERROR_TEXT_MAX_BYTES, + ); + expect(failure.errorText.startsWith('Error: plugin start failed\n')).toBe(true); + expect(failure.errorText).toContain( + `[... runner error text truncated from ${Buffer.byteLength(stack, 'utf8')} bytes ...]`, + ); + expect(failure.errorText.endsWith('deepest-frame')).toBe(true); + }); + }); + + it('safely stringifies a non-string Error stack before sending it over child IPC', async () => { + const processPort = new FakeRunnerProcess(); + const error = new Error('plugin start failed'); + Object.defineProperty(error, 'stack', { + configurable: true, + value: { toString: () => 'structured plugin stack' }, + }); + runMiniAppHostRunner({ + process: processPort, + start: async () => { + throw error; + }, + }); + + processPort.emit('message', INIT); + + await vi.waitFor(() => { + expect(processPort.messages).toContainEqual({ + type: 'miniapp:error', + code: 'RUNNER_START_FAILED', + errorText: 'structured plugin stack', + }); + }); + }); + + it.each([ + { thrown: 'plain plugin failure', expected: 'plain plugin failure' }, + { + thrown: { + [Symbol.toPrimitive]() { + throw new Error('conversion failed'); + }, + }, + expected: 'Runner error could not be converted to text', + }, + ])('safely reports a non-Error throw as text', async ({ thrown, expected }) => { + const processPort = new FakeRunnerProcess(); + runMiniAppHostRunner({ + process: processPort, + start: async () => { + throw thrown; + }, + }); + + processPort.emit('message', INIT); + + await vi.waitFor(() => { + expect(processPort.messages).toContainEqual({ + type: 'miniapp:error', + code: 'RUNNER_START_FAILED', + errorText: expected, + }); + }); + }); +}); + +function waitForChildMessage( + child: ChildProcess, + predicate: (message: MiniAppRunnerChildMessage) => boolean, + timeoutMs = 5_000, +): Promise { + return new Promise((resolve, reject) => { + const finish = (error?: Error, message?: MiniAppRunnerChildMessage) => { + clearTimeout(timer); + child.removeListener('message', onMessage); + child.removeListener('exit', onExit); + if (error) reject(error); + else if (message) resolve(message); + }; + const onMessage = (message: unknown) => { + if (typeof message !== 'object' || message === null) return; + const candidate = message as MiniAppRunnerChildMessage; + if (predicate(candidate)) finish(undefined, candidate); + }; + const onExit = () => finish(new Error('Runner exited before the expected IPC message')); + const timer = setTimeout( + () => finish(new Error('Timed out waiting for the expected runner IPC message')), + timeoutMs, + ); + timer.unref(); + child.on('message', onMessage); + child.once('exit', onExit); + }); +} + +function waitForChildExit( + child: ChildProcess, + timeoutMs: number, +): Promise<{ readonly code: number | null; readonly signal: NodeJS.Signals | null }> { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.removeListener('exit', onExit); + reject(new Error(`Runner did not naturally exit within ${timeoutMs} ms`)); + }, timeoutMs); + timer.unref(); + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + clearTimeout(timer); + resolve({ code, signal }); + }; + child.once('exit', onExit); + }); +} diff --git a/packages/local-runtime-v2/test/unit/miniapp/installed-miniapp-inventory.test.ts b/packages/local-runtime-v2/test/unit/miniapp/installed-miniapp-inventory.test.ts new file mode 100644 index 000000000..cd30ffc85 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/installed-miniapp-inventory.test.ts @@ -0,0 +1,126 @@ +// The installed Mini App inventory, and what it must not include. +// +// Two properties are under test, and the second matters as much as the first: +// +// * a local Mini App the person **disabled** is still installed, so a management +// surface can show it and offer to enable it — the enabled-only projection that +// activation and publication use must keep excluding it, or a disabled package +// would become startable; +// * an ordinary plugin is **not** a Mini App. `capabilities.appCount` counts a +// package's `apps` references, which are a different contribution from +// `miniapp`, so a listing built on that count would misclassify plugins. The +// identification here reads the `miniapp` contribution itself. +// +// The snapshot is a literal rather than a scanned package tree: what is being +// pinned is the projection's rule, not the scanner. + +import { describe, expect, it } from "vitest"; + +import { + listEnabledMiniAppDefinitions, + listInstalledMiniAppDefinitions, +} from "../../../src/service/plugin-system/plugin/runtime/miniapp/candidate.js"; +import type { PluginSnapshot } from "../../../src/service/plugin-system/plugin/runtime/snapshot-builder.js"; + +/** A package that contributes a Mini App, or one that does not. */ +function plugin( + name: string, + options: { readonly source?: "LOCAL" | "OFFICIAL"; readonly miniapp?: boolean } = {}, +) { + const source = options.source ?? "LOCAL"; + return { + name, + source, + rootPath: `/packages/${name}`, + contentDigest: `digest-${name}`, + ...(options.miniapp === false + ? {} + : { + miniapp: { + contentDigest: `miniapp-digest-${name}`, + clientDigest: `client-digest-${name}`, + nodeDigest: `node-digest-${name}`, + surface: { path: "/dashboard" }, + runtime: { lifecycle: "on-demand", entry: "server/index.js" }, + mcpEndpoints: [], + }, + }), + }; +} + +function snapshot(input: { + readonly official?: readonly ReturnType[]; + readonly local?: readonly { readonly plugin: ReturnType; readonly enabled: boolean }[]; +}): PluginSnapshot { + return { + revision: "fixture", + officialPackages: (input.official ?? []).map((entry) => ({ + name: entry.name, + rootPath: entry.rootPath, + contentDigest: entry.contentDigest, + plugin: entry, + })), + localPlugins: (input.local ?? []).map((entry) => ({ + name: entry.plugin.name, + rootPath: entry.plugin.rootPath, + contentDigest: entry.plugin.contentDigest, + source: entry.plugin.source, + enabled: entry.enabled, + plugin: entry.plugin, + })), + enabledPlugins: [], + skills: [], + mcpServers: [], + diagnostics: [], + turnCapabilities: {}, + } as unknown as PluginSnapshot; +} + +describe("the installed Mini App inventory", () => { + const cases = snapshot({ + official: [plugin("official.app", { source: "OFFICIAL" })], + local: [ + // Installed and startable. + { plugin: plugin("local.enabled"), enabled: true }, + // Installed and disabled: the row the catalogue has to be able to enable. + { plugin: plugin("local.disabled"), enabled: false }, + // Installed, but not a Mini App at all. + { plugin: plugin("local.ordinary", { miniapp: false }), enabled: true }, + ], + }); + + it("lists a disabled local Mini App, marked disabled", () => { + const installed = listInstalledMiniAppDefinitions(cases); + expect(installed.map((definition) => [definition.candidate.pluginId, definition.enabled])).toEqual([ + ["local.disabled", false], + ["local.enabled", true], + ["official.app", true], + ]); + }); + + it("does not turn an ordinary plugin into a Mini App", () => { + const installed = listInstalledMiniAppDefinitions(cases); + expect(installed.map((definition) => definition.candidate.pluginId)).not.toContain("local.ordinary"); + }); + + it("keeps the startable projection free of a disabled package", () => { + // Activation, publication and the runtime projection all read this one, so a + // disabled Mini App appearing here would become startable. + const enabled = listEnabledMiniAppDefinitions(cases); + expect(enabled.map((definition) => definition.candidate.pluginId)).toEqual([ + "local.enabled", + "official.app", + ]); + }); + + it("reads the same package as a Mini App in both projections", () => { + // One walk behind both, so a package cannot be a Mini App for one listing and + // not for the other. + const installed = new Set( + listInstalledMiniAppDefinitions(cases).map((definition) => definition.candidate.pluginId), + ); + for (const definition of listEnabledMiniAppDefinitions(cases)) { + expect(installed.has(definition.candidate.pluginId)).toBe(true); + } + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/ipc-transport.test.ts b/packages/local-runtime-v2/test/unit/miniapp/ipc-transport.test.ts new file mode 100644 index 000000000..b4823ef22 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/ipc-transport.test.ts @@ -0,0 +1,221 @@ +import { EventEmitter } from 'node:events'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { MiniAppIpcMessage } from '../../../src/service/miniapp/contracts.js'; +import { + bindMiniAppIpcChannel, + encodeMiniAppIpcMessage, + sendMiniAppIpcMessage, +} from '../../../src/service/miniapp/process/node-runtime/ipc-transport.js'; + +class FakeIpc extends EventEmitter { + connected = true; + send = vi.fn<(message: MiniAppIpcMessage, callback: (error: Error | null) => void) => boolean>(); +} + +describe('Mini App IPC transport', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('copies JSON payloads before queuing and enforces the UTF-8 payload byte limit', () => { + const payload = { arguments: { text: '你好' } }; + const encoded = encodeMiniAppIpcMessage('miniapp:connector', payload, 256); + payload.arguments.text = 'changed'; + expect(encoded.payload).toEqual({ arguments: { text: '你好' } }); + expect(() => encodeMiniAppIpcMessage('miniapp:connector', payload, 8)).toThrow('byte limit'); + expect(() => encodeMiniAppIpcMessage('miniapp:connector', { number: 1n }, 256)).toThrow( + 'valid JSON', + ); + expect(() => encodeMiniAppIpcMessage('miniapp:connector', undefined, 256)).toThrow( + 'valid JSON', + ); + }); + + it.each([ + ['request', 256 * 1024], + ['response', 8 * 1024 * 1024], + ] as const)('preserves the full %s payload byte budget in both directions', (_kind, maxBytes) => { + // {"text":""} is 11 bytes, and the first character contributes three UTF-8 bytes. + const payload = { text: `界${'a'.repeat(maxBytes - 14)}` }; + const oversized = { text: `${payload.text}a` }; + expect(Buffer.byteLength(JSON.stringify(payload), 'utf8')).toBe(maxBytes); + expect(Buffer.byteLength(JSON.stringify(oversized), 'utf8')).toBe(maxBytes + 1); + expect(encodeMiniAppIpcMessage('miniapp:connector', payload, maxBytes)).toEqual({ + type: 'miniapp:connector', + payload, + }); + expect(() => encodeMiniAppIpcMessage('miniapp:connector', oversized, maxBytes)).toThrow( + 'byte limit', + ); + + const transport = new FakeIpc(); + const onMessage = vi.fn(); + const onFatal = vi.fn(); + const detach = bindMiniAppIpcChannel(transport, { + type: 'miniapp:connector', + maxBytes, + onMessage, + onFatal, + }); + transport.emit('message', { type: 'miniapp:connector', payload }); + expect(onMessage).toHaveBeenCalledWith(payload); + expect(onFatal).not.toHaveBeenCalled(); + transport.emit('message', { type: 'miniapp:connector', payload: oversized }); + expect(onMessage).toHaveBeenCalledOnce(); + expect(onFatal).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('byte limit') }), + ); + detach(); + }); + + it('routes messages by type, checks envelope and size, and removes channel listeners', () => { + const transport = new FakeIpc(); + const onMessage = vi.fn(); + const onFatal = vi.fn(); + const detach = bindMiniAppIpcChannel(transport, { + type: 'miniapp:connector', + maxBytes: 128, + onMessage, + onFatal, + }); + transport.emit('message', { type: 'miniapp:log', message: 'ignored' }); + transport.emit('message', { type: 'miniapp:started' }); + transport.emit('message', { type: 'miniapp:connector', payload: { ok: true } }); + expect(onMessage).toHaveBeenCalledOnce(); + expect(onMessage).toHaveBeenCalledWith({ ok: true }); + transport.emit('message', { type: 'miniapp:connector', payload: null, extra: true }); + transport.emit('message', { type: 'miniapp:connector', payload: '界'.repeat(128) }); + transport.emit('disconnect'); + transport.emit('error', new Error('native IPC failed')); + expect(onFatal).toHaveBeenCalledTimes(4); + detach(); + expect(transport.eventNames()).toEqual([]); + }); + + it.each([true, false])('waits for the send callback when accepted=%s', async (accepted) => { + vi.useFakeTimers(); + const transport = new FakeIpc(); + let complete!: (error: Error | null) => void; + transport.send.mockImplementation((_message, callback) => { + complete = callback; + return accepted; + }); + const onSend = vi.fn(); + let resolved = false; + const pending = (async () => { + await sendMiniAppIpcMessage(transport, { type: 'miniapp:connector', payload: {} }, onSend); + resolved = true; + })(); + await Promise.resolve(); + expect(resolved).toBe(false); + expect(onSend).toHaveBeenCalledOnce(); + complete(null); + await pending; + expect(resolved).toBe(true); + expect(transport.eventNames()).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each([true, false])('bounds a stalled send callback when accepted=%s', async (accepted) => { + vi.useFakeTimers(); + const transport = new FakeIpc(); + let complete!: (error: Error | null) => void; + transport.send.mockImplementation((_message, callback) => { + complete = callback; + return accepted; + }); + const onSend = vi.fn(); + let outcome: unknown = 'pending'; + const observed = (async () => { + try { + await sendMiniAppIpcMessage(transport, { type: 'miniapp:connector', payload: {} }, onSend); + outcome = 'resolved'; + } catch (error) { + outcome = error; + } + })(); + try { + await vi.advanceTimersByTimeAsync(29_999); + expect(outcome).toBe('pending'); + await vi.advanceTimersByTimeAsync(1); + expect(outcome).toEqual(new Error('Mini App IPC send timed out')); + expect(onSend).toHaveBeenCalledOnce(); + expect(transport.send).toHaveBeenCalledOnce(); + expect(transport.eventNames()).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + const timeout = outcome; + complete(null); + complete(new Error('late callback failure')); + await observed; + expect(outcome).toBe(timeout); + } finally { + complete(null); + await observed; + } + }); + + it('removes attempted channel listeners when registration throws midway', () => { + const transport = new FakeIpc(); + const existing = vi.fn(); + transport.on('message', existing); + const originalOn = transport.on.bind(transport); + vi.spyOn(transport, 'on').mockImplementation((event, listener) => { + originalOn(event, listener); + if (event === 'disconnect') throw new Error('private native registration failure'); + return transport; + }); + const onFatal = vi.fn(); + expect(() => + bindMiniAppIpcChannel(transport, { + type: 'miniapp:connector', + maxBytes: 128, + onMessage: vi.fn(), + onFatal, + }), + ).toThrow('Mini App IPC channel registration failed'); + expect(transport.listeners('message')).toEqual([existing]); + expect(transport.listenerCount('disconnect')).toBe(0); + expect(transport.listenerCount('error')).toBe(0); + expect(onFatal).not.toHaveBeenCalled(); + }); + + it.each(['disconnect', 'error', 'callback'] as const)( + 'settles pending sends on %s', + async (failure) => { + vi.useFakeTimers(); + const transport = new FakeIpc(); + let complete!: (error: Error | null) => void; + transport.send.mockImplementation((_message, callback) => { + complete = callback; + return false; + }); + const pending = sendMiniAppIpcMessage(transport, { type: 'miniapp:connector', payload: {} }); + const rejected = expect(pending).rejects.toThrow(); + if (failure === 'callback') complete(new Error('native callback failed')); + else transport.emit(failure); + await rejected; + expect(transport.eventNames()).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + complete(null); + }, + ); + + it('does not claim a handoff when send throws or the channel is already closed', async () => { + vi.useFakeTimers(); + const transport = new FakeIpc(); + const onSend = vi.fn(); + transport.send.mockImplementation(() => { + throw new Error('native send threw'); + }); + const message = { type: 'miniapp:connector' as const, payload: {} }; + await expect(sendMiniAppIpcMessage(transport, message, onSend)).rejects.toThrow('send failed'); + expect(onSend).not.toHaveBeenCalled(); + expect(transport.eventNames()).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + transport.connected = false; + await expect(sendMiniAppIpcMessage(transport, message)).rejects.toThrow('closed'); + expect(transport.send).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts b/packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts new file mode 100644 index 000000000..5c35bf915 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts @@ -0,0 +1,382 @@ +import { spawn } from 'node:child_process'; +import http from 'node:http'; +import { createServer as createNetServer, connect as connectNet, type Socket } from 'node:net'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + MINIAPP_LOOPBACK_HEALTH_PATH, + MINIAPP_LOOPBACK_TOKEN_HEADER, +} from '../../../src/service/miniapp/contracts.js'; +import { probeMiniAppPortOwnership } from '../../../src/service/miniapp/process/node-runtime/loopback-ownership.js'; +import { waitForLoopbackReadiness } from '../../../src/service/miniapp/process/node-runtime/loopback-readiness.js'; +import { + isMiniAppRunnerInitMessage, + startMiniAppModule, + type MiniAppRunnerInitMessage, + type RunningMiniAppModule, +} from '../../../src/service/miniapp/process/node-runtime/host-runner.js'; + +/** + * The guard patches `http.Server.prototype.emit` process-wide and has no uninstall, which is + * exactly how it behaves inside a Mini App process: one credential, one port, every server in + * that process. So this file installs one guarded plugin server and interrogates it, rather than + * starting a fresh one per case. + */ +const TOKEN = 'test-loopback-credential-0123456789'; + +interface ObservedRequest { + readonly headers: http.IncomingHttpHeaders; + readonly rawHeaders: readonly string[]; + readonly url: string; +} + +const observed: ObservedRequest[] = []; +let server: RunningMiniAppModule | undefined; +let port = 0; +let packageRoot = ''; + +async function freePort(): Promise { + const probe = createNetServer(); + await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve)); + const address = probe.address(); + const allocated = typeof address === 'object' && address ? address.port : 0; + await new Promise((resolve) => probe.close(() => resolve())); + return allocated; +} + +function initMessage(): MiniAppRunnerInitMessage { + return { + type: 'miniapp:init', + protocolVersion: 1, + pluginId: 'fixture.miniapp', + processGeneration: 'generation-1', + packageRoot, + nodeEntry: 'miniapp/node/server.js', + dataDir: packageRoot, + listen: { host: '127.0.0.1', port }, + readiness: { startupToken: 'startup-token', nodeDigest: 'node-digest' }, + loopbackToken: TOKEN, + hostConnectorPolicy: { kind: 'deny-all' }, + agentPolicy: { kind: 'deny-all' }, + }; +} + +beforeAll(async () => { + packageRoot = await mkdtemp(join(tmpdir(), 'miniapp guard ')); + // The runner resolves the declared entry for real before the injected loader runs. + const entry = join(packageRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(entry), { recursive: true }); + await writeFile(entry, 'export const start = async () => undefined;\n'); + port = await freePort(); + server = await startMiniAppModule(initMessage(), { + loadModule: async () => ({ + start: async (context: { readonly listen: { host: string; port: number } }) => { + const pluginServer = http.createServer((request, response) => { + observed.push({ + headers: { ...request.headers }, + rawHeaders: [...request.rawHeaders], + url: request.url ?? '', + }); + response.writeHead(200, { 'content-type': 'text/plain' }).end('plugin'); + }); + const upgraded = new Set(); + pluginServer.on('upgrade', (request, socket) => { + upgraded.add(socket); + socket.once('close', () => upgraded.delete(socket)); + observed.push({ + headers: { ...request.headers }, + rawHeaders: [...request.rawHeaders], + url: request.url ?? '', + }); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n', + ); + }); + await new Promise((resolve, reject) => { + pluginServer.once('error', reject); + pluginServer.listen(context.listen.port, context.listen.host, resolve); + }); + return { + async dispose() { + // An upgraded socket leaves the server's own tracking; the fixture ends it here. + for (const socket of upgraded) socket.destroy(); + pluginServer.closeAllConnections(); + await new Promise((resolve) => pluginServer.close(() => resolve())); + }, + }; + }, + }), + }); +}); + +afterAll(async () => { + await server?.stop(); + await rm(packageRoot, { recursive: true, force: true }); +}); + +interface Response { + readonly status: number; + readonly body: string; +} + +function request(input: { + readonly path: string; + readonly token?: string; + readonly host?: string; +}): Promise { + return new Promise((resolve, reject) => { + const outgoing = http.request( + { + host: '127.0.0.1', + port, + method: 'GET', + path: input.path, + // A kept-alive socket would hold the fixture server open past the last assertion. + agent: false, + headers: { + ...(input.token === undefined ? {} : { [MINIAPP_LOOPBACK_TOKEN_HEADER]: input.token }), + ...(input.host === undefined ? {} : { host: input.host }), + }, + }, + (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + body += chunk; + }); + response.on('end', () => resolve({ status: response.statusCode ?? 0, body })); + }, + ); + outgoing.on('error', reject); + outgoing.end(); + }); +} + +/** Resolves with the first bytes the server sends, or 'closed' when the socket is destroyed. */ +function upgradeOutcome(token?: string): Promise { + return new Promise((resolve, reject) => { + const socket = connectNet({ host: '127.0.0.1', port }); + let received = ''; + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(received === '' ? 'closed' : received); + }; + socket.once('connect', () => { + const extra = token === undefined ? '' : `${MINIAPP_LOOPBACK_TOKEN_HEADER}: ${token}\r\n`; + socket.write( + `GET /ws HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n${extra}\r\n`, + ); + }); + socket.on('data', (chunk) => { + received += chunk.toString('utf8'); + finish(); + }); + socket.once('error', finish); + socket.once('end', finish); + socket.once('close', finish); + socket.setTimeout(2_000, () => { + socket.destroy(); + reject(new Error('upgrade outcome timed out')); + }); + }); +} + +describe('Mini App loopback guard', () => { + it('answers an unauthenticated request with 401 and no body', async () => { + const response = await request({ path: '/' }); + + expect(response.status).toBe(401); + expect(response.body).toBe(''); + }); + + it('answers a request carrying the wrong credential with 401', async () => { + const response = await request({ path: '/', token: 'not-the-credential' }); + + expect(response.status).toBe(401); + expect(response.body).toBe(''); + }); + + it('rejects the right credential on the wrong host', async () => { + const response = await request({ path: '/', token: TOKEN, host: `localhost:${port}` }); + + expect(response.status).toBe(401); + }); + + it('reaches the plugin handler with the right credential and hides the header from it', async () => { + observed.length = 0; + const response = await request({ path: '/dashboard', token: TOKEN }); + + expect(response.status).toBe(200); + expect(response.body).toBe('plugin'); + expect(observed).toHaveLength(1); + expect(observed[0]?.url).toBe('/dashboard'); + expect(observed[0]?.headers).not.toHaveProperty(MINIAPP_LOOPBACK_TOKEN_HEADER); + expect( + observed[0]?.rawHeaders.some( + (value) => value.toLowerCase() === MINIAPP_LOOPBACK_TOKEN_HEADER, + ), + ).toBe(false); + }); + + it('answers the reserved health path itself, before the plugin handler', async () => { + observed.length = 0; + const authenticated = await request({ path: MINIAPP_LOOPBACK_HEALTH_PATH, token: TOKEN }); + + expect(authenticated.status).toBe(204); + expect(observed).toHaveLength(0); + + const anonymous = await request({ path: MINIAPP_LOOPBACK_HEALTH_PATH }); + expect(anonymous.status).toBe(401); + expect(observed).toHaveLength(0); + }); + + it('destroys an upgrade without the credential and forwards one that carries it', async () => { + observed.length = 0; + expect(await upgradeOutcome()).toBe('closed'); + expect(observed).toHaveLength(0); + + const forwarded = await upgradeOutcome(TOKEN); + expect(forwarded).toContain('101'); + expect(observed).toHaveLength(1); + expect(observed[0]?.headers).not.toHaveProperty(MINIAPP_LOOPBACK_TOKEN_HEADER); + }); +}); + +describe('runner init message', () => { + it('requires a non-empty loopback credential', () => { + expect(isMiniAppRunnerInitMessage(initMessage())).toBe(true); + + const withoutToken: Record = { ...initMessage() }; + delete withoutToken.loopbackToken; + expect(isMiniAppRunnerInitMessage(withoutToken)).toBe(false); + expect(isMiniAppRunnerInitMessage({ ...initMessage(), loopbackToken: '' })).toBe(false); + expect(isMiniAppRunnerInitMessage({ ...initMessage(), loopbackToken: 42 })).toBe(false); + expect(isMiniAppRunnerInitMessage({ ...initMessage(), loopbackToken: ['token'] })).toBe(false); + }); +}); + +describe('Mini App loopback readiness', () => { + function readiness(port: number, token: string, timeoutMs = 2_000) { + return waitForLoopbackReadiness({ + port, + token, + deadline: Date.now() + timeoutMs, + nowMs: Date.now, + probeTimeoutMs: 500, + retryMs: 25, + wait: (ms, signal) => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener('abort', () => { + clearTimeout(timer); + reject(signal.reason); + }); + }), + signal: new AbortController().signal, + }); + } + + it('passes when the guard rejects the anonymous probe and answers the credentialed one', async () => { + await expect(readiness(port, TOKEN)).resolves.toBeUndefined(); + }); + + it('fails when the credentialed probe is rejected', async () => { + await expect(readiness(port, 'wrong-credential')).rejects.toMatchObject({ + code: 'READINESS_PROBE_REJECTED', + message: 'authenticated health probe expected 204, received 401', + }); + }); + + it('fails when an anonymous probe is not rejected', async () => { + const plainPort = await freePort(); + const script = [ + "const http = require('node:http');", + 'http', + ' .createServer((_request, response) => response.writeHead(200).end())', + ` .listen(${plainPort}, '127.0.0.1');`, + ].join('\n'); + const child = spawn(process.execPath, ['-e', script], { stdio: 'ignore' }); + try { + await new Promise((resolve) => setTimeout(resolve, 300)); + await expect(readiness(plainPort, TOKEN)).rejects.toMatchObject({ + code: 'READINESS_PROBE_REJECTED', + message: 'unauthenticated health probe expected 401, received 200', + }); + } finally { + child.kill('SIGKILL'); + } + }); + + it('times out when nothing is listening', async () => { + const closedPort = await freePort(); + await expect(readiness(closedPort, TOKEN, 150)).rejects.toMatchObject({ + code: 'READINESS_TIMEOUT', + }); + }); +}); + +describe('Mini App port ownership', () => { + const probe = (target: number) => + probeMiniAppPortOwnership({ + port: target, + timeoutMs: 500, + signal: new AbortController().signal, + }); + + it('reports a closed port as free', async () => { + expect(await probe(await freePort())).toBe('free'); + }); + + it('reports a port whose guard rejects an anonymous request as guarded', async () => { + expect(await probe(port)).toBe('guarded'); + }); + + it('reports a port held by something that does not answer like the guard as foreign', async () => { + const plainPort = await freePort(); + const script = [ + "const http = require('node:http');", + 'http', + ' .createServer((_request, response) => response.writeHead(200).end())', + ` .listen(${plainPort}, '127.0.0.1');`, + ].join('\n'); + const child = spawn(process.execPath, ['-e', script], { stdio: 'ignore' }); + try { + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(await probe(plainPort)).toBe('foreign'); + } finally { + child.kill('SIGKILL'); + } + }); + + it('reports a stalled listener as unknown rather than free', async () => { + const stalledPort = await freePort(); + const sockets = new Set(); + const stalledServer = createNetServer((socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + stalledServer.once('error', reject); + stalledServer.listen(stalledPort, '127.0.0.1', resolve); + }); + try { + await expect( + probeMiniAppPortOwnership({ + port: stalledPort, + timeoutMs: 10, + signal: new AbortController().signal, + }), + ).resolves.toBe('unknown'); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => stalledServer.close(() => resolve())); + } + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-application.test.ts b/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-application.test.ts new file mode 100644 index 000000000..d4b1fd77b --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-application.test.ts @@ -0,0 +1,109 @@ +/** + * The client-facing agent capability: what the application facade offers a caller, and what it + * withholds. + * + * The gateway owns admission, run slots and the session ports. The facade is the only thing a client + * sees, so it must carry the nine operations and none of the gateway's own surface — no `bind`, no + * `registerArena`, no ports. + */ +import { describe, expect, it, vi } from 'vitest'; + +import { createProcessLocalMiniAppAgentApplication } from '../../../src/application/session/process-local-miniapp-agent-application.js'; + +type Gateway = Parameters[0]; + +function gatewayDouble() { + return { + run: vi.fn(async () => ({ + sessionId: 'session-1', + turnId: 'turn-1', + status: 'finished' as const, + text: 'reply', + })), + models: vi.fn(async () => ({ models: [], default: { providerId: 'p', modelId: 'm' } })), + agents: vi.fn(async () => ({ agents: [] })), + create: vi.fn(async () => ({ sessionId: 'session-2' })), + reply: vi.fn(async () => undefined), + answer: vi.fn(async () => ({ sessionId: 'session-1', status: 'finished' as const })), + open: vi.fn(async () => ({ sessionId: 'session-1', hostSessionId: 'chat-1' })), + panelOpened: vi.fn(async () => undefined), + panelClosed: vi.fn(async () => undefined), + }; +} + +const CALLER = { pluginId: 'demo.board', runId: 'gen-1', hostSessionId: 'chat-1' } as const; + +describe('createProcessLocalMiniAppAgentApplication', () => { + it('exposes exactly the client operations, without the gateway or its ports', () => { + const facade = createProcessLocalMiniAppAgentApplication(gatewayDouble() as unknown as Gateway); + + expect(Object.keys(facade).sort()).toEqual([ + 'agents', + 'answer', + 'create', + 'models', + 'open', + 'panelClosed', + 'panelOpened', + 'reply', + 'run', + ]); + expect(facade).not.toHaveProperty('bind'); + expect(facade).not.toHaveProperty('registerArena'); + expect(facade).not.toHaveProperty('ports'); + expect(facade).not.toHaveProperty('subscribePermissionAsks'); + }); + + it('turns the trusted tab identity into a page caller for every operation', async () => { + const gateway = gatewayDouble(); + const facade = createProcessLocalMiniAppAgentApplication(gateway as unknown as Gateway); + + await facade.run({ caller: CALLER, text: 'hi' }); + await facade.models({ caller: CALLER }); + await facade.create({ caller: CALLER, agent: 'writer' }); + await facade.reply({ caller: CALLER, sessionId: 's', requestId: 'r', decision: 'deny' }); + await facade.open({ caller: CALLER, sessionId: 's' }); + + const pageCaller = { + pluginId: 'demo.board', + processGeneration: 'gen-1', + source: 'page', + hostSessionId: 'chat-1', + }; + expect(gateway.run).toHaveBeenCalledWith(pageCaller, { text: 'hi' }, {}); + expect(gateway.models).toHaveBeenCalledWith(pageCaller); + expect(gateway.create).toHaveBeenCalledWith(pageCaller, { agent: 'writer' }); + expect(gateway.reply).toHaveBeenCalledWith(pageCaller, { + sessionId: 's', + requestId: 'r', + decision: 'deny', + }); + expect(gateway.open).toHaveBeenCalledWith(pageCaller, { sessionId: 's' }); + }); + + it('carries the caller abort signal and event listener into the run it starts', async () => { + const gateway = gatewayDouble(); + const facade = createProcessLocalMiniAppAgentApplication(gateway as unknown as Gateway); + const controller = new AbortController(); + const onEvent = vi.fn(); + + await facade.run({ + caller: CALLER, + text: 'hi', + sessionId: 'session-9', + model: { providerId: 'minimax', modelId: 'M3' }, + signal: controller.signal, + onEvent, + }); + + expect(gateway.run).toHaveBeenCalledWith( + expect.objectContaining({ pluginId: 'demo.board', processGeneration: 'gen-1' }), + { + text: 'hi', + sessionId: 'session-9', + model: { providerId: 'minimax', modelId: 'M3' }, + }, + { signal: controller.signal, onEvent }, + ); + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-gateway.test.ts b/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-gateway.test.ts new file mode 100644 index 000000000..37f930bf2 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-gateway.test.ts @@ -0,0 +1,1872 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { MINIAPP_AGENT_MAX_TEXT_LENGTH } from '@mavis/shared/miniapp-surface'; +import { describe, expect, it, vi } from 'vitest'; + +import { + MINIAPP_AGENT_DEFAULT_CONCURRENCY, + MINIAPP_AGENT_QUEUE_FACTOR, + MiniAppAgentError, + type MiniAppAgentArena, + type MiniAppAgentCaller, + type MiniAppAgentRunEvent, + type MiniAppAgentSessionPorts, + type MiniAppPermissionAsk, +} from '../../../src/service/miniapp/agent/contracts.js'; +import { createMiniAppAgentGateway } from '../../../src/service/miniapp/agent/gateway.js'; + +const ARENA: MiniAppAgentArena = { + pluginId: 'demo board', + processGeneration: 'gen-1', + policy: { + kind: 'allowlist', + capabilities: ['session.run'], + maxConcurrent: MINIAPP_AGENT_DEFAULT_CONCURRENCY, + }, +}; +const DEFAULT_MODEL = { providerId: 'minimax', modelId: 'M3' }; +const MODEL_OPTIONS = [{ providerId: 'minimax', modelId: 'M3', displayName: 'MiniMax M3' }]; +const AGENT_OPTIONS = [ + { name: 'mavis', displayName: 'Mavis', primary: true }, + { name: 'writer', displayName: 'Writer', primary: false }, +]; +const DEFAULT_QUEUE_BOUND = MINIAPP_AGENT_DEFAULT_CONCURRENCY * MINIAPP_AGENT_QUEUE_FACTOR; +const CALLER: MiniAppAgentCaller = { + pluginId: 'demo board', + processGeneration: 'gen-1', + source: 'process', +}; + +describe('createMiniAppAgentGateway', () => { + it('fails closed as unavailable until ports are bound', async () => { + const { gateway } = setup({ bind: false }); + gateway.registerArena(ARENA); + + await expect(gateway.run(CALLER, { text: 'hi' })).rejects.toMatchObject({ + code: 'AGENT_UNAVAILABLE', + }); + }); + + it.each([ + ['no arena', undefined, 'no-arena'], + ['a deny-all policy', { ...ARENA, policy: { kind: 'deny-all' } }, 'policy'], + [ + 'a policy without session.run', + { ...ARENA, policy: { kind: 'allowlist', capabilities: [] } }, + 'policy', + ], + ] as const)('rejects %s as NOT_PERMITTED', async (_label, arena, reason) => { + const { gateway, ports, logger } = setup(); + if (arena) gateway.registerArena(arena as MiniAppAgentArena); + + await expect(gateway.run(CALLER, { text: 'hi' })).rejects.toMatchObject({ + code: 'NOT_PERMITTED', + }); + expect(ports.createSession).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ phase: 'rejected', errorCode: 'NOT_PERMITTED', reason }), + 'Mini App Agent run', + ); + }); + + it('rejects a caller of an older generation as retryable unavailable', async () => { + const { gateway, ports, logger } = setup(); + gateway.registerArena(ARENA); + + await expect( + gateway.run({ ...CALLER, processGeneration: 'gen-0' }, { text: 'hi' }), + ).rejects.toMatchObject({ code: 'AGENT_UNAVAILABLE', retryable: true }); + expect(ports.createSession).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + processGeneration: 'gen-0', + errorCode: 'AGENT_UNAVAILABLE', + reason: 'generation-mismatch', + }), + 'Mini App Agent run', + ); + }); + + it.each(['', ' ', 42, 'x'.repeat(MINIAPP_AGENT_MAX_TEXT_LENGTH + 1)])( + 'rejects invalid text as INVALID_ARGUMENTS (%#)', + async (text) => { + const { gateway, logger } = setup(); + gateway.registerArena(ARENA); + + await expect(gateway.run(CALLER, { text: text as string })).rejects.toMatchObject({ + code: 'INVALID_ARGUMENTS', + }); + expect(logger.warn).toHaveBeenCalledWith( + { + pluginId: 'demo board', + processGeneration: 'gen-1', + source: 'process', + phase: 'rejected', + errorCode: 'INVALID_ARGUMENTS', + }, + 'Mini App Agent run', + ); + }, + ); + + it('creates a session in the plugin agent workspace and audits admission and terminal', async () => { + const { gateway, ports, logger } = setup(); + gateway.registerArena(ARENA); + + await expect(gateway.run(CALLER, { text: 'hi' })).resolves.toEqual({ + sessionId: 'session-new', + turnId: 'turn-1', + status: 'finished', + text: 'reply', + }); + expect(ports.createSession).toHaveBeenCalledWith({ + pluginId: 'demo board', + agentName: 'mavis', + workspaceDir: join('/data', 'agents', 'mavis', 'miniapps', 'demo%20board'), + createDir: true, + model: DEFAULT_MODEL, + }); + expect(ports.resolveHostSession).not.toHaveBeenCalled(); + expect(logger.info.mock.calls).toEqual([ + [ + expect.objectContaining({ + pluginId: 'demo board', + sessionId: 'session-new', + agentName: 'mavis', + model: 'minimax/M3', + phase: 'admission', + }), + 'Mini App Agent run', + ], + [ + expect.objectContaining({ phase: 'terminal', status: 'finished', durationMs: 0 }), + 'Mini App Agent run', + ], + ]); + }); + + it('continues an owned session and hides sessions owned by others', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockImplementation(async (sessionId) => + sessionId === 'mine' ? 'demo board' : sessionId === 'theirs' ? 'other' : undefined, + ); + gateway.registerArena(ARENA); + + await expect(gateway.run(CALLER, { text: 'hi', sessionId: 'mine' })).resolves.toMatchObject({ + sessionId: 'mine', + }); + for (const sessionId of ['theirs', 'missing']) { + await expect(gateway.run(CALLER, { text: 'hi', sessionId })).rejects.toMatchObject({ + code: 'SESSION_NOT_FOUND', + }); + } + expect(ports.createSession).not.toHaveBeenCalled(); + expect(ports.runTurn).toHaveBeenCalledTimes(1); + }); + + it('aborts in-flight runs on release and rejects later calls', async () => { + const { gateway, ports } = setup(); + ports.runTurn.mockImplementation(({ signal }) => untilAborted(signal)); + const release = gateway.registerArena(ARENA); + + const run = gateway.run(CALLER, { text: 'hi' }); + await vi.waitFor(() => expect(ports.runTurn).toHaveBeenCalled()); + release(); + + await expect(run).resolves.toMatchObject({ status: 'aborted' }); + await expect(gateway.run(CALLER, { text: 'hi' })).rejects.toMatchObject({ + code: 'NOT_PERMITTED', + }); + }); + + it('replaces the previous generation on re-register and ignores its late release', async () => { + const { gateway, ports } = setup(); + ports.runTurn.mockImplementationOnce(({ signal }) => untilAborted(signal)); + const releaseOld = gateway.registerArena(ARENA); + const oldRun = gateway.run(CALLER, { text: 'hi' }); + await vi.waitFor(() => expect(ports.runTurn).toHaveBeenCalled()); + + gateway.registerArena({ ...ARENA, processGeneration: 'gen-2' }); + + await expect(oldRun).resolves.toMatchObject({ status: 'aborted' }); + await expect(gateway.run(CALLER, { text: 'hi' })).rejects.toMatchObject({ + code: 'AGENT_UNAVAILABLE', + retryable: true, + }); + releaseOld(); + await expect( + gateway.run({ ...CALLER, processGeneration: 'gen-2' }, { text: 'hi' }), + ).resolves.toMatchObject({ status: 'finished' }); + }); + + it('forwards the caller signal to the run', async () => { + const { gateway, ports } = setup(); + ports.runTurn.mockImplementation(({ signal }) => untilAborted(signal)); + gateway.registerArena(ARENA); + const controller = new AbortController(); + + const run = gateway.run(CALLER, { text: 'hi' }, { signal: controller.signal }); + await vi.waitFor(() => expect(ports.runTurn).toHaveBeenCalled()); + controller.abort(); + + await expect(run).resolves.toMatchObject({ status: 'aborted' }); + }); +}); + +describe('createMiniAppAgentGateway failure audit', () => { + it('reports session creation failures as unavailable and turn failures as unknown outcome', async () => { + const { gateway, ports, logger } = setup(); + const release = gateway.registerArena(ARENA); + ports.createSession.mockRejectedValueOnce(new Error('db locked')); + await expect(gateway.run(CALLER, { text: 'hi' })).rejects.toMatchObject({ + code: 'AGENT_UNAVAILABLE', + }); + expect(ports.runTurn).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + phase: 'rejected', + errorCode: 'AGENT_UNAVAILABLE', + stage: 'create-session', + errorName: 'Error', + }), + 'Mini App Agent run', + ); + expect(JSON.stringify(logger.warn.mock.calls)).not.toContain('db locked'); + + ports.runTurn.mockRejectedValueOnce(new Error('stream closed')); + await expect(gateway.run(CALLER, { text: 'hi' })).rejects.toMatchObject({ + code: 'AGENT_OUTCOME_UNKNOWN', + }); + expect(logger.info).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: 'terminal', + stage: 'turn', + errorCode: 'AGENT_OUTCOME_UNKNOWN', + errorName: 'Error', + }), + 'Mini App Agent run', + ); + + // Both failures released their slot on the same arena: a full batch is still admitted. + ports.runTurn.mockImplementation(({ signal }) => untilAborted(signal)); + const runs = Array.from({ length: MINIAPP_AGENT_DEFAULT_CONCURRENCY }, () => + gateway.run(CALLER, { text: 'hi' }), + ); + await vi.waitFor(() => + expect(ports.runTurn).toHaveBeenCalledTimes(MINIAPP_AGENT_DEFAULT_CONCURRENCY + 1), + ); + release(); + await expect(Promise.all(runs)).resolves.toEqual( + runs.map(() => expect.objectContaining({ status: 'aborted' })), + ); + }); + + it('audits a run cancelled after its session was created', async () => { + const { gateway, ports, logger } = setup(); + gateway.registerArena(ARENA); + const controller = new AbortController(); + ports.createSession.mockImplementationOnce(async () => { + controller.abort(); + return { sessionId: 'session-new' }; + }); + + await expect( + gateway.run(CALLER, { text: 'hi' }, { signal: controller.signal }), + ).rejects.toMatchObject({ code: 'REQUEST_CANCELLED' }); + expect(ports.runTurn).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + phase: 'rejected', + errorCode: 'REQUEST_CANCELLED', + operation: 'run', + sessionId: 'session-new', + cancelledAfterCreate: true, + }), + 'Mini App Agent run', + ); + }); + + it('logs a failed reply and reports a failure after dispatch as unknown', async () => { + const { gateway, ports, logger } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + gateway.registerArena(ARENA); + const input = { sessionId: 'mine', requestId: 'perm-1', decision: 'deny' } as const; + + ports.replyPermission.mockRejectedValueOnce(new Error('ipc closed')); + await expect(gateway.reply(CALLER, input)).rejects.toMatchObject({ + code: 'AGENT_OUTCOME_UNKNOWN', + }); + const unavailable = new MiniAppAgentError('AGENT_UNAVAILABLE', 'lookup failed', true); + ports.replyPermission.mockRejectedValueOnce(unavailable); + await expect(gateway.reply(CALLER, input)).rejects.toBe(unavailable); + + const audit = { phase: 'rejected', stage: 'reply', sessionId: 'mine', requestId: 'perm-1' }; + expect(logger.warn.mock.calls).toEqual([ + [ + expect.objectContaining({ + ...audit, + errorCode: 'AGENT_OUTCOME_UNKNOWN', + errorName: 'Error', + }), + 'Mini App Agent run', + ], + [ + expect.objectContaining({ + ...audit, + errorCode: 'AGENT_UNAVAILABLE', + errorName: 'MiniAppAgentError', + }), + 'Mini App Agent run', + ], + ]); + expect(logger.info).not.toHaveBeenCalled(); + }); +}); + +describe('createMiniAppAgentGateway session context', () => { + const PAGE: MiniAppAgentCaller = { ...CALLER, source: 'page', hostSessionId: 'chat-1' }; + const HOST_MODEL = { providerId: 'byok', modelId: 'orion', variant: 'thinking' }; + const pluginDir = (agentName: string) => + join('/data', 'agents', agentName, 'miniapps', 'demo%20board'); + + it('works in the project directory of a host chat that has one, with its agent', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'miniapp-host-project-')); + try { + const { gateway, ports, logger } = setup(); + ports.resolveHostSession.mockResolvedValue({ + agentName: 'custom-x', + workspaceDir: projectDir, + isDefaultWorkspace: false, + model: HOST_MODEL, + }); + gateway.registerArena(ARENA); + + await gateway.run(PAGE, { text: 'hi' }); + + expect(ports.resolveHostSession.mock.calls).toEqual([['chat-1']]); + expect(ports.createSession).toHaveBeenCalledWith({ + pluginId: 'demo board', + agentName: 'custom-x', + workspaceDir: projectDir, + createDir: false, + model: HOST_MODEL, + }); + expect(ports.defaultSessionModel).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalledWith( + expect.objectContaining({ hostSessionId: 'chat-1' }), + expect.anything(), + ); + } finally { + await rm(projectDir, { recursive: true, force: true }); + } + }); + + it('uses the plugin directory of the host agent when the host chat has no project', async () => { + const { gateway, ports } = setup(); + ports.resolveHostSession.mockResolvedValue({ + agentName: 'custom-x', + workspaceDir: '/home/user/.mavis/default-workspace', + isDefaultWorkspace: true, + }); + gateway.registerArena(ARENA); + + await gateway.run(PAGE, { text: 'hi' }); + + // The permission module only lets an agent read dataDir paths under its own agent assets. + expect(ports.createSession).toHaveBeenCalledWith({ + pluginId: 'demo board', + agentName: 'custom-x', + workspaceDir: pluginDir('custom-x'), + createDir: true, + // The host chat record carries no model, so the runtime default is frozen instead. + model: DEFAULT_MODEL, + }); + expect(ports.defaultSessionModel).toHaveBeenCalledTimes(1); + }); + + it('falls back to the plugin directory, traceably, when the project directory is gone', async () => { + const { gateway, ports, logger } = setup(); + const projectDir = await mkdtemp(join(tmpdir(), 'miniapp-host-project-')); + await rm(projectDir, { recursive: true }); + ports.resolveHostSession.mockResolvedValue({ + agentName: 'custom-x', + workspaceDir: projectDir, + isDefaultWorkspace: false, + }); + gateway.registerArena(ARENA); + + await gateway.run(PAGE, { text: 'hi' }); + + expect(ports.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + agentName: 'custom-x', + workspaceDir: pluginDir('custom-x'), + createDir: true, + }), + ); + expect(logger.info).toHaveBeenCalledWith( + { + pluginId: 'demo board', + hostSessionId: 'chat-1', + agentName: 'custom-x', + workspaceSource: 'plugin', + }, + 'Mini App Agent host chat project directory is gone; using the plugin directory', + ); + }); + + it('falls back to the default agent and its plugin directory when the host chat is gone', async () => { + const { gateway, ports, logger } = setup(); + gateway.registerArena(ARENA); + + await gateway.run({ ...PAGE, hostSessionId: 'gone' }, { text: 'hi' }); + + expect(ports.resolveHostSession.mock.calls).toEqual([['gone']]); + expect(ports.createSession).toHaveBeenCalledWith({ + pluginId: 'demo board', + agentName: 'mavis', + workspaceDir: pluginDir('mavis'), + createDir: true, + model: DEFAULT_MODEL, + }); + expect(logger.info).toHaveBeenCalledWith( + { + pluginId: 'demo board', + hostSessionId: 'gone', + agentName: 'mavis', + workspaceSource: 'plugin', + }, + 'Mini App Agent host chat session not found; using the default agent', + ); + }); + + it('passes the ports result and turn.started model through unchanged', async () => { + const { gateway, ports } = setup(); + // The record holds the runtime-normalized selection; nothing in the request carries a variant. + const recorded = { providerId: 'byok', modelId: 'orion', variant: 'thinking' }; + ports.runTurn.mockImplementation(async ({ sessionId, onEvent }) => { + onEvent?.({ type: 'turn.started', sessionId, turnId: 'turn-1', model: recorded }); + return { turnId: 'turn-1', status: 'finished', model: recorded }; + }); + gateway.registerArena(ARENA); + const events: MiniAppAgentRunEvent[] = []; + + const result = await gateway.run(PAGE, { text: 'hi' }, { onEvent: (e) => events.push(e) }); + + expect(result.model).toEqual(recorded); + expect(events).toContainEqual({ + type: 'turn.started', + sessionId: 'session-new', + turnId: 'turn-1', + model: recorded, + }); + }); + + it('keeps the agent and directory of a continued session and ignores hostSessionId', async () => { + const { gateway, ports, logger } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + gateway.registerArena(ARENA); + + await gateway.run(PAGE, { text: 'hi', sessionId: 'mine' }); + + expect(ports.resolveHostSession).not.toHaveBeenCalled(); + expect(ports.createSession).not.toHaveBeenCalled(); + expect(logger.info.mock.calls[0]?.[0]).not.toHaveProperty('agentName'); + }); + + it.each([false, true])( + 'binds a queued Page session to its host context at creation (project removed while waiting: %s)', + async (removeProject) => { + const projectDir = await mkdtemp(join(tmpdir(), 'miniapp-queued-host-project-')); + const { gateway, ports, turns, release, running } = await fullArena(1); + ports.resolveHostSession.mockResolvedValue({ + agentName: 'custom-x', + workspaceDir: projectDir, + isDefaultWorkspace: false, + }); + const onEvent = vi.fn(); + const queued = gateway.run(PAGE, { text: 'queued page' }, { onEvent }); + const settled = Promise.allSettled([...running, queued]); + try { + expect(onEvent).toHaveBeenCalledWith({ type: 'run.queued', position: 1 }); + expect(ports.createSession).toHaveBeenCalledTimes(1); + expect(ports.resolveHostSession).not.toHaveBeenCalled(); + if (removeProject) await rm(projectDir, { recursive: true }); + + turns.finish(0); + await vi.waitFor(() => expect(turns.texts()).toEqual(['r1', 'queued page'])); + + // The host has no stored model, so the new session snapshots the runtime default. + expect(ports.createSession).toHaveBeenLastCalledWith({ + pluginId: 'demo board', + agentName: 'custom-x', + workspaceDir: removeProject ? pluginDir('custom-x') : projectDir, + createDir: removeProject, + model: DEFAULT_MODEL, + }); + turns.finish(1); + await expect(queued).resolves.toMatchObject({ status: 'finished' }); + } finally { + release(); + await settled; + await rm(projectDir, { recursive: true, force: true }); + } + }, + ); +}); + +describe('createMiniAppAgentGateway session exclusivity', () => { + it('rejects a second run on a session that is already running as retryable BUSY', async () => { + const { gateway, ports, logger } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + ports.runTurn.mockImplementation(({ signal }) => untilAborted(signal)); + const release = gateway.registerArena(ARENA); + + const first = gateway.run(CALLER, { text: 'hi', sessionId: 'mine' }); + await vi.waitFor(() => expect(ports.runTurn).toHaveBeenCalledTimes(1)); + await expect(gateway.run(CALLER, { text: 'hi', sessionId: 'mine' })).rejects.toMatchObject({ + code: 'BUSY', + retryable: true, + reason: 'session_busy', + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ phase: 'rejected', errorCode: 'BUSY', reason: 'session_busy' }), + 'Mini App Agent run', + ); + + release(); + await expect(first).resolves.toMatchObject({ status: 'aborted' }); + const next = gateway.registerArena(ARENA); + ports.runTurn.mockResolvedValueOnce({ turnId: 'turn-2', status: 'finished' }); + await expect(gateway.run(CALLER, { text: 'hi', sessionId: 'mine' })).resolves.toMatchObject({ + status: 'finished', + }); + next(); + }); +}); + +describe('createMiniAppAgentGateway run queue', () => { + it('starts waiting runs in FIFO order as slots free and audits where each waited', async () => { + const { gateway, logger, turns, release, running } = await fullArena(); + const events: MiniAppAgentRunEvent[] = []; + const queued = ['q1', 'q2', 'q3'].map((text) => + gateway.run(CALLER, { text }, { onEvent: (event) => events.push(event) }), + ); + expect(events).toEqual([1, 2, 3].map((position) => ({ type: 'run.queued', position }))); + + for (const [index, slot] of [1, 0, 2].entries()) { + turns.finish(slot); + await vi.waitFor(() => expect(turns.texts()).toHaveLength(5 + index)); + } + expect(turns.texts()).toEqual(['r1', 'r2', 'r3', 'r4', 'q1', 'q2', 'q3']); + const admissions = logger.info.mock.calls + .map(([fields]) => fields) + .filter((fields) => fields.phase === 'admission'); + expect(admissions.map((fields) => (fields.queued ? fields.position : 0))).toEqual([ + 0, 0, 0, 0, 1, 2, 3, + ]); + + release(); + await Promise.all([...running, ...queued]); + }); + + it('refuses a call for a running session at once instead of queueing it', async () => { + const { gateway, release, running } = await fullArena(); + const onEvent = vi.fn(); + + await expect( + gateway.run(CALLER, { text: 'again', sessionId: 'session-1' }, { onEvent }), + ).rejects.toMatchObject({ code: 'BUSY', retryable: true, reason: 'session_busy' }); + await expect( + gateway.answer(CALLER, { sessionId: 'session-2', requestId: 'q', answers: [] }, { onEvent }), + ).rejects.toMatchObject({ code: 'BUSY', reason: 'session_busy' }); + expect(onEvent).not.toHaveBeenCalled(); + + release(); + await Promise.all(running); + }); + + it('refuses a call beyond the wait bound as queue_full', async () => { + const { gateway, logger, release, running } = await fullArena(); + const queued = Promise.allSettled( + Array.from({ length: DEFAULT_QUEUE_BOUND }, () => gateway.run(CALLER, { text: 'q' })), + ); + const onEvent = vi.fn(); + + await expect(gateway.run(CALLER, { text: 'q' }, { onEvent })).rejects.toMatchObject({ + code: 'BUSY', + retryable: true, + reason: 'queue_full', + }); + expect(onEvent).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ phase: 'rejected', errorCode: 'BUSY', reason: 'queue_full' }), + 'Mini App Agent run', + ); + + release(); + await Promise.all(running); + expect((await queued).map(({ status }) => status)).toEqual( + Array.from({ length: DEFAULT_QUEUE_BOUND }, () => 'rejected'), + ); + }); + + it('cancels a waiting call on abort and keeps the order of the rest', async () => { + const { gateway, logger, turns, release, running } = await fullArena(); + const controller = new AbortController(); + const first = gateway.run(CALLER, { text: 'q1' }); + const cancelled = gateway.run(CALLER, { text: 'q2' }, { signal: controller.signal }); + const third = gateway.run(CALLER, { text: 'q3' }); + + controller.abort(); + await expect(cancelled).rejects.toMatchObject({ code: 'REQUEST_CANCELLED' }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ errorCode: 'REQUEST_CANCELLED', stage: 'queue', operation: 'run' }), + 'Mini App Agent run', + ); + const onEvent = vi.fn(); + const fourth = gateway.run(CALLER, { text: 'q4' }, { onEvent }); + expect(onEvent).toHaveBeenCalledWith({ type: 'run.queued', position: 3 }); + for (const [index, slot] of [0, 1, 2].entries()) { + turns.finish(slot); + await vi.waitFor(() => expect(turns.texts()).toHaveLength(5 + index)); + } + expect(turns.texts().slice(4)).toEqual(['q1', 'q3', 'q4']); + + release(); + await Promise.all([...running, first, third, fourth]); + }); + + it('cancels every waiting call when the arena is released', async () => { + const { gateway, ports, release, running } = await fullArena(); + const queued = Promise.allSettled([ + gateway.run(CALLER, { text: 'q1' }), + gateway.answer(CALLER, { sessionId: 'mine', requestId: 'q', answers: [] }), + ]); + + release(); + + const cancelled = { + status: 'rejected', + reason: expect.objectContaining({ code: 'REQUEST_CANCELLED' }), + }; + await expect(queued).resolves.toEqual([cancelled, cancelled]); + await expect(Promise.all(running)).resolves.toEqual( + running.map(() => expect.objectContaining({ status: 'aborted' })), + ); + expect(ports.runTurn).toHaveBeenCalledTimes(MINIAPP_AGENT_DEFAULT_CONCURRENCY); + expect(ports.answerQuestion).not.toHaveBeenCalled(); + }); + + it('refuses a waiting call whose session started while it waited, and frees its slot', async () => { + const { gateway, turns, release, running } = await fullArena(); + const MINIAPP_ROOT = { miniApp: true, parentSessionId: null }; + const events: MiniAppAgentRunEvent[] = []; + const onEvent = (event: MiniAppAgentRunEvent) => events.push(event); + const first = gateway.run(CALLER, { text: 'a', sessionId: 'mine' }, { onEvent }); + const second = gateway.run(CALLER, { text: 'b', sessionId: 'mine' }, { onEvent }); + expect(events).toEqual([ + { type: 'run.queued', sessionId: 'mine', position: 1 }, + { type: 'run.queued', sessionId: 'mine', position: 2 }, + ]); + // A waiting call is no one who can answer an ask yet. + await expect(gateway.shouldDenyApproval('mine', MINIAPP_ROOT)).resolves.toBe(true); + + turns.finish(0); + await vi.waitFor(() => expect(turns.texts()).toHaveLength(5)); + await expect(gateway.shouldDenyApproval('mine', MINIAPP_ROOT)).resolves.toBe(false); + turns.finish(1); + await expect(second).rejects.toMatchObject({ code: 'BUSY', reason: 'session_busy' }); + const next = gateway.run(CALLER, { text: 'c' }); + await vi.waitFor(() => expect(turns.texts()).toEqual(['r1', 'r2', 'r3', 'r4', 'a', 'c'])); + + release(); + await Promise.all([...running, first, next]); + }); +}); + +describe('createMiniAppAgentGateway declared concurrency', () => { + it('runs as many calls at once as the policy allows and queues the next', async () => { + const { gateway, turns, release, running } = await fullArena(2); + const onEvent = vi.fn(); + + const third = gateway.run(CALLER, { text: 'q1' }, { onEvent }); + + expect(turns.texts()).toEqual(['r1', 'r2']); + expect(onEvent).toHaveBeenCalledWith({ type: 'run.queued', position: 1 }); + turns.finish(0); + await vi.waitFor(() => expect(turns.texts()).toEqual(['r1', 'r2', 'q1'])); + + release(); + await Promise.all([...running, third]); + }); + + it('scales the wait bound with the declared concurrency', async () => { + const { gateway, release, running } = await fullArena(1); + const queued = Promise.allSettled( + Array.from({ length: MINIAPP_AGENT_QUEUE_FACTOR }, () => gateway.run(CALLER, { text: 'q' })), + ); + + await expect(gateway.run(CALLER, { text: 'q' })).rejects.toMatchObject({ + code: 'BUSY', + reason: 'queue_full', + }); + + release(); + await Promise.all(running); + await queued; + }); +}); + +describe('createMiniAppAgentGateway approval routing', () => { + it('never denies before bind, then only for Mini App sessions nobody listens to', async () => { + const unbound = setup({ bind: false }); + await expect(unbound.gateway.shouldDenyApproval('chat')).resolves.toBe(false); + + const { gateway, ports } = setup(); + ports.getOwner.mockImplementation(async (sessionId) => + sessionId.startsWith('mine') ? 'demo board' : undefined, + ); + ports.runTurn.mockImplementation(({ signal }) => untilAborted(signal)); + const release = gateway.registerArena(ARENA); + + await expect(gateway.shouldDenyApproval('chat')).resolves.toBe(false); + await expect(gateway.shouldDenyApproval('mine')).resolves.toBe(true); + const listened = gateway.run(CALLER, { text: 'hi', sessionId: 'mine' }, { onEvent: vi.fn() }); + const silent = gateway.run(CALLER, { text: 'hi', sessionId: 'mine-silent' }); + await vi.waitFor(() => expect(ports.runTurn).toHaveBeenCalledTimes(2)); + + await expect(gateway.shouldDenyApproval('mine')).resolves.toBe(false); + await expect(gateway.shouldDenyApproval('mine-silent')).resolves.toBe(true); + release(); + await Promise.all([listened, silent]); + await expect(gateway.shouldDenyApproval('mine')).resolves.toBe(true); + }); + + it('denies a session started from a chat unless its panel is open', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockImplementation(async (sessionId) => + sessionId === 'page-made' ? 'demo board' : undefined, + ); + + // Creating the session from a chat is not evidence that anyone is watching it. + await expect(gateway.shouldDenyApproval('page-made')).resolves.toBe(true); + gateway.panelOpened({ sessionId: 'page-made', hostSessionId: 'chat-1' }); + await expect(gateway.shouldDenyApproval('page-made')).resolves.toBe(false); + gateway.panelClosed({ sessionId: 'page-made', hostSessionId: 'chat-1' }); + await expect(gateway.shouldDenyApproval('page-made')).resolves.toBe(true); + }); + + it('fails the lookup instead of guessing when ownership cannot be read', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockRejectedValue(new Error('db locked')); + + await expect(gateway.shouldDenyApproval('mine')).rejects.toThrow('db locked'); + }); + + it('forwards a permission ask only to the run of its session', async () => { + const { gateway, ports, ask } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + ports.runTurn.mockImplementation(({ sessionId, signal, onEvent }) => { + onEvent?.({ + type: 'turn.started', + sessionId, + turnId: `turn-${sessionId}`, + model: DEFAULT_MODEL, + }); + return untilAborted(signal); + }); + const release = gateway.registerArena(ARENA); + const first: MiniAppAgentRunEvent[] = []; + const second: MiniAppAgentRunEvent[] = []; + const runs = [ + gateway.run(CALLER, { text: 'hi', sessionId: 'a' }, { onEvent: (e) => first.push(e) }), + gateway.run(CALLER, { text: 'hi', sessionId: 'b' }, { onEvent: (e) => second.push(e) }), + ]; + await vi.waitFor(() => expect(ports.runTurn).toHaveBeenCalledTimes(2)); + + ask({ sessionId: 'a', requestId: 'perm-1', toolName: 'bash', toolInput: 'ls', reason: 'r' }); + ask({ sessionId: 'nobody', requestId: 'perm-2', toolName: 'bash', reason: 'r' }); + ask({ sessionId: 'b', turnId: 'turn-x', requestId: 'perm-3', toolName: 'write', reason: 'r' }); + + expect(first.slice(1)).toEqual([ + { + type: 'permission.requested', + sessionId: 'a', + turnId: 'turn-a', + requestId: 'perm-1', + toolName: 'bash', + toolInput: 'ls', + reason: 'r', + }, + ]); + expect(second.slice(1)).toEqual([ + { + type: 'permission.requested', + sessionId: 'b', + turnId: 'turn-x', + requestId: 'perm-3', + toolName: 'write', + reason: 'r', + }, + ]); + release(); + await Promise.all(runs); + }); + + it('routes asks of delegated child sessions through their Mini App root', async () => { + const { gateway, ports, ask } = setup(); + ports.getOwner.mockImplementation(async (sessionId) => + sessionId.startsWith('mine') ? 'demo board' : undefined, + ); + ports.resolveMiniAppRoot.mockImplementation(async (sessionId) => { + if (sessionId.startsWith('mine')) return { rootSessionId: sessionId, pluginId: 'demo board' }; + if (sessionId === 'child') return { rootSessionId: 'mine', pluginId: 'demo board' }; + if (sessionId === 'silent-child') { + return { rootSessionId: 'mine-silent', pluginId: 'demo board' }; + } + return undefined; + }); + ports.runTurn.mockImplementation(({ signal }) => untilAborted(signal)); + const release = gateway.registerArena(ARENA); + const events: MiniAppAgentRunEvent[] = []; + const run = gateway.run( + CALLER, + { text: 'hi', sessionId: 'mine' }, + { onEvent: (event) => events.push(event) }, + ); + await vi.waitFor(() => expect(ports.runTurn).toHaveBeenCalled()); + + await expect(gateway.shouldDenyApproval('child')).resolves.toBe(false); + await expect(gateway.shouldDenyApproval('silent-child')).resolves.toBe(true); + ask({ sessionId: 'child', requestId: 'perm-c', toolName: 'bash', reason: 'r' }); + ask({ sessionId: 'silent-child', requestId: 'perm-s', toolName: 'bash', reason: 'r' }); + + // The child keeps its own sessionId, which is what the author replies with; no turn is known. + await vi.waitFor(() => + expect(events).toEqual([ + { + type: 'permission.requested', + sessionId: 'child', + requestId: 'perm-c', + toolName: 'bash', + reason: 'r', + }, + ]), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(events).toHaveLength(1); + release(); + await run; + }); +}); + +describe('createMiniAppAgentGateway approval lineage', () => { + const ORDINARY = { miniApp: false, parentSessionId: null }; + const MINIAPP_ROOT = { miniApp: true, parentSessionId: null }; + + it('answers an ordinary root session or SideChat without touching the ports', async () => { + const { gateway, ports } = setup(); + + await expect(gateway.shouldDenyApproval('chat', ORDINARY)).resolves.toBe(false); + await expect(gateway.shouldDenyApproval('chat', { miniApp: false })).resolves.toBe(false); + expect(ports.resolveMiniAppRoot).not.toHaveBeenCalled(); + expect(ports.getOwner).not.toHaveBeenCalled(); + }); + + it('decides a Mini App root from its listener and panel without touching the ports', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + ports.runTurn.mockImplementation(({ signal }) => untilAborted(signal)); + const release = gateway.registerArena(ARENA); + + await expect(gateway.shouldDenyApproval('mine', MINIAPP_ROOT)).resolves.toBe(true); + const run = gateway.run(CALLER, { text: 'hi', sessionId: 'mine' }, { onEvent: vi.fn() }); + await vi.waitFor(() => expect(ports.runTurn).toHaveBeenCalled()); + await expect(gateway.shouldDenyApproval('mine', MINIAPP_ROOT)).resolves.toBe(false); + release(); + await run; + await expect(gateway.shouldDenyApproval('mine', MINIAPP_ROOT)).resolves.toBe(true); + + gateway.panelOpened({ sessionId: 'mine', hostSessionId: 'chat-1' }); + await expect(gateway.shouldDenyApproval('mine', MINIAPP_ROOT)).resolves.toBe(false); + gateway.panelClosed({ sessionId: 'mine', hostSessionId: 'chat-1' }); + await expect(gateway.shouldDenyApproval('mine', MINIAPP_ROOT)).resolves.toBe(true); + expect(ports.resolveMiniAppRoot).not.toHaveBeenCalled(); + }); + + it('keeps a session answerable while any chat still shows it', async () => { + const { gateway } = setup(); + gateway.panelOpened({ sessionId: 'mine', hostSessionId: 'chat-1' }); + gateway.panelOpened({ sessionId: 'mine', hostSessionId: 'chat-2' }); + + gateway.panelClosed({ sessionId: 'mine', hostSessionId: 'chat-1' }); + await expect(gateway.shouldDenyApproval('mine', MINIAPP_ROOT)).resolves.toBe(false); + gateway.panelClosed({ sessionId: 'mine', hostSessionId: 'chat-2' }); + await expect(gateway.shouldDenyApproval('mine', MINIAPP_ROOT)).resolves.toBe(true); + // Closing a tab that was never reported open is a no-op. + gateway.panelClosed({ sessionId: 'other', hostSessionId: 'chat-1' }); + }); + + it.each([ + ['a delegated child', { miniApp: false, parentSessionId: 'mine' }], + ['a caller without lineage', undefined], + ])('walks to the Mini App root for %s', async (_name, lineage) => { + const { gateway, ports } = setup(); + ports.resolveMiniAppRoot.mockResolvedValue({ rootSessionId: 'mine', pluginId: 'demo board' }); + + await expect(gateway.shouldDenyApproval('child', lineage)).resolves.toBe(true); + gateway.panelOpened({ sessionId: 'mine', hostSessionId: 'chat-1' }); + await expect(gateway.shouldDenyApproval('child', lineage)).resolves.toBe(false); + expect(ports.resolveMiniAppRoot).toHaveBeenCalledWith('child'); + ports.resolveMiniAppRoot.mockRejectedValue(new Error('db locked')); + await expect(gateway.shouldDenyApproval('child', lineage)).rejects.toThrow('db locked'); + }); + + it('never denies before bind, whatever the lineage says', async () => { + const { gateway } = setup({ bind: false }); + + await expect(gateway.shouldDenyApproval('mine', MINIAPP_ROOT)).resolves.toBe(false); + }); + + it('logs a listener that throws on an ask and keeps delivering', async () => { + const { gateway, ports, ask, logger } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + ports.runTurn.mockImplementation(({ signal }) => untilAborted(signal)); + const release = gateway.registerArena(ARENA); + const onEvent = vi.fn(() => { + throw new TypeError('author bug'); + }); + const run = gateway.run(CALLER, { text: 'hi', sessionId: 'mine' }, { onEvent }); + await vi.waitFor(() => expect(ports.runTurn).toHaveBeenCalled()); + + ask({ sessionId: 'mine', requestId: 'perm-1', toolName: 'bash', reason: 'r' }); + ask({ sessionId: 'mine', requestId: 'perm-2', toolName: 'bash', reason: 'r' }); + + expect(onEvent).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + { sessionId: 'mine', requestId: 'perm-1', errorName: 'TypeError' }, + 'Mini App Agent listener threw on ask', + ); + release(); + await run; + }); +}); + +describe('createMiniAppAgentGateway reply', () => { + it('decides a pending request of an owned session and audits it', async () => { + const { gateway, ports, logger } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + gateway.registerArena(ARENA); + + await gateway.reply(CALLER, { sessionId: 'mine', requestId: 'perm-1', decision: 'allowOnce' }); + + expect(ports.replyPermission).toHaveBeenCalledWith({ + sessionId: 'mine', + requestId: 'perm-1', + decision: 'allowOnce', + }); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: 'demo board', + sessionId: 'mine', + operation: 'reply', + decision: 'allowOnce', + outcome: 'processed', + }), + 'Mini App Agent run', + ); + }); + + it('rejects unknown requests, foreign sessions, bad decisions and missing permission', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockImplementation(async (sessionId) => + sessionId === 'mine' ? 'demo board' : 'other', + ); + const input = { sessionId: 'mine', requestId: 'perm-1', decision: 'deny' } as const; + + await expect(gateway.reply(CALLER, input)).rejects.toMatchObject({ code: 'NOT_PERMITTED' }); + gateway.registerArena(ARENA); + ports.replyPermission.mockResolvedValueOnce('skipped'); + await expect(gateway.reply(CALLER, input)).rejects.toMatchObject({ + code: 'REQUEST_NOT_FOUND', + }); + await expect(gateway.reply(CALLER, { ...input, sessionId: 'theirs' })).rejects.toMatchObject({ + code: 'SESSION_NOT_FOUND', + }); + await expect( + gateway.reply(CALLER, { ...input, decision: 'allowAlways' as never }), + ).rejects.toMatchObject({ code: 'INVALID_ARGUMENTS' }); + expect(ports.replyPermission).toHaveBeenCalledTimes(1); + }); + + it('decides a request of a delegated child but never one under another plugin', async () => { + const { gateway, ports } = setup(); + ports.resolveMiniAppRoot.mockImplementation(async (sessionId) => + sessionId === 'child' + ? { rootSessionId: 'mine', pluginId: 'demo board' } + : sessionId === 'their-child' + ? { rootSessionId: 'theirs', pluginId: 'other' } + : undefined, + ); + gateway.registerArena(ARENA); + + await gateway.reply(CALLER, { sessionId: 'child', requestId: 'perm-c', decision: 'deny' }); + await expect( + gateway.reply(CALLER, { sessionId: 'their-child', requestId: 'perm-t', decision: 'deny' }), + ).rejects.toMatchObject({ code: 'SESSION_NOT_FOUND' }); + expect(ports.replyPermission.mock.calls).toEqual([ + [{ sessionId: 'child', requestId: 'perm-c', decision: 'deny' }], + ]); + }); + + it('rejects a non-Mini-App session and a stale caller generation', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + gateway.registerArena(ARENA); + const input = { sessionId: 'mine', requestId: 'perm-1', decision: 'deny' } as const; + + ports.resolveMiniAppRoot.mockResolvedValueOnce(undefined); + await expect(gateway.reply(CALLER, { ...input, sessionId: 'chat' })).rejects.toMatchObject({ + code: 'SESSION_NOT_FOUND', + }); + await expect( + gateway.reply({ ...CALLER, processGeneration: 'gen-0' }, input), + ).rejects.toMatchObject({ code: 'AGENT_UNAVAILABLE', retryable: true }); + expect(ports.replyPermission).not.toHaveBeenCalled(); + }); + + it('reports a request raised under another session of the same plugin as not found', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + // The ports only settle a request raised under the given session's root. + ports.replyPermission.mockResolvedValueOnce('skipped'); + gateway.registerArena(ARENA); + + await expect( + gateway.reply(CALLER, { sessionId: 'mine', requestId: 'perm-of-mine-2', decision: 'deny' }), + ).rejects.toMatchObject({ code: 'REQUEST_NOT_FOUND' }); + expect(ports.replyPermission).toHaveBeenCalledWith({ + sessionId: 'mine', + requestId: 'perm-of-mine-2', + decision: 'deny', + }); + }); +}); + +describe('createMiniAppAgentGateway open', () => { + const PAGE: MiniAppAgentCaller = { ...CALLER, source: 'page', hostSessionId: 'chat-1' }; + + function withSessions() { + const harness = setup(); + harness.ports.resolveMiniAppRoot.mockImplementation(async (sessionId) => { + switch (sessionId) { + case 'mine': + case 'child': + return { rootSessionId: 'mine', pluginId: 'demo board' }; + case 'other-chat': + return { rootSessionId: 'other-chat', pluginId: 'demo board' }; + case 'node-made': + return { rootSessionId: 'node-made', pluginId: 'demo board' }; + case 'theirs': + return { rootSessionId: 'theirs', pluginId: 'other' }; + default: + return undefined; + } + }); + return harness; + } + + it('confirms a session this plugin started from the caller chat and audits it', async () => { + const { gateway, logger } = withSessions(); + gateway.registerArena(ARENA); + + await expect(gateway.open(PAGE, { sessionId: 'mine' })).resolves.toEqual({ + sessionId: 'mine', + hostSessionId: 'chat-1', + }); + expect(logger.info.mock.calls).toEqual([ + [ + { + pluginId: 'demo board', + processGeneration: 'gen-1', + source: 'page', + sessionId: 'mine', + operation: 'open', + }, + 'Mini App Agent run', + ], + ]); + }); + + it.each(['other-chat', 'node-made'])( + 'opens %s in the chat the Mini App is open in now, whichever chat created it', + async (sessionId) => { + const { gateway } = withSessions(); + gateway.registerArena(ARENA); + + await expect(gateway.open(PAGE, { sessionId })).resolves.toEqual({ + sessionId, + hostSessionId: 'chat-1', + }); + }, + ); + + it.each(['child', 'theirs', 'chat'])('hides %s as SESSION_NOT_FOUND', async (sessionId) => { + const { gateway, logger } = withSessions(); + gateway.registerArena(ARENA); + + await expect(gateway.open(PAGE, { sessionId })).rejects.toMatchObject({ + code: 'SESSION_NOT_FOUND', + }); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it('refuses a caller without a host chat or an arena, and a stale caller is unavailable', async () => { + const { gateway, ports } = withSessions(); + + await expect(gateway.open(PAGE, { sessionId: 'mine' })).rejects.toMatchObject({ + code: 'NOT_PERMITTED', + }); + gateway.registerArena(ARENA); + await expect(gateway.open(CALLER, { sessionId: 'mine' })).rejects.toMatchObject({ + code: 'NOT_PERMITTED', + }); + await expect( + gateway.open({ ...PAGE, processGeneration: 'gen-0' }, { sessionId: 'mine' }), + ).rejects.toMatchObject({ code: 'AGENT_UNAVAILABLE', retryable: true }); + expect(ports.resolveMiniAppRoot).not.toHaveBeenCalled(); + }); + + it('reports an unreadable session as unavailable', async () => { + const { gateway, ports } = withSessions(); + gateway.registerArena(ARENA); + ports.resolveMiniAppRoot.mockRejectedValueOnce(new Error('db locked')); + + await expect(gateway.open(PAGE, { sessionId: 'mine' })).rejects.toMatchObject({ + code: 'AGENT_UNAVAILABLE', + }); + }); +}); + +describe('createMiniAppAgentGateway answer', () => { + const ANSWER = { + sessionId: 'mine', + requestId: 'question-1', + answers: [{ stepId: 's1', selectedOptionIds: ['a'], selectedOther: false }], + }; + + it('follows the continuation turn of an owned session and audits it', async () => { + const { gateway, ports, logger } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + gateway.registerArena(ARENA); + const onEvent = vi.fn(); + + await expect(gateway.answer(CALLER, ANSWER, { onEvent })).resolves.toEqual({ + sessionId: 'mine', + turnId: 'turn-2', + status: 'finished', + text: 'continued', + }); + expect(ports.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'mine', + requestId: 'question-1', + answers: ANSWER.answers, + onEvent: expect.any(Function), + }), + ); + expect(logger.info.mock.calls.map(([fields]) => [fields.phase, fields.operation])).toEqual([ + ['admission', 'answer'], + ['terminal', 'answer'], + ]); + }); + + it('shares the run slots and their queue, and the one-run-per-session rule', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + const turns = holdTurns(ports); + const release = gateway.registerArena(ARENA); + + const running = gateway.run(CALLER, { text: 'hi', sessionId: 'mine' }); + await vi.waitFor(() => expect(ports.runTurn).toHaveBeenCalledTimes(1)); + await expect(gateway.answer(CALLER, ANSWER)).rejects.toMatchObject({ + code: 'BUSY', + retryable: true, + reason: 'session_busy', + }); + const others = Array.from({ length: MINIAPP_AGENT_DEFAULT_CONCURRENCY - 1 }, () => + gateway.run(CALLER, { text: 'hi' }), + ); + await vi.waitFor(() => + expect(ports.runTurn).toHaveBeenCalledTimes(MINIAPP_AGENT_DEFAULT_CONCURRENCY), + ); + const events: MiniAppAgentRunEvent[] = []; + const waiting = gateway.answer( + CALLER, + { ...ANSWER, sessionId: 'mine-2' }, + { onEvent: (event) => events.push(event) }, + ); + expect(events).toEqual([{ type: 'run.queued', sessionId: 'mine-2', position: 1 }]); + await Promise.resolve(); + expect(ports.answerQuestion).not.toHaveBeenCalled(); + + // Any slot that frees goes to the waiting answer, whichever run held it. + turns.finish(1); + await expect(others[0]).resolves.toMatchObject({ status: 'finished' }); + await expect(waiting).resolves.toMatchObject({ sessionId: 'mine-2', status: 'finished' }); + + release(); + await Promise.all([running, ...others]); + }); + + it('rejects a foreign session and a missing session id', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockResolvedValue('other'); + gateway.registerArena(ARENA); + + await expect(gateway.answer(CALLER, ANSWER)).rejects.toMatchObject({ + code: 'SESSION_NOT_FOUND', + }); + await expect( + gateway.answer(CALLER, { ...ANSWER, sessionId: undefined as never }), + ).rejects.toMatchObject({ code: 'INVALID_ARGUMENTS' }); + expect(ports.answerQuestion).not.toHaveBeenCalled(); + }); + + it('rejects a non-Mini-App session, a delegated child and a stale caller generation', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockImplementation(async (sessionId) => + sessionId === 'mine' ? 'demo board' : undefined, + ); + // A child resolves to the caller's root, but answers continue the Mini App session itself. + ports.resolveMiniAppRoot.mockResolvedValue({ rootSessionId: 'mine', pluginId: 'demo board' }); + gateway.registerArena(ARENA); + + for (const sessionId of ['chat', 'child']) { + await expect(gateway.answer(CALLER, { ...ANSWER, sessionId })).rejects.toMatchObject({ + code: 'SESSION_NOT_FOUND', + }); + } + await expect( + gateway.answer({ ...CALLER, processGeneration: 'gen-0' }, ANSWER), + ).rejects.toMatchObject({ code: 'AGENT_UNAVAILABLE', retryable: true }); + expect(ports.answerQuestion).not.toHaveBeenCalled(); + }); +}); + +describe('createMiniAppAgentGateway model override', () => { + const REQUESTED = { providerId: 'minimax', modelId: 'M3' }; + const RESOLVED = { providerId: 'minimax', modelId: 'M3', variant: 'fast' }; + + it('validates then persists the requested model before dispatching', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + ports.resolveModel.mockResolvedValue(RESOLVED); + ports.readSessionModel.mockResolvedValue(RESOLVED); + gateway.registerArena(ARENA); + + await gateway.run(CALLER, { text: 'x', sessionId: 'mine', model: REQUESTED }); + + expect(ports.resolveModel).toHaveBeenCalledWith(REQUESTED); + expect(ports.setSessionModel).toHaveBeenCalledWith('mine', RESOLVED); + expect(ports.setSessionModel.mock.invocationCallOrder[0]).toBeLessThan( + ports.runTurn.mock.invocationCallOrder[0] ?? 0, + ); + }); + + it('leaves the session model alone when no model is requested', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + gateway.registerArena(ARENA); + + await gateway.run(CALLER, { text: 'x', sessionId: 'mine' }); + + expect(ports.setSessionModel).not.toHaveBeenCalled(); + }); + + it('creates a new session with the requested model, without a second write', async () => { + const { gateway, ports } = setup(); + ports.resolveModel.mockImplementation(async (requested) => requested ?? DEFAULT_MODEL); + const other = { providerId: 'minimax', modelId: 'M2' }; + gateway.registerArena(ARENA); + + await gateway.run(CALLER, { text: 'x', model: other }); + + expect(ports.createSession).toHaveBeenCalledWith(expect.objectContaining({ model: other })); + expect(ports.setSessionModel).not.toHaveBeenCalled(); + expect(ports.createSession.mock.invocationCallOrder[0]).toBeLessThan( + ports.runTurn.mock.invocationCallOrder[0] ?? 0, + ); + }); + + it('rejects an unavailable model unchanged, without touching the session', async () => { + const { gateway, ports, logger } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + ports.resolveModel.mockRejectedValue( + new MiniAppAgentError('INVALID_ARGUMENTS', 'Model is not available'), + ); + gateway.registerArena(ARENA); + + await expect( + gateway.run(CALLER, { + text: 'x', + sessionId: 'mine', + model: { providerId: 'nope', modelId: 'x' }, + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGUMENTS', retryable: false }); + expect(ports.setSessionModel).not.toHaveBeenCalled(); + expect(ports.runTurn).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + phase: 'rejected', + errorCode: 'INVALID_ARGUMENTS', + stage: 'resolve-model', + operation: 'run', + sessionId: 'mine', + }), + 'Mini App Agent run', + ); + }); + + it('creates no session for an unusable model, and frees its slot for the next queued call', async () => { + const { gateway, ports, turns, release, running } = await fullArena(1); + ports.resolveModel.mockImplementation(async (requested) => { + if (requested?.modelId === 'nope') { + throw new MiniAppAgentError('INVALID_ARGUMENTS', 'Model is not available'); + } + return requested ?? DEFAULT_MODEL; + }); + const created = ports.createSession.mock.calls.length; + + const refused = gateway.run(CALLER, { + text: 'bad', + model: { providerId: 'minimax', modelId: 'nope' }, + }); + const refusedResult = expect(refused).rejects.toMatchObject({ + code: 'INVALID_ARGUMENTS', + retryable: false, + }); + const next = gateway.run(CALLER, { text: 'next' }); + turns.finish(0); + + await refusedResult; + // The queue drained past the refused call: the next call got the slot and ran. + await vi.waitFor(() => expect(turns.texts()).toEqual(['r1', 'next'])); + expect(ports.createSession).toHaveBeenCalledTimes(created + 1); + expect(ports.setSessionModel).not.toHaveBeenCalled(); + + turns.finish(1); + await next; + release(); + await Promise.all(running); + }); + + it('frees the slot of a call refused for an unusable model', async () => { + const { gateway, ports } = setup(); + ports.resolveModel.mockImplementation(async (requested) => { + if (requested) throw new MiniAppAgentError('INVALID_ARGUMENTS', 'Model is not available'); + return DEFAULT_MODEL; + }); + gateway.registerArena({ + ...ARENA, + policy: { kind: 'allowlist', capabilities: ['session.run'], maxConcurrent: 1 }, + }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect( + gateway.run(CALLER, { text: 'x', model: { providerId: 'nope', modelId: 'x' } }), + ).rejects.toMatchObject({ code: 'INVALID_ARGUMENTS' }); + } + expect(ports.createSession).not.toHaveBeenCalled(); + // Were the slot still taken, this would wait in the queue instead of running. + await expect(gateway.run(CALLER, { text: 'ok' })).resolves.toMatchObject({ + status: 'finished', + }); + }); + + it('fails retryably, with nothing dispatched, when the model cannot be saved', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + ports.setSessionModel.mockRejectedValue(new Error('db locked')); + gateway.registerArena(ARENA); + + await expect( + gateway.run(CALLER, { text: 'x', sessionId: 'mine', model: REQUESTED }), + ).rejects.toMatchObject({ code: 'AGENT_UNAVAILABLE', retryable: true }); + expect(ports.runTurn).not.toHaveBeenCalled(); + }); + + it.each([ + ['a non-object', 'minimax/M3'], + ['a missing modelId', { providerId: 'minimax' }], + ['a non-string thinking.effort', { ...REQUESTED, thinking: { effort: 1 } }], + ])('rejects %s as INVALID_ARGUMENTS before any port call or slot', async (_label, model) => { + const { gateway, ports } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + gateway.registerArena(ARENA); + + await expect( + gateway.run(CALLER, { text: 'x', sessionId: 'mine', model: model as never }), + ).rejects.toMatchObject({ code: 'INVALID_ARGUMENTS' }); + expect(ports.getOwner).not.toHaveBeenCalled(); + expect(ports.resolveModel).not.toHaveBeenCalled(); + expect(ports.setSessionModel).not.toHaveBeenCalled(); + expect(ports.runTurn).not.toHaveBeenCalled(); + // The failed call held no slot: the session can run at once. + await expect(gateway.run(CALLER, { text: 'x', sessionId: 'mine' })).resolves.toMatchObject({ + status: 'finished', + }); + }); + + it('writes the model only after a queued run obtains its slot', async () => { + const { gateway, ports, turns, release, running } = await fullArena(1); + const other = { providerId: 'minimax', modelId: 'M2' }; + ports.resolveModel.mockImplementation(async (requested) => requested ?? DEFAULT_MODEL); + + const queued = gateway.run(CALLER, { text: 'q', sessionId: 'b', model: other }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(ports.resolveModel).not.toHaveBeenCalledWith(other); + expect(ports.setSessionModel).not.toHaveBeenCalled(); + + turns.finish(0); + await vi.waitFor(() => expect(turns.texts()).toEqual(['r1', 'q'])); + turns.finish(1); + await queued; + + expect(ports.setSessionModel).toHaveBeenCalledWith('b', other); + const [first, second] = ports.runTurn.mock.invocationCallOrder; + const written = ports.setSessionModel.mock.invocationCallOrder[0] ?? 0; + expect(written).toBeGreaterThan(first ?? Infinity); + expect(written).toBeLessThan(second ?? 0); + + release(); + await Promise.all(running); + }); + + it('applies the model of an answer before the continuation, and updates a session whose answer starts no turn', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + ports.resolveModel.mockResolvedValue(RESOLVED); + ports.answerQuestion.mockResolvedValue({ status: 'finished' }); + gateway.registerArena(ARENA); + + await expect( + gateway.answer(CALLER, { + sessionId: 'mine', + requestId: 'question-1', + answers: [], + model: REQUESTED, + }), + ).resolves.toEqual({ sessionId: 'mine', status: 'finished' }); + + expect(ports.setSessionModel).toHaveBeenCalledWith('mine', RESOLVED); + expect(ports.setSessionModel.mock.invocationCallOrder[0]).toBeLessThan( + ports.answerQuestion.mock.invocationCallOrder[0] ?? 0, + ); + }); + + it('rejects a malformed model on answer before any port call', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + gateway.registerArena(ARENA); + + await expect( + gateway.answer(CALLER, { + sessionId: 'mine', + requestId: 'question-1', + answers: [], + model: { providerId: 'minimax' } as never, + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGUMENTS' }); + expect(ports.answerQuestion).not.toHaveBeenCalled(); + expect(ports.setSessionModel).not.toHaveBeenCalled(); + }); +}); + +/** + * Fills every run slot of an arena allowing `maxConcurrent` runs with held runs `r1`, `r2`, ..., + * each on a new session `session-`. + */ +async function fullArena(maxConcurrent = MINIAPP_AGENT_DEFAULT_CONCURRENCY) { + const harness = setup(); + let created = 0; + harness.ports.createSession.mockImplementation(async () => ({ + sessionId: `session-${++created}`, + })); + harness.ports.getOwner.mockResolvedValue('demo board'); + const turns = holdTurns(harness.ports); + const release = harness.gateway.registerArena({ + ...ARENA, + policy: { kind: 'allowlist', capabilities: ['session.run'], maxConcurrent }, + }); + const running = Array.from({ length: maxConcurrent }, (_, index) => + harness.gateway.run(CALLER, { text: `r${index + 1}` }), + ); + await vi.waitFor(() => expect(turns.texts()).toHaveLength(running.length)); + return { ...harness, turns, release, running }; +} + +describe('createMiniAppAgentGateway default model snapshot', () => { + it('hands the default model context window to a session created on the default only', async () => { + const { gateway, ports } = setup(); + ports.defaultSessionModel.mockResolvedValue({ + selection: DEFAULT_MODEL, + contextWindow: 128000, + }); + gateway.registerArena(ARENA); + + await gateway.run(CALLER, { text: 'x' }); + await gateway.run(CALLER, { text: 'y', model: { providerId: 'minimax', modelId: 'M2' } }); + + expect(ports.createSession.mock.calls[0]?.[0]).toMatchObject({ + model: DEFAULT_MODEL, + contextWindow: 128000, + }); + expect(ports.createSession.mock.calls[1]?.[0]).not.toHaveProperty('contextWindow'); + }); +}); + +describe('createMiniAppAgentGateway models / agents / create', () => { + const PAGE: MiniAppAgentCaller = { ...CALLER, source: 'page', hostSessionId: 'chat-1' }; + const HOST_MODEL = { providerId: 'byok', modelId: 'orion', variant: 'thinking' }; + const hostChat = { agentName: 'custom-x', workspaceDir: '/home/u/ws', isDefaultWorkspace: true }; + const pluginDir = (agentName: string) => + join('/data', 'agents', agentName, 'miniapps', 'demo%20board'); + + it('lists the models with the default and, for a page, the host chat model', async () => { + const { gateway, ports } = setup(); + ports.resolveHostSession.mockResolvedValue({ ...hostChat, model: HOST_MODEL }); + gateway.registerArena(ARENA); + + await expect(gateway.models(PAGE)).resolves.toEqual({ + models: MODEL_OPTIONS, + default: DEFAULT_MODEL, + host: HOST_MODEL, + }); + expect(ports.resolveHostSession.mock.calls).toEqual([['chat-1']]); + }); + + it('reports the default as the host model when the host chat has none', async () => { + const { gateway, ports } = setup(); + ports.resolveHostSession.mockResolvedValue(hostChat); + gateway.registerArena(ARENA); + + await expect(gateway.models(PAGE)).resolves.toEqual({ + models: MODEL_OPTIONS, + default: DEFAULT_MODEL, + host: DEFAULT_MODEL, + }); + }); + + it('has no host model for a Node caller or a host chat that is gone', async () => { + const { gateway, ports } = setup(); + gateway.registerArena(ARENA); + + const node = await gateway.models(CALLER); + const gone = await gateway.models(PAGE); + + expect(node).toEqual({ models: MODEL_OPTIONS, default: DEFAULT_MODEL }); + expect(gone).toEqual({ models: MODEL_OPTIONS, default: DEFAULT_MODEL }); + expect(ports.resolveHostSession.mock.calls).toEqual([['chat-1']]); + }); + + it('fails models, run and create alike when the runtime has no usable default model', async () => { + const { gateway, ports } = setup(); + const unusable = new MiniAppAgentError('INVALID_ARGUMENTS', 'Model is not available'); + ports.resolveModel.mockRejectedValue(unusable); + ports.defaultSessionModel.mockRejectedValue(unusable); + gateway.registerArena(ARENA); + + await expect(gateway.models(PAGE)).rejects.toMatchObject({ code: 'INVALID_ARGUMENTS' }); + await expect(gateway.run(PAGE, { text: 'x' })).rejects.toMatchObject({ + code: 'INVALID_ARGUMENTS', + }); + await expect(gateway.create(PAGE)).rejects.toMatchObject({ code: 'INVALID_ARGUMENTS' }); + expect(ports.runTurn).not.toHaveBeenCalled(); + }); + + it('lists the agents the runtime offers, mavis being the primary one', async () => { + const { gateway } = setup(); + gateway.registerArena(ARENA); + + await expect(gateway.agents(PAGE)).resolves.toEqual({ agents: AGENT_OPTIONS }); + }); + + it.each(['models', 'agents', 'create'] as const)( + '%s refuses a deny-all policy and a stale caller like run', + async (method) => { + const { gateway, ports } = setup(); + const call = (caller: MiniAppAgentCaller) => gateway[method](caller); + + await expect(call(PAGE)).rejects.toMatchObject({ code: 'NOT_PERMITTED' }); + gateway.registerArena({ ...ARENA, policy: { kind: 'deny-all' } }); + await expect(call(PAGE)).rejects.toMatchObject({ code: 'NOT_PERMITTED' }); + gateway.registerArena(ARENA); + await expect(call({ ...PAGE, processGeneration: 'gen-0' })).rejects.toMatchObject({ + code: 'AGENT_UNAVAILABLE', + retryable: true, + }); + expect(ports.listModels).not.toHaveBeenCalled(); + expect(ports.listAgents).not.toHaveBeenCalled(); + expect(ports.createSession).not.toHaveBeenCalled(); + }, + ); + + it('creates a session bound like a first run would, without a turn or a run slot', async () => { + const { gateway, ports, logger } = setup(); + ports.resolveHostSession.mockResolvedValue({ ...hostChat, model: HOST_MODEL }); + ports.runTurn.mockImplementation(({ signal }) => untilAborted(signal)); + const release = gateway.registerArena({ + ...ARENA, + policy: { kind: 'allowlist', capabilities: ['session.run'], maxConcurrent: 1 }, + }); + const running = gateway.run(CALLER, { text: 'hold the only slot' }); + await vi.waitFor(() => expect(ports.runTurn).toHaveBeenCalledTimes(1)); + + await expect(gateway.create(PAGE)).resolves.toEqual({ sessionId: 'session-new' }); + + expect(ports.createSession).toHaveBeenLastCalledWith({ + pluginId: 'demo board', + agentName: 'custom-x', + workspaceDir: pluginDir('custom-x'), + createDir: true, + model: HOST_MODEL, + }); + expect(ports.runTurn).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: 'demo board', + sessionId: 'session-new', + operation: 'create', + phase: 'admission', + agentName: 'custom-x', + model: 'byok/orion', + }), + 'Mini App Agent run', + ); + release(); + await running; + }); + + it('creates a Node session in the plugin directory with the runtime default model', async () => { + const { gateway, ports } = setup(); + gateway.registerArena(ARENA); + + await expect(gateway.create(CALLER)).resolves.toEqual({ sessionId: 'session-new' }); + + expect(ports.createSession).toHaveBeenCalledWith({ + pluginId: 'demo board', + agentName: 'mavis', + workspaceDir: pluginDir('mavis'), + createDir: true, + model: DEFAULT_MODEL, + }); + }); +}); + +describe('createMiniAppAgentGateway chosen agent and failing lookups', () => { + const PAGE: MiniAppAgentCaller = { ...CALLER, source: 'page', hostSessionId: 'chat-1' }; + const HOST_MODEL = { providerId: 'byok', modelId: 'orion', variant: 'thinking' }; + const hostChat = { agentName: 'custom-x', workspaceDir: '/home/u/ws', isDefaultWorkspace: true }; + const pluginDir = (agentName: string) => + join('/data', 'agents', agentName, 'miniapps', 'demo%20board'); + + it.each([ + [ + 'create', + (gateway: ReturnType['gateway']) => gateway.create(PAGE, { agent: 'writer' }), + ], + [ + 'run', + (gateway: ReturnType['gateway']) => + gateway.run(PAGE, { text: 'hi', agent: 'writer' }), + ], + ])('%s with a chosen agent binds it, keeping the host model', async (_label, start) => { + const { gateway, ports } = setup(); + ports.resolveHostSession.mockResolvedValue({ ...hostChat, model: HOST_MODEL }); + gateway.registerArena(ARENA); + + await start(gateway); + + expect(ports.createSession).toHaveBeenCalledWith({ + pluginId: 'demo board', + agentName: 'writer', + workspaceDir: pluginDir('writer'), + createDir: true, + model: HOST_MODEL, + }); + }); + + it('keeps the host project directory when a different agent is chosen', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'miniapp-host-project-')); + try { + const { gateway, ports } = setup(); + ports.resolveHostSession.mockResolvedValue({ + agentName: 'custom-x', + workspaceDir: projectDir, + isDefaultWorkspace: false, + model: HOST_MODEL, + }); + gateway.registerArena(ARENA); + + await gateway.create(PAGE, { agent: 'writer' }); + + expect(ports.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + agentName: 'writer', + workspaceDir: projectDir, + createDir: false, + }), + ); + } finally { + await rm(projectDir, { recursive: true, force: true }); + } + }); + + it.each([['nope'], ['main'], [''], [42]])( + 'rejects the agent %j as INVALID_ARGUMENTS without creating a session', + async (agent) => { + const { gateway, ports } = setup(); + gateway.registerArena(ARENA); + + await expect(gateway.create(PAGE, { agent: agent as string })).rejects.toMatchObject({ + code: 'INVALID_ARGUMENTS', + }); + await expect(gateway.run(PAGE, { text: 'hi', agent: agent as string })).rejects.toMatchObject( + { code: 'INVALID_ARGUMENTS' }, + ); + expect(ports.createSession).not.toHaveBeenCalled(); + expect(ports.runTurn).not.toHaveBeenCalled(); + }, + ); + + it('rejects choosing an agent for an existing session before anything runs', async () => { + const { gateway, ports } = setup(); + ports.getOwner.mockResolvedValue('demo board'); + gateway.registerArena(ARENA); + + await expect( + gateway.run(PAGE, { text: 'hi', sessionId: 'mine', agent: 'writer' }), + ).rejects.toMatchObject({ code: 'INVALID_ARGUMENTS' }); + expect(ports.listAgents).not.toHaveBeenCalled(); + expect(ports.runTurn).not.toHaveBeenCalled(); + }); + + it('lets an unusable default model reach the caller as INVALID_ARGUMENTS', async () => { + const { gateway, ports } = setup(); + const unusable = new MiniAppAgentError( + 'INVALID_ARGUMENTS', + 'Model is not available: no default', + ); + ports.resolveModel.mockRejectedValue(unusable); + ports.defaultSessionModel.mockRejectedValue(unusable); + gateway.registerArena(ARENA); + + await expect(gateway.run(CALLER, { text: 'hi' })).rejects.toMatchObject({ + code: 'INVALID_ARGUMENTS', + }); + await expect(gateway.create(CALLER)).rejects.toMatchObject({ code: 'INVALID_ARGUMENTS' }); + expect(ports.createSession).not.toHaveBeenCalled(); + }); + + it.each(['models', 'agents', 'create'] as const)( + '%s reports a failing lookup as retryable unavailable', + async (method) => { + const { gateway, ports } = setup(); + ports.listModels.mockRejectedValue(new Error('db locked')); + ports.listAgents.mockRejectedValue(new Error('db locked')); + ports.createSession.mockRejectedValue(new Error('db locked')); + gateway.registerArena(ARENA); + + await expect(gateway[method](CALLER)).rejects.toMatchObject({ + code: 'AGENT_UNAVAILABLE', + retryable: true, + }); + }, + ); +}); + +function setup(options: { readonly bind?: boolean } = {}) { + const logger = { info: vi.fn(), warn: vi.fn() }; + const gateway = createMiniAppAgentGateway({ dataDir: '/data', logger, nowMs: () => 0 }); + const askListeners: Array<(ask: MiniAppPermissionAsk) => void> = []; + const ports = { + createSession: vi.fn(async () => ({ + sessionId: 'session-new', + })), + getOwner: vi.fn(async () => undefined), + resolveMiniAppRoot: vi.fn(), + defaultSessionModel: vi.fn(async () => ({ + selection: DEFAULT_MODEL, + })), + resolveModel: vi.fn( + async (requested) => requested ?? DEFAULT_MODEL, + ), + listModels: vi.fn(async () => MODEL_OPTIONS), + listAgents: vi.fn(async () => AGENT_OPTIONS), + readSessionModel: vi.fn(async () => undefined), + setSessionModel: vi.fn(async () => undefined), + resolveHostSession: vi.fn( + async () => undefined, + ), + runTurn: vi.fn(async () => ({ + turnId: 'turn-1', + status: 'finished', + text: 'reply', + })), + answerQuestion: vi.fn(async () => ({ + turnId: 'turn-2', + status: 'finished', + text: 'continued', + })), + replyPermission: vi.fn(async () => 'processed'), + subscribePermissionAsks: vi.fn( + (listener) => { + askListeners.push(listener); + return () => askListeners.splice(askListeners.indexOf(listener), 1); + }, + ), + }; + // A Mini App session is its own root unless a test models delegated children. + ports.resolveMiniAppRoot.mockImplementation(async (sessionId) => { + const pluginId = await ports.getOwner(sessionId); + return pluginId ? { rootSessionId: sessionId, pluginId } : undefined; + }); + if (options.bind !== false) gateway.bind(ports); + const ask = (value: MiniAppPermissionAsk): void => { + for (const listener of askListeners) listener(value); + }; + return { gateway, ports, logger, ask }; +} + +function untilAborted( + signal: AbortSignal | undefined, +): ReturnType { + return new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve({ turnId: 'turn-1', status: 'aborted' }), { + once: true, + }); + }); +} + +/** Holds each turn until `finish(n)` (n-th turn started, from 0) or its abort. */ +function holdTurns(ports: ReturnType['ports']) { + const finishers: Array<() => void> = []; + ports.runTurn.mockImplementation(({ signal }) => { + const turn = untilAborted(signal); + return Promise.race([ + turn, + new Promise>((resolve) => { + finishers.push(() => resolve({ turnId: 'turn-1', status: 'finished' })); + }), + ]); + }); + return { + finish: (index: number) => finishers[index]?.(), + texts: () => ports.runTurn.mock.calls.map(([input]) => input.text), + }; +} diff --git a/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-model-selection.test.ts b/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-model-selection.test.ts new file mode 100644 index 000000000..2cf91d612 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-model-selection.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { + modelSelectionFromRecord, + readMiniAppAgentModelSelection, + sessionModelFields, +} from '../../../src/service/miniapp/agent/model-selection.js'; + +describe('Mini App model selection', () => { + it('reads the contract shape and nothing else', () => { + expect( + readMiniAppAgentModelSelection({ + providerId: 'minimax', + modelId: 'M3', + variant: 'fast', + thinking: { effort: 'high' }, + extra: 1, + }), + ).toEqual({ + providerId: 'minimax', + modelId: 'M3', + variant: 'fast', + thinking: { effort: 'high' }, + }); + for (const bad of [ + null, + {}, + { providerId: '', modelId: 'x' }, + { providerId: 'p', modelId: 'm', thinking: { effort: 3 } }, + { providerId: 'p', modelId: 'm', variant: 7 }, + ]) { + expect(readMiniAppAgentModelSelection(bad)).toBeNull(); + } + }); + + it('round-trips a session record', () => { + const selection = { providerId: 'minimax', modelId: 'M3', thinking: { effort: 'low' } }; + const fields = sessionModelFields(selection); + expect(fields).toEqual({ + effectiveModel: 'minimax/M3', + effectiveModelVariant: null, + effectiveModelThinking: { effort: 'low' }, + }); + expect(modelSelectionFromRecord(fields)).toEqual(selection); + expect(modelSelectionFromRecord({ effectiveModel: '' })).toBeUndefined(); + expect( + modelSelectionFromRecord({ + effectiveModel: 'byok-acme/orion/with-slash', + effectiveModelVariant: 'v', + }), + ).toEqual({ providerId: 'byok-acme', modelId: 'orion/with-slash', variant: 'v' }); + }); + + it('keeps variant "" (thinking explicitly off) end to end, distinct from no variant', () => { + expect(readMiniAppAgentModelSelection({ providerId: 'p', modelId: 'm', variant: '' })).toEqual({ + providerId: 'p', + modelId: 'm', + variant: '', + }); + const off = { providerId: 'p', modelId: 'm', variant: '' }; + const fields = sessionModelFields(off); + expect(fields.effectiveModelVariant).toBe(''); + expect(sessionModelFields({ providerId: 'p', modelId: 'm' }).effectiveModelVariant).toBeNull(); + expect(modelSelectionFromRecord(fields)).toEqual(off); + expect(modelSelectionFromRecord({ ...fields, effectiveModelVariant: null })).toEqual({ + providerId: 'p', + modelId: 'm', + }); + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-policy.test.ts b/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-policy.test.ts new file mode 100644 index 000000000..a1e37834a --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-policy.test.ts @@ -0,0 +1,102 @@ +/** + * The parsed manifest's `agentAccess` becomes the runtime's admission policy. + * + * The reader half (`validation.ts`) is pinned by `miniapp-manifest.test.ts`; this suite covers the + * seam after it: the contribution a real reader produces, projected onto the candidate the runtime + * admits Mini App agent calls from. Nothing here is a mock — the manifest is real files on disk and + * the contribution is what `readOptionalMiniAppContribution` returns for them. + */ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { readOptionalMiniAppContribution } from '../../../src/service/plugin-system/plugin/package/miniapp/reader.js'; +import type { RuntimeEligibleScannedReadPluginPackage } from '../../../src/service/plugin-system/plugin/package/types.js'; +import { miniAppCandidateFromPackage } from '../../../src/service/plugin-system/plugin/runtime/miniapp/candidate.js'; + +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); +}); + +async function writeTree(root: string, files: Record): Promise { + for (const [relativePath, content] of Object.entries(files)) { + const target = join(root, relativePath); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content); + } +} + +/** The smallest package the reader accepts, plus whatever agent fields the case declares. */ +async function candidateFor(miniapp: Record) { + const root = await mkdtemp(join(tmpdir(), 'miniapp-agent-policy-')); + roots.push(root); + await writeTree(root, { + 'package.json': JSON.stringify({ + name: 'miniapp-fixture', + version: '0.0.0', + mcode: { schemaVersion: 2, miniApp: './miniapp/miniapp.json' }, + }), + 'miniapp/miniapp.json': JSON.stringify({ + schemaVersion: 1, + artifacts: { client: ['./miniapp/client'], node: ['./miniapp/node'] }, + runtime: { kind: 'process', entry: './miniapp/node/server.js' }, + surface: { path: '/dashboard' }, + ...miniapp, + }), + 'miniapp/client/index.html': '\n', + 'miniapp/node/server.js': 'export {};\n', + }); + const contribution = await readOptionalMiniAppContribution(root, []); + if (!contribution) throw new Error('reader rejected the fixture package'); + return miniAppCandidateFromPackage({ + plugin: { + name: 'demo.board', + rootPath: root, + miniapp: contribution, + } as unknown as RuntimeEligibleScannedReadPluginPackage, + }); +} + +describe('Mini App agent policy from the parsed manifest', () => { + it('admits session.run and carries the declared concurrency', async () => { + const candidate = await candidateFor({ agentAccess: ['session.run'], agentConcurrency: 2 }); + + expect(candidate.agentPolicy).toEqual({ + kind: 'allowlist', + capabilities: ['session.run'], + maxConcurrent: 2, + }); + }); + + it('defaults the concurrency when agentAccess is declared without agentConcurrency', async () => { + const candidate = await candidateFor({ agentAccess: ['session.run'] }); + + expect(candidate.agentPolicy).toEqual({ + kind: 'allowlist', + capabilities: ['session.run'], + maxConcurrent: 4, + }); + }); + + it('denies everything when the manifest declares no agentAccess', async () => { + const candidate = await candidateFor({}); + + expect(candidate.agentPolicy).toEqual({ kind: 'deny-all' }); + }); + + it('denies everything for an empty agentAccess list', async () => { + const candidate = await candidateFor({ agentAccess: [] }); + + expect(candidate.agentPolicy).toEqual({ kind: 'deny-all' }); + }); + + it('keeps agentConcurrency inert without agentAccess', async () => { + const candidate = await candidateFor({ agentConcurrency: 7 }); + + expect(candidate.agentPolicy).toEqual({ kind: 'deny-all' }); + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-session-ports-projection.test.ts b/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-session-ports-projection.test.ts new file mode 100644 index 000000000..43f480426 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-session-ports-projection.test.ts @@ -0,0 +1,185 @@ +import { + MsgType, + RespDataType, + Role, + RuntimeEventStatus, + RuntimeEventType, + ToolCallStatus, +} from '@mavis/agent-core/protocol'; +import { describe, expect, it } from 'vitest'; + +import type { SessionFrame } from '../../../src/service/session-system/index.js'; +import { createFrameProjector } from '../../../src/service/miniapp/agent/session-ports-projection.js'; + +const MODEL = { providerId: 'minimax', modelId: 'M3', thinking: { effort: 'high' } }; + +describe('createFrameProjector', () => { + it('emits turn.started once for the running session status', () => { + const projector = createFrameProjector('session-1', 'turn-1', MODEL); + + expect(projector.project(runtimeFrame(runningStatus()))).toEqual([ + { type: 'turn.started', sessionId: 'session-1', turnId: 'turn-1', model: MODEL }, + ]); + expect(projector.project(runtimeFrame(runningStatus()))).toEqual([]); + }); + + it('maps assistant text chunks to text.delta and skips chunks without text', () => { + const projector = createFrameProjector('session-1', 'turn-1', MODEL); + + expect(projector.project(chunkFrame({ msg_content: 'Hel' }))).toEqual([ + { type: 'text.delta', sessionId: 'session-1', turnId: 'turn-1', text: 'Hel' }, + ]); + expect(projector.project(chunkFrame({ thinking_content: 'hmm' }))).toEqual([]); + expect(projector.project(chunkFrame({ finish: true, finish_reason: 'stop' }))).toEqual([]); + expect(projector.project(chunkFrame({ msg_content: 'echo', role: Role.User }))).toEqual([]); + }); + + it('maps a completed assistant message to message.committed', () => { + const projector = createFrameProjector('session-1', 'turn-1', MODEL); + + expect( + projector.project(messageFrame({ msg_content: 'final', msg_type: MsgType.AgentContent })), + ).toEqual([ + { type: 'message.committed', sessionId: 'session-1', turnId: 'turn-1', text: 'final' }, + ]); + expect(projector.project(messageFrame({ msg_content: ' ' }))).toEqual([]); + expect( + projector.project(messageFrame({ msg_content: '[Error] boom', finish_reason: 'error' })), + ).toEqual([]); + expect( + projector.project(messageFrame({ msg_content: 'compacted', kind: 'compaction' })), + ).toEqual([]); + }); + + it('maps tool lifecycle chunks to tool.started and tool.finished once per call', () => { + const projector = createFrameProjector('session-1', 'turn-1', MODEL); + + expect(projector.project(toolChunk('call-1', 'read', ToolCallStatus.Preparing))).toEqual([]); + expect(projector.project(toolChunk('call-1', 'read', ToolCallStatus.Start))).toEqual([ + { type: 'tool.started', sessionId: 'session-1', turnId: 'turn-1', name: 'read' }, + ]); + expect(projector.project(toolChunk('call-1', 'read', ToolCallStatus.Failed))).toEqual([ + { type: 'tool.finished', sessionId: 'session-1', turnId: 'turn-1', name: 'read', ok: false }, + ]); + // The completed message repeats the settled calls; they were already reported. + expect( + projector.project( + messageFrame({ + msg_content: 'let me check', + msg_type: MsgType.AgentToolCall, + tool_calls: [toolCall('call-1', 'read', ToolCallStatus.Failed)], + }), + ), + ).toEqual([ + { type: 'message.committed', sessionId: 'session-1', turnId: 'turn-1', text: 'let me check' }, + ]); + }); + + it('reports tool calls first seen on a completed message, even without text', () => { + const projector = createFrameProjector('session-1', 'turn-1', MODEL); + + expect( + projector.project( + messageFrame({ + msg_content: '', + msg_type: MsgType.AgentToolCall, + tool_calls: [ + toolCall('call-1', 'bash', ToolCallStatus.Finished), + toolCall('call-2', 'grep', ToolCallStatus.Start), + ], + }), + ), + ).toEqual([ + { type: 'tool.started', sessionId: 'session-1', turnId: 'turn-1', name: 'bash' }, + { type: 'tool.finished', sessionId: 'session-1', turnId: 'turn-1', name: 'bash', ok: true }, + { type: 'tool.started', sessionId: 'session-1', turnId: 'turn-1', name: 'grep' }, + ]); + }); + + it('resolves a tool result without a name from its started call', () => { + const projector = createFrameProjector('session-1', 'turn-1', MODEL); + projector.project(toolChunk('call-1', 'write', ToolCallStatus.Start)); + + expect(projector.project(toolChunk('call-1', '', ToolCallStatus.Finished))).toEqual([ + { type: 'tool.finished', sessionId: 'session-1', turnId: 'turn-1', name: 'write', ok: true }, + ]); + expect(projector.project(toolChunk('call-9', '', ToolCallStatus.Finished))).toEqual([]); + }); + + it('ignores malformed data, other turns and non-runtime frames', () => { + const projector = createFrameProjector('session-1', 'turn-1', MODEL); + + expect(projector.project(runtimeFrame('{not json'))).toEqual([]); + expect(projector.project(runtimeFrame(JSON.stringify([1, 2])))).toEqual([]); + expect( + projector.project(runtimeFrame(JSON.stringify({ type: RespDataType.AgentMessageChunk }))), + ).toEqual([]); + expect( + projector.project( + runtimeFrame( + JSON.stringify({ + type: RespDataType.AgentMessageChunk, + agent_message_chunk: { msg_id: 'm', role: Role.Assistant, tool_calls: 'x' }, + }), + ), + ), + ).toEqual([]); + expect(projector.project(runtimeFrame(null))).toEqual([]); + expect(projector.project(chunkFrame({ msg_content: 'other' }, 'turn-0'))).toEqual([]); + expect(projector.project({ ...runtimeFrame(runningStatus()), kind: 'session-status' })).toEqual( + [], + ); + expect( + projector.project({ ...chunkFrame({ msg_content: 'x' }), kind: 'durable-message' }), + ).toEqual([]); + }); +}); + +function runningStatus(): Record { + return { + event_id: 'e1', + session_id: 'session-1', + turn_id: 'turn-1', + type: RuntimeEventType.SESSION_STATUS, + payload: { status: RuntimeEventStatus.RUNNING }, + }; +} + +function chunkFrame(chunk: Record, turnId = 'turn-1'): SessionFrame { + return runtimeFrame( + JSON.stringify({ + type: RespDataType.AgentMessageChunk, + agent_message_chunk: { msg_id: 'm1', role: Role.Assistant, turn_id: turnId, ...chunk }, + }), + turnId, + ); +} + +function messageFrame(message: Record): SessionFrame { + return runtimeFrame( + JSON.stringify({ + type: RespDataType.AgentMessage, + agent_message: { msg_id: 'm1', role: Role.Assistant, turn_id: 'turn-1', ...message }, + }), + ); +} + +function toolChunk(id: string, name: string, status: ToolCallStatus): SessionFrame { + return chunkFrame({ tool_calls: [toolCall(id, name, status)] }); +} + +function toolCall(id: string, name: string, status: ToolCallStatus): Record { + return { tool_call_id: id, tool_name: name, tool_call_status: status }; +} + +function runtimeFrame(data: unknown, turnId = 'turn-1'): SessionFrame { + return { + identity: 'runtime:e1', + sessionId: 'session-1', + turnId, + kind: 'runtime-event', + data, + cursor: 'cursor-1', + createdAtMs: 5, + }; +} diff --git a/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-session-ports.test.ts b/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-session-ports.test.ts new file mode 100644 index 000000000..6756ebd95 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/miniapp-agent-session-ports.test.ts @@ -0,0 +1,1457 @@ +import { mkdtemp, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + RespDataType, + Role, + RuntimeEventStatus, + RuntimeEventType, +} from '@mavis/agent-core/protocol'; +import type { GlobalEvent } from '@mavis/shared/global-events'; +import type { AskQuestionnaireRequest } from '@mavis/shared/questionnaire'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ModelProviderModelEntry } from '../../../src/service/model-system/index.js'; +import type { SessionFrame } from '../../../src/service/session-system/index.js'; +import type { MiniAppAgentRunEvent } from '../../../src/service/miniapp/agent/contracts.js'; +import { + createMiniAppAgentSessionPorts, + type MiniAppAgentSessionPortsOptions, +} from '../../../src/service/miniapp/agent/session-ports.js'; + +const cleanup: string[] = []; + +/** What `fakes()` resolves as the runtime default model (M3 has no thinking: variant `''`). */ +const DEFAULT_MODEL = { providerId: 'minimax', modelId: 'M3', variant: '' }; +const BYOK_CONFIG = { + dataDir: '/tmp/miniapp-agent-ports-byok-config', + defaultModel: 'byok-acme/orion', + defaultModelVariant: 'thinking', + defaultModelThinking: { effort: 'low' }, + defaultModelContextWindow: 128_000, + provider: { + 'byok-acme': { + models: { + orion: { + limit: { context: 128_000, output: 16_000 }, + contextWindowOptions: [128_000], + thinking: { effortOptions: ['low', 'high'], defaultEffort: 'high' }, + }, + }, + }, + }, +}; +const RUNTIME_CONFIG = { + dataDir: '/tmp/miniapp-agent-ports-config', + defaultModel: 'minimax/M3', + provider: { + minimax: { + models: { M3: { limit: { context: 512_000, output: 16_000 } } }, + }, + }, +}; + +afterEach(async () => { + for (const dir of cleanup.splice(0)) await rm(dir, { recursive: true, force: true }); +}); + +describe('createMiniAppAgentSessionPorts records', () => { + it('creates a hidden session of the given agent tagged with the plugin and makes its workspace', async () => { + const root = await mkdtemp(join(tmpdir(), 'miniapp-agent-ports-')); + cleanup.push(root); + const workspaceDir = join(root, 'agent-workspace'); + const fake = fakes(); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect( + ports.createSession({ + pluginId: 'demo.board', + agentName: 'custom-x', + workspaceDir, + createDir: true, + model: { providerId: 'minimax', modelId: 'M3', thinking: { effort: 'high' } }, + }), + ).resolves.toEqual({ sessionId: 'session-1' }); + + expect((await stat(workspaceDir)).isDirectory()).toBe(true); + expect(fake.options.records.createInternalSession).toHaveBeenCalledWith( + expect.objectContaining({ + agentName: 'custom-x', + sessionType: 'branch', + sessionKind: 'conversation', + visibility: 'hidden', + purpose: 'miniapp:demo.board', + workspaceDir, + isDefaultWorkspace: false, + + title: 'demo.board', + effectiveModel: 'minimax/M3', + effectiveModelThinking: { effort: 'high' }, + }), + ); + }); + + it('works in the host chat project directory without creating it', async () => { + const root = await mkdtemp(join(tmpdir(), 'miniapp-agent-ports-')); + cleanup.push(root); + const projectDir = join(root, 'deleted-project'); + const fake = fakes(); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await ports.createSession({ + pluginId: 'demo.board', + agentName: 'mavis', + workspaceDir: projectDir, + createDir: false, + model: DEFAULT_MODEL, + }); + + await expect(stat(projectDir)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(fake.options.records.createInternalSession).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: projectDir, + isDefaultWorkspace: false, + + }), + ); + }); + + it('reads the owner from the session record', async () => { + const fake = fakes(); + fake.sessions.set('owned', { purpose: 'miniapp:demo' }); + fake.sessions.set('plain', {}); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect(ports.getOwner('owned')).resolves.toBe('demo'); + await expect(ports.getOwner('plain')).resolves.toBeUndefined(); + await expect(ports.getOwner('missing')).resolves.toBeUndefined(); + }); + + it('reads what a new Mini App session takes from its host chat session record', async () => { + const fake = fakes(); + fake.sessions.set('chat', { + agentName: 'custom-x', + workspaceDir: '/work/project', + isDefaultWorkspace: false, + parentSessionId: 'other', + effectiveModel: 'minimax/M3', + effectiveModelVariant: 'fast', + }); + fake.sessions.set('modelless-chat', { + agentName: 'custom-x', + workspaceDir: '/work/project', + isDefaultWorkspace: false, + effectiveModel: '', + }); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect(ports.resolveHostSession('chat')).resolves.toEqual({ + agentName: 'custom-x', + workspaceDir: '/work/project', + isDefaultWorkspace: false, + model: { providerId: 'minimax', modelId: 'M3', variant: 'fast' }, + }); + await expect(ports.resolveHostSession('modelless-chat')).resolves.toMatchObject({ + model: undefined, + }); + await expect(ports.resolveHostSession('missing')).resolves.toBeUndefined(); + fake.options.repository.get.mockRejectedValueOnce(new Error('db locked')); + await expect(ports.resolveHostSession('chat')).rejects.toMatchObject({ + code: 'AGENT_UNAVAILABLE', + retryable: true, + }); + }); + + it('resolves the runtime default model, or validates a requested one like the composer', async () => { + const fake = fakes(); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect(ports.resolveModel()).resolves.toEqual(DEFAULT_MODEL); + await expect(ports.resolveModel({ providerId: 'minimax', modelId: 'M3' })).resolves.toEqual( + DEFAULT_MODEL, + ); + await expect(ports.resolveModel({ providerId: 'nope', modelId: 'x' })).rejects.toMatchObject({ + code: 'INVALID_ARGUMENTS', + }); + }); + + it('refuses a model this build does not offer instead of dispatching the turn', async () => { + // The shipped generation replaced a retired official model with the default. This build scopes + // that replacement to the legacy managed provider alias (`isLegacyManagedMinimaxProvider`, + // packages/config/src/provider-auth-mode.ts:29): for an ordinary provider the request fails + // closed here rather than starting a turn against a model the route does not serve. + const fake = fakes(); + fake.options.modelConfig.mockReturnValue({ + ...RUNTIME_CONFIG, + provider: { + minimax: { + options: { authMode: 'managed-login' }, + models: RUNTIME_CONFIG.provider.minimax.models, + model_order: ['M3'], + }, + }, + }); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect( + ports.resolveModel({ providerId: 'minimax', modelId: 'MiniMax-M2.7' }), + ).rejects.toMatchObject({ code: 'INVALID_ARGUMENTS' }); + }); + + it('refuses a default model that is not configured', async () => { + const fake = fakes(); + fake.options.modelConfig.mockReturnValue({ dataDir: '/tmp/x', provider: {} }); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect(ports.resolveModel()).rejects.toMatchObject({ code: 'INVALID_ARGUMENTS' }); + }); + + it('reads the session record model, and nothing for a record without one or none at all', async () => { + const fake = fakes(); + fake.sessions.set('with', { + effectiveModel: 'minimax/M3', + effectiveModelThinking: { effort: 'low' }, + }); + fake.sessions.set('without', { effectiveModel: null }); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect(ports.readSessionModel('with')).resolves.toEqual({ + providerId: 'minimax', + modelId: 'M3', + thinking: { effort: 'low' }, + }); + await expect(ports.readSessionModel('without')).resolves.toBeUndefined(); + await expect(ports.readSessionModel('missing')).resolves.toBeUndefined(); + fake.options.repository.get.mockRejectedValueOnce(new Error('db locked')); + await expect(ports.readSessionModel('with')).rejects.toMatchObject({ + code: 'AGENT_UNAVAILABLE', + retryable: true, + }); + }); + + it('resolves the Mini App root through delegated child sessions', async () => { + const fake = fakes(); + fake.sessions.set('root', { purpose: 'miniapp:demo' }); + fake.sessions.set('child', { parentSessionId: 'root' }); + fake.sessions.set('grandchild', { parentSessionId: 'child' }); + fake.sessions.set('chat', {}); + fake.sessions.set('page-root', { + purpose: 'miniapp:demo', + }); + fake.sessions.set('page-child', { parentSessionId: 'page-root' }); + fake.sessions.set('loop-a', { parentSessionId: 'loop-b' }); + fake.sessions.set('loop-b', { parentSessionId: 'loop-a' }); + for (let depth = 0; depth < 20; depth += 1) { + fake.sessions.set(`deep-${depth}`, { parentSessionId: depth ? `deep-${depth - 1}` : 'root' }); + } + const ports = createMiniAppAgentSessionPorts(fake.options); + + const root = { rootSessionId: 'root', pluginId: 'demo' }; + await expect(ports.resolveMiniAppRoot('root')).resolves.toEqual(root); + await expect(ports.resolveMiniAppRoot('grandchild')).resolves.toEqual(root); + await expect(ports.resolveMiniAppRoot('chat')).resolves.toBeUndefined(); + // Where the root was created is not part of the answer. + await expect(ports.resolveMiniAppRoot('page-child')).resolves.toEqual({ + rootSessionId: 'page-root', + pluginId: 'demo', + }); + await expect(ports.resolveMiniAppRoot('missing')).resolves.toBeUndefined(); + expect(fake.options.logger.warn).not.toHaveBeenCalled(); + + await expect(ports.resolveMiniAppRoot('loop-a')).resolves.toBeUndefined(); + await expect(ports.resolveMiniAppRoot('deep-19')).resolves.toBeUndefined(); + expect(fake.options.logger.warn.mock.calls).toEqual([ + [ + { sessionId: 'loop-a', hops: 2, cycle: true }, + 'Mini App root lookup stopped at the hop limit or a cycle', + ], + [ + { sessionId: 'deep-19', hops: 17, cycle: false }, + 'Mini App root lookup stopped at the hop limit or a cycle', + ], + ]); + }); +}); + +describe('createMiniAppAgentSessionPorts model snapshot', () => { + it('applies the global variant, thinking and context window to the runtime default', async () => { + const fake = fakes(); + fake.options.modelConfig.mockReturnValue(BYOK_CONFIG); + const ports = createMiniAppAgentSessionPorts(fake.options); + const selection = { + providerId: 'byok-acme', + modelId: 'orion', + variant: 'thinking', + thinking: { effort: 'low' }, + }; + + await expect(ports.defaultSessionModel()).resolves.toEqual({ + selection, + contextWindow: 128_000, + }); + await expect(ports.resolveModel()).resolves.toEqual(selection); + }); + + it('keeps variant "" (thinking off) when resolving, creating and switching', async () => { + const root = await mkdtemp(join(tmpdir(), 'miniapp-agent-ports-')); + cleanup.push(root); + const fake = fakes(); + fake.options.modelConfig.mockReturnValue(BYOK_CONFIG); + fake.sessions.set('chat', { + agentName: 'mavis', + workspaceDir: '/work/project', + isDefaultWorkspace: false, + effectiveModel: 'byok-acme/orion', + effectiveModelVariant: '', + }); + const ports = createMiniAppAgentSessionPorts(fake.options); + const off = { providerId: 'byok-acme', modelId: 'orion', variant: '' }; + + await expect(ports.resolveHostSession('chat')).resolves.toMatchObject({ model: off }); + await expect(ports.resolveModel(off)).resolves.toMatchObject(off); + await ports.createSession({ + pluginId: 'demo.board', + agentName: 'mavis', + workspaceDir: join(root, 'ws'), + createDir: true, + model: off, + }); + await ports.setSessionModel('mine', off); + + expect(fake.options.records.createInternalSession).toHaveBeenCalledWith( + expect.objectContaining({ effectiveModel: 'byok-acme/orion', effectiveModelVariant: '' }), + ); + expect(fake.options.lifecycle.mutateSession).toHaveBeenCalledWith( + 'mine', + expect.objectContaining({ effectiveModelVariant: '', effectiveModelContextWindow: null }), + ); + }); + + it('writes the default context window only when the gateway hands it over', async () => { + const root = await mkdtemp(join(tmpdir(), 'miniapp-agent-ports-')); + cleanup.push(root); + const fake = fakes(); + const ports = createMiniAppAgentSessionPorts(fake.options); + const base = { + pluginId: 'demo.board', + agentName: 'mavis', + workspaceDir: join(root, 'ws'), + createDir: true, + model: DEFAULT_MODEL, + }; + + await ports.createSession({ ...base, contextWindow: 128_000 }); + await ports.createSession(base); + + const written = fake.options.records.createInternalSession; + expect(written).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ effectiveModelContextWindow: 128_000 }), + ); + expect(written).toHaveBeenLastCalledWith( + expect.not.objectContaining({ effectiveModelContextWindow: expect.anything() }), + ); + }); +}); + +describe('createMiniAppAgentSessionPorts runTurn', () => { + it('projects frames in order and returns the final assistant text on completion', async () => { + const fake = fakes(); + fake.messages.push( + { role: 'user', msg_content: 'question' }, + { role: 'assistant', msg_content: 'thinking aloud' }, + { role: 'assistant', msg_content: 'final answer' }, + ); + const ports = createMiniAppAgentSessionPorts(fake.options); + const events: MiniAppAgentRunEvent[] = []; + + const run = ports.runTurn({ + sessionId: 'session-1', + text: 'hello', + onEvent: (event) => events.push(event), + }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + fake.push(frame('runtime-event', textChunk('fin'))); + fake.push(frame('message-committed', { messages: [] })); + fake.push(frame('turn-terminal', { status: 'completed' })); + + await expect(run).resolves.toEqual({ + turnId: 'turn-1', + model: DEFAULT_MODEL, + status: 'finished', + text: 'final answer', + }); + // turn.finished carries the settled text read after the terminal frame. + expect(events).toEqual([ + { type: 'text.delta', sessionId: 'session-1', turnId: 'turn-1', text: 'fin' }, + { + type: 'turn.finished', + sessionId: 'session-1', + turnId: 'turn-1', + status: 'finished', + text: 'final answer', + }, + ]); + expect(fake.options.turns.steer).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-1', + input: { text: 'hello' }, + producerId: 'miniapp:session-1', + provenance: expect.objectContaining({ source: 'miniapp' }), + }), + ); + expect(fake.options.messages.listTurn).toHaveBeenCalledWith('session-1', 'turn-1'); + }); + + it('maps a failed terminal to an error result', async () => { + const fake = fakes(); + const ports = createMiniAppAgentSessionPorts(fake.options); + const events: MiniAppAgentRunEvent[] = []; + + const run = ports.runTurn({ + sessionId: 'session-1', + text: 'hello', + onEvent: (event) => events.push(event), + }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + fake.push(frame('turn-terminal', { status: 'failed', error: 'boom', errorSource: 'model' })); + + const error = { code: 'model', message: 'The Agent turn failed.' }; + await expect(run).resolves.toEqual({ + turnId: 'turn-1', + model: DEFAULT_MODEL, + status: 'error', + error, + }); + expect(events).toEqual([ + { type: 'turn.finished', sessionId: 'session-1', turnId: 'turn-1', status: 'error', error }, + ]); + }); + + it('never forwards free-text runtime error sources as the error code', async () => { + const fake = fakes(); + const ports = createMiniAppAgentSessionPorts(fake.options); + + const run = ports.runTurn({ sessionId: 'session-1', text: 'hello' }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + fake.push(frame('turn-terminal', { status: 'failed', errorSource: 'db at /private/x failed' })); + + await expect(run).resolves.toMatchObject({ + error: { code: 'TURN_FAILED', message: 'The Agent turn failed.' }, + }); + }); + + it('aborts the running turn and resolves aborted when the signal fires', async () => { + const fake = fakes(); + const ports = createMiniAppAgentSessionPorts(fake.options); + const controller = new AbortController(); + + const events: MiniAppAgentRunEvent[] = []; + const run = ports.runTurn({ + sessionId: 'session-1', + text: 'hello', + signal: controller.signal, + onEvent: (event) => events.push(event), + }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + controller.abort(); + await vi.waitFor(() => + expect(fake.options.turns.abort).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1', turnId: 'turn-1' }), + ), + ); + let settled = false; + void run.then(() => { + settled = true; + }); + fake.push(frame('turn-terminal', { status: 'failed' }, 'turn-0')); + await new Promise((resolve) => setTimeout(resolve, 10)); + // Still waiting for its own terminal so a follow-up run finds the session idle. + expect(settled).toBe(false); + fake.push(frame('turn-terminal', { status: 'aborted' })); + + await expect(run).resolves.toEqual({ + turnId: 'turn-1', + model: DEFAULT_MODEL, + status: 'aborted', + }); + expect(events).toEqual([ + { type: 'turn.finished', sessionId: 'session-1', turnId: 'turn-1', status: 'aborted' }, + ]); + expect(fake.closed()).toBe(true); + }); + + it('stops waiting for the aborted terminal after the settle timeout once the abort was acknowledged', async () => { + vi.useFakeTimers(); + try { + const fake = fakes(); + const ports = createMiniAppAgentSessionPorts(fake.options); + const controller = new AbortController(); + + const run = ports.runTurn({ + sessionId: 'session-1', + text: 'hello', + signal: controller.signal, + }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + controller.abort(); + await vi.waitFor(() => expect(fake.options.turns.abort).toHaveBeenCalled()); + await vi.advanceTimersByTimeAsync(15_000); + + await expect(run).resolves.toEqual({ + turnId: 'turn-1', + model: DEFAULT_MODEL, + status: 'aborted', + }); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps running when the event listener throws', async () => { + const fake = fakes(); + fake.messages.push({ role: 'assistant', msg_content: 'done' }); + const ports = createMiniAppAgentSessionPorts(fake.options); + + const run = ports.runTurn({ + sessionId: 'session-1', + text: 'hello', + onEvent: () => { + throw new Error('listener failed'); + }, + }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + fake.push(frame('runtime-event', textChunk('d'))); + fake.push(frame('turn-terminal', { status: 'completed' })); + + await expect(run).resolves.toMatchObject({ status: 'finished', text: 'done' }); + }); + + it('rejects as unavailable when the steer is not delivered', async () => { + const fake = fakes(); + fake.options.turns.steer.mockResolvedValueOnce({ + delivered: false, + reason: 'delivery-closed', + }); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect(ports.runTurn({ sessionId: 'session-1', text: 'hello' })).rejects.toMatchObject({ + code: 'AGENT_UNAVAILABLE', + }); + expect(fake.closed()).toBe(true); + }); +}); + +describe('createMiniAppAgentSessionPorts run model', () => { + it('reports the session record model on turn.started and the result, not a default', async () => { + const fake = fakes(); + fake.sessions.set('session-1', { + effectiveModel: 'byok/orion', + effectiveModelVariant: 'thinking', + effectiveModelThinking: { effort: 'high' }, + }); + const ports = createMiniAppAgentSessionPorts(fake.options); + const events: MiniAppAgentRunEvent[] = []; + const model = { + providerId: 'byok', + modelId: 'orion', + variant: 'thinking', + thinking: { effort: 'high' }, + }; + + const run = ports.runTurn({ + sessionId: 'session-1', + text: 'hello', + onEvent: (event) => events.push(event), + }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + fake.push( + frame('runtime-event', { + type: RuntimeEventType.SESSION_STATUS, + payload: { status: RuntimeEventStatus.RUNNING }, + }), + ); + fake.push(frame('turn-terminal', { status: 'completed' })); + + await expect(run).resolves.toMatchObject({ turnId: 'turn-1', status: 'finished', model }); + expect(events[0]).toEqual({ + type: 'turn.started', + sessionId: 'session-1', + turnId: 'turn-1', + model, + }); + expect(fake.options.modelConfig).not.toHaveBeenCalled(); + }); + + it('reports the runtime default model for a record that predates the model snapshot', async () => { + const fake = fakes(); + fake.sessions.set('session-1', { effectiveModel: null }); + const ports = createMiniAppAgentSessionPorts(fake.options); + const events: MiniAppAgentRunEvent[] = []; + + const run = ports.runTurn({ + sessionId: 'session-1', + text: 'hello', + onEvent: (event) => events.push(event), + }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + fake.push( + frame('runtime-event', { + type: RuntimeEventType.SESSION_STATUS, + payload: { status: RuntimeEventStatus.RUNNING }, + }), + ); + fake.push(frame('turn-terminal', { status: 'completed' })); + + await expect(run).resolves.toMatchObject({ status: 'finished', model: DEFAULT_MODEL }); + expect(events[0]).toMatchObject({ type: 'turn.started', model: DEFAULT_MODEL }); + }); +}); + +describe('createMiniAppAgentSessionPorts setSessionModel', () => { + it('stores the selection on the session, clearing the parameters and limits it does not carry', async () => { + const fake = fakes(); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await ports.setSessionModel('mine', { + providerId: 'minimax', + modelId: 'M3', + variant: 'fast', + thinking: { effort: 'high' }, + }); + await ports.setSessionModel('mine', { providerId: 'minimax', modelId: 'M3' }); + + expect(fake.options.lifecycle.mutateSession.mock.calls).toEqual([ + [ + 'mine', + { + effectiveModel: 'minimax/M3', + effectiveModelVariant: 'fast', + effectiveModelThinking: { effort: 'high' }, + effectiveModelContextWindow: null, + effectiveModelMaxOutputTokens: null, + }, + ], + [ + 'mine', + { + effectiveModel: 'minimax/M3', + effectiveModelVariant: null, + effectiveModelThinking: null, + effectiveModelContextWindow: null, + effectiveModelMaxOutputTokens: null, + }, + ], + ]); + }); + + it('fails as retryable unavailable, logging the stage, when the record cannot be written', async () => { + const fake = fakes(); + fake.options.lifecycle.mutateSession.mockRejectedValueOnce(new Error('db locked')); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect( + ports.setSessionModel('mine', { providerId: 'minimax', modelId: 'M3' }), + ).rejects.toMatchObject({ code: 'AGENT_UNAVAILABLE', retryable: true }); + expect(fake.options.logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ stage: 'set-session-model', sessionId: 'mine' }), + 'Mini App Agent lookup failed before dispatch', + ); + }); +}); + +describe('createMiniAppAgentSessionPorts turn correlation', () => { + it('ignores frames and terminals of other turns and settles on its own terminal', async () => { + const fake = fakes(); + fake.messages.push({ role: 'assistant', msg_content: 'mine' }); + const ports = createMiniAppAgentSessionPorts(fake.options); + const events: MiniAppAgentRunEvent[] = []; + + const run = ports.runTurn({ + sessionId: 'session-1', + text: 'hello', + onEvent: (event) => events.push(event), + }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + fake.push(frame('runtime-event', textChunk('x', 'turn-0'), 'turn-0')); + fake.push(frame('turn-terminal', { status: 'failed' }, 'turn-0')); + fake.push(frame('session-status', {}, null)); + fake.push(frame('runtime-event', textChunk('y'))); + fake.push(frame('turn-terminal', { status: 'completed' })); + + await expect(run).resolves.toEqual({ + turnId: 'turn-1', + model: DEFAULT_MODEL, + status: 'finished', + text: 'mine', + }); + expect(events).toEqual([ + { type: 'text.delta', sessionId: 'session-1', turnId: 'turn-1', text: 'y' }, + { + type: 'turn.finished', + sessionId: 'session-1', + turnId: 'turn-1', + status: 'finished', + text: 'mine', + }, + ]); + // The stale terminal closed the first iterator; the run resumed from its cursor. + expect(fake.options.stream.resume).toHaveBeenCalledTimes(2); + expect(fake.options.stream.resume).toHaveBeenLastCalledWith({ + sessionId: 'session-1', + afterCursor: 'cursor:turn-terminal:turn-0', + waitForTerminal: true, + }); + }); + + it('rejects as BUSY without forwarding frames when the text was merged into a running turn', async () => { + const fake = fakes(); + fake.options.turns.steer.mockResolvedValueOnce({ + delivered: true, + mode: 'steered', + turnId: 'turn-0', + }); + fake.push(frame('runtime-event', textChunk('busy', 'turn-0'), 'turn-0')); + const onEvent = vi.fn(); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect( + ports.runTurn({ sessionId: 'session-1', text: 'hello', onEvent }), + ).rejects.toMatchObject({ code: 'BUSY', retryable: true, reason: 'session_busy' }); + expect(onEvent).not.toHaveBeenCalled(); + expect(fake.options.turns.abort).not.toHaveBeenCalled(); + expect(fake.closed()).toBe(true); + }); + + it('aborts its turn and rejects when the stream ends before the terminal', async () => { + const fake = fakes(); + const ports = createMiniAppAgentSessionPorts(fake.options); + + const run = ports.runTurn({ sessionId: 'session-1', text: 'hello' }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + fake.end(); + + await expect(run).rejects.toThrow('Session stream ended'); + expect(fake.options.turns.abort).toHaveBeenCalledWith({ + sessionId: 'session-1', + turnId: 'turn-1', + reason: 'miniapp-stream-lost', + }); + }); +}); + +const QUESTION = { + schemaVersion: 2, + id: 'question-1', + presentation: 'inline', + steps: [], +} as unknown as AskQuestionnaireRequest; + +describe('createMiniAppAgentSessionPorts questions', () => { + it('reports the questionnaire a finished turn paused on before turn.finished', async () => { + const fake = fakes(); + fake.sessions.set('session-1', { agentName: 'custom-x' }); + fake.options.questionnaires.getPending.mockResolvedValue(QUESTION); + const ports = createMiniAppAgentSessionPorts(fake.options); + const events: MiniAppAgentRunEvent[] = []; + + const run = ports.runTurn({ + sessionId: 'session-1', + text: 'hello', + onEvent: (event) => events.push(event), + }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + fake.push(frame('turn-terminal', { status: 'completed' })); + + const pendingQuestion = { requestId: 'question-1', mode: 'questionnaire', request: QUESTION }; + await expect(run).resolves.toEqual({ + turnId: 'turn-1', + model: DEFAULT_MODEL, + status: 'finished', + pendingQuestion, + }); + expect(events).toEqual([ + { type: 'question.requested', sessionId: 'session-1', turnId: 'turn-1', ...pendingQuestion }, + { type: 'turn.finished', sessionId: 'session-1', turnId: 'turn-1', status: 'finished' }, + ]); + expect(fake.options.questionnaires.getPending).toHaveBeenCalledWith({ + agentName: 'custom-x', + sessionId: 'session-1', + }); + }); + + it('rejects as unknown after delivering the terminal when the question lookup fails', async () => { + const fake = fakes(); + fake.sessions.set('session-1', { agentName: 'mavis' }); + fake.options.questionnaires.getPending.mockRejectedValue(new Error('db locked')); + const ports = createMiniAppAgentSessionPorts(fake.options); + const events: MiniAppAgentRunEvent[] = []; + + const run = ports.runTurn({ + sessionId: 'session-1', + text: 'hello', + onEvent: (event) => events.push(event), + }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + fake.push(frame('turn-terminal', { status: 'completed' })); + + // The turn ended, but "nothing to answer" is not confirmed. + await expect(run).rejects.toMatchObject({ code: 'AGENT_OUTCOME_UNKNOWN', retryable: false }); + expect(events).toEqual([ + { type: 'turn.finished', sessionId: 'session-1', turnId: 'turn-1', status: 'finished' }, + ]); + expect(fake.options.logger.warn).toHaveBeenCalledWith( + { + stage: 'pending-question-lookup', + sessionId: 'session-1', + turnId: 'turn-1', + errorName: 'Error', + error: 'db locked', + }, + 'Mini App Agent pending questionnaire lookup failed', + ); + expect(fake.options.turns.abort).not.toHaveBeenCalled(); + }); + + it('does not report a questionnaire left pending by an earlier turn', async () => { + const fake = fakes(); + fake.sessions.set('session-1', { agentName: 'mavis' }); + fake.options.questionnaires.getPending.mockResolvedValue({ ...QUESTION, createdAt: 41 }); + const ports = createMiniAppAgentSessionPorts(fake.options); + const events: MiniAppAgentRunEvent[] = []; + + const run = ports.runTurn({ + sessionId: 'session-1', + text: 'hello', + onEvent: (event) => events.push(event), + }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + fake.push(frame('turn-terminal', { status: 'completed' })); + + await expect(run).resolves.toEqual({ + turnId: 'turn-1', + model: DEFAULT_MODEL, + status: 'finished', + }); + expect(events.map((event) => event.type)).toEqual(['turn.finished']); + }); +}); + +describe('createMiniAppAgentSessionPorts answers', () => { + it('answers the pending question and follows the continuation turn it starts', async () => { + const fake = fakes(); + fake.sessions.set('session-1', { agentName: 'mavis' }); + fake.options.questionnaires.getPending.mockResolvedValueOnce(QUESTION); + fake.options.questionnaires.reply.mockImplementation(async () => { + fake.push(frame('session-status', {}, null)); + fake.push(frame('runtime-event', textChunk('ok', 'turn-2'), 'turn-2')); + return { ok: true, injected: true }; + }); + fake.messages.push({ role: 'assistant', msg_content: 'continued' }); + const ports = createMiniAppAgentSessionPorts(fake.options); + const events: MiniAppAgentRunEvent[] = []; + const answers = [{ stepId: 's1', selectedOptionIds: ['a'], selectedOther: false }]; + + const run = ports.answerQuestion({ + sessionId: 'session-1', + requestId: 'question-1', + answers, + onEvent: (event) => events.push(event), + }); + await vi.waitFor(() => expect(fake.options.questionnaires.reply).toHaveBeenCalled()); + fake.push(frame('turn-terminal', { status: 'completed' }, 'turn-2')); + + await expect(run).resolves.toEqual({ + turnId: 'turn-2', + model: DEFAULT_MODEL, + status: 'finished', + text: 'continued', + }); + expect(fake.options.questionnaires.reply).toHaveBeenCalledWith({ + agentName: 'mavis', + requestId: 'question-1', + answers, + submittedAt: 42, + }); + // The stream was subscribed before replying, so no continuation frame is missed. + expect(fake.options.stream.resume.mock.invocationCallOrder[0]).toBeLessThan( + fake.options.questionnaires.reply.mock.invocationCallOrder[0] ?? 0, + ); + expect(events).toEqual([ + { type: 'text.delta', sessionId: 'session-1', turnId: 'turn-2', text: 'ok' }, + { + type: 'turn.finished', + sessionId: 'session-1', + turnId: 'turn-2', + status: 'finished', + text: 'continued', + }, + ]); + expect(fake.options.messages.listTurn).toHaveBeenCalledWith('session-1', 'turn-2'); + }); + + it.each([ + ['no pending question', undefined], + ['another pending question', { ...QUESTION, id: 'question-2' }], + ])('rejects an answer with REQUEST_NOT_FOUND when there is %s', async (_label, pending) => { + const fake = fakes(); + fake.sessions.set('session-1', { agentName: 'mavis' }); + fake.options.questionnaires.getPending.mockResolvedValue(pending); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect( + ports.answerQuestion({ sessionId: 'session-1', requestId: 'question-1', answers: [] }), + ).rejects.toMatchObject({ code: 'REQUEST_NOT_FOUND' }); + expect(fake.options.questionnaires.reply).not.toHaveBeenCalled(); + }); + + it('aborts the continuation turn when cancelled before it was seen', async () => { + const fake = fakes(); + fake.sessions.set('session-1', { agentName: 'mavis' }); + fake.options.questionnaires.getPending.mockResolvedValue(QUESTION); + const controller = new AbortController(); + const events: MiniAppAgentRunEvent[] = []; + const ports = createMiniAppAgentSessionPorts(fake.options); + + const run = ports.answerQuestion({ + sessionId: 'session-1', + requestId: 'question-1', + answers: [], + signal: controller.signal, + onEvent: (event) => events.push(event), + }); + await vi.waitFor(() => expect(fake.options.questionnaires.reply).toHaveBeenCalled()); + controller.abort(); + fake.push(frame('runtime-event', textChunk('late', 'turn-2'), 'turn-2')); + await vi.waitFor(() => + expect(fake.options.turns.abort).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1', turnId: 'turn-2' }), + ), + ); + fake.push(frame('turn-terminal', { status: 'aborted' }, 'turn-2')); + + await expect(run).resolves.toEqual({ + turnId: 'turn-2', + model: DEFAULT_MODEL, + status: 'aborted', + }); + expect(events).toEqual([ + { type: 'turn.finished', sessionId: 'session-1', turnId: 'turn-2', status: 'aborted' }, + ]); + }); + + it('settles finished without a turn when the answer starts no continuation', async () => { + const fake = fakes(); + fake.sessions.set('session-1', { agentName: 'mavis' }); + fake.options.questionnaires.getPending.mockResolvedValue(QUESTION); + fake.options.questionnaires.reply.mockResolvedValue({ ok: true, injected: false }); + const onEvent = vi.fn(); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect( + ports.answerQuestion({ + sessionId: 'session-1', + requestId: 'question-1', + answers: [], + onEvent, + }), + ).resolves.toEqual({ status: 'finished' }); + expect(onEvent).not.toHaveBeenCalled(); + expect(fake.options.messages.listTurn).not.toHaveBeenCalled(); + expect(fake.options.turns.abort).not.toHaveBeenCalled(); + expect(fake.closed()).toBe(true); + }); + + it('rejects as retryable unavailable when the pending lookup fails before answering', async () => { + const fake = fakes(); + fake.sessions.set('session-1', { agentName: 'mavis' }); + fake.options.questionnaires.getPending.mockRejectedValue(new Error('db locked')); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect( + ports.answerQuestion({ sessionId: 'session-1', requestId: 'question-1', answers: [] }), + ).rejects.toMatchObject({ code: 'AGENT_UNAVAILABLE', retryable: true }); + expect(fake.options.questionnaires.reply).not.toHaveBeenCalled(); + }); + + it.each([ + [400, 'INVALID_ARGUMENTS'], + [410, 'REQUEST_NOT_FOUND'], + ])('maps a %d questionnaire reply rejection to %s', async (status, code) => { + const fake = fakes(); + fake.sessions.set('session-1', { agentName: 'mavis' }); + fake.options.questionnaires.getPending.mockResolvedValue(QUESTION); + fake.options.questionnaires.reply.mockRejectedValue(Object.assign(new Error('x'), { status })); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect( + ports.answerQuestion({ sessionId: 'session-1', requestId: 'question-1', answers: [] }), + ).rejects.toMatchObject({ code }); + expect(fake.options.turns.abort).not.toHaveBeenCalled(); + }); +}); + +describe('createMiniAppAgentSessionPorts unconfirmed outcomes', () => { + /** Runs `body` under fake timers; the run's assertion is attached before time moves. */ + async function withFakeTimers(body: () => Promise): Promise { + vi.useFakeTimers(); + try { + await body(); + } finally { + vi.useRealTimers(); + } + } + + function answering(fake: ReturnType, signal?: AbortSignal) { + fake.sessions.set('session-1', { agentName: 'mavis' }); + fake.options.questionnaires.getPending.mockResolvedValue(QUESTION); + const onEvent = vi.fn(); + const run = createMiniAppAgentSessionPorts(fake.options).answerQuestion({ + sessionId: 'session-1', + requestId: 'question-1', + answers: [], + ...(signal ? { signal } : {}), + onEvent, + }); + return { run, onEvent }; + } + + it('rejects as unknown when the answer was recorded but no continuation turn was seen', () => + withFakeTimers(async () => { + const fake = fakes(); + const { run, onEvent } = answering(fake); + const settled = expect(run).rejects.toMatchObject({ + code: 'AGENT_OUTCOME_UNKNOWN', + retryable: false, + message: expect.stringContaining('follow-up turn was not observed'), + }); + await vi.waitFor(() => expect(fake.options.questionnaires.reply).toHaveBeenCalled()); + await vi.advanceTimersByTimeAsync(15_000); + + await settled; + expect(onEvent).not.toHaveBeenCalled(); + expect(fake.options.logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ stage: 'answer-continuation', requestId: 'question-1' }), + 'Mini App Agent answer continuation turn was not observed', + ); + })); + + it('rejects as unknown when an answer is cancelled and its continuation never appears', () => + withFakeTimers(async () => { + const fake = fakes(); + const controller = new AbortController(); + const { run, onEvent } = answering(fake, controller.signal); + const settled = expect(run).rejects.toMatchObject({ code: 'AGENT_OUTCOME_UNKNOWN' }); + await vi.waitFor(() => expect(fake.options.questionnaires.reply).toHaveBeenCalled()); + controller.abort(); + await vi.advanceTimersByTimeAsync(15_000); + + await settled; + expect(onEvent).not.toHaveBeenCalled(); + expect(fake.options.turns.abort).not.toHaveBeenCalled(); + })); + + it.each([ + ['the abort timed out and no terminal arrived', 'abort-timeout', undefined, undefined], + ['the turn had stopped running and ended aborted', 'not-running', 'aborted', 'aborted'], + ['the turn had stopped running and ended completed', 'not-running', 'completed', undefined], + ] as const)('a cancelled run where %s', (_label, abortStatus, terminalStatus, expected) => + withFakeTimers(async () => { + const fake = fakes(); + fake.options.turns.abort.mockResolvedValue( + abortStatus === 'not-running' + ? { status: abortStatus } + : { status: abortStatus, turnId: 'turn-1' }, + ); + const controller = new AbortController(); + const events: MiniAppAgentRunEvent[] = []; + const run = createMiniAppAgentSessionPorts(fake.options).runTurn({ + sessionId: 'session-1', + text: 'hello', + signal: controller.signal, + onEvent: (event) => events.push(event), + }); + const settled = expected + ? expect(run).resolves.toEqual({ turnId: 'turn-1', model: DEFAULT_MODEL, status: expected }) + : expect(run).rejects.toMatchObject({ code: 'AGENT_OUTCOME_UNKNOWN' }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + controller.abort(); + await vi.waitFor(() => expect(fake.options.turns.abort).toHaveBeenCalled()); + if (terminalStatus) fake.push(frame('turn-terminal', { status: terminalStatus })); + await vi.advanceTimersByTimeAsync(15_000); + + await settled; + // An unconfirmed cancel reports no terminal and does not abort again. + expect(events.map((event) => event.type)).toEqual(expected ? ['turn.finished'] : []); + expect(fake.options.turns.abort).toHaveBeenCalledTimes(1); + }), + ); + + it('rejects a terminal without a known status as unknown, not as a failed turn', async () => { + const fake = fakes(); + const onEvent = vi.fn(); + const run = createMiniAppAgentSessionPorts(fake.options).runTurn({ + sessionId: 'session-1', + text: 'hello', + onEvent, + }); + await vi.waitFor(() => expect(fake.options.turns.steer).toHaveBeenCalled()); + fake.push(frame('turn-terminal', { status: 'exploded' })); + + await expect(run).rejects.toMatchObject({ code: 'AGENT_OUTCOME_UNKNOWN' }); + expect(onEvent).not.toHaveBeenCalled(); + expect(fake.options.turns.abort).not.toHaveBeenCalled(); + expect(fake.options.logger.warn).toHaveBeenCalledWith( + { + stage: 'turn-terminal', + sessionId: 'session-1', + turnId: 'turn-1', + terminalStatus: 'exploded', + }, + 'Mini App Agent turn terminal has no known status', + ); + }); +}); + +describe('createMiniAppAgentSessionPorts permissions', () => { + it('decides only a pending request raised under the same Mini App root', async () => { + const fake = fakes(); + fake.sessions.set('session-1', { purpose: 'miniapp:demo' }); + fake.sessions.set('session-2', { purpose: 'miniapp:demo' }); + fake.sessions.set('child', { parentSessionId: 'session-1' }); + fake.options.permissions.listPending.mockResolvedValue([ + { requestId: 'perm-1', sessionId: 'session-1' }, + { requestId: 'perm-2', sessionId: 'chat' }, + { requestId: 'perm-3', sessionId: 'session-2' }, + { requestId: 'perm-c', sessionId: 'child' }, + ]); + fake.options.permissions.reply.mockImplementation(async ({ requestIds }) => ({ + processed: [...requestIds], + })); + const ports = createMiniAppAgentSessionPorts(fake.options); + const input = { sessionId: 'session-1', decision: 'allowOnce' } as const; + + await expect(ports.replyPermission({ ...input, requestId: 'perm-1' })).resolves.toBe( + 'processed', + ); + await expect(ports.replyPermission({ ...input, requestId: 'perm-2' })).resolves.toBe('skipped'); + // Another session of the same plugin is a different root. + await expect(ports.replyPermission({ ...input, requestId: 'perm-3' })).resolves.toBe('skipped'); + // A delegated child's request belongs to its root, whichever of the two the author names. + await expect( + ports.replyPermission({ ...input, sessionId: 'child', requestId: 'perm-c' }), + ).resolves.toBe('processed'); + await expect(ports.replyPermission({ ...input, requestId: 'perm-c' })).resolves.toBe( + 'processed', + ); + await expect( + ports.replyPermission({ ...input, sessionId: 'chat', requestId: 'perm-2' }), + ).resolves.toBe('skipped'); + expect(fake.options.permissions.reply.mock.calls.map(([call]) => call.requestIds)).toEqual([ + ['perm-1'], + ['perm-c'], + ['perm-c'], + ]); + + fake.options.permissions.reply.mockResolvedValue({ processed: [] }); + await expect(ports.replyPermission({ ...input, requestId: 'perm-1' })).resolves.toBe('skipped'); + }); + + it('reports lookup failures before the decision as retryable and passes reply failures through', async () => { + const fake = fakes(); + fake.sessions.set('session-1', { purpose: 'miniapp:demo' }); + fake.options.permissions.listPending.mockResolvedValue([ + { requestId: 'perm-1', sessionId: 'session-1' }, + ]); + const ports = createMiniAppAgentSessionPorts(fake.options); + const input = { sessionId: 'session-1', requestId: 'perm-1', decision: 'deny' } as const; + const unavailable = { code: 'AGENT_UNAVAILABLE', retryable: true }; + + fake.options.repository.get.mockRejectedValueOnce(new Error('db locked')); + await expect(ports.replyPermission(input)).rejects.toMatchObject(unavailable); + fake.options.permissions.listPending.mockRejectedValueOnce(new Error('ipc closed')); + await expect(ports.replyPermission(input)).rejects.toMatchObject(unavailable); + expect(fake.options.permissions.reply).not.toHaveBeenCalled(); + expect(fake.options.logger.warn).toHaveBeenLastCalledWith( + { + stage: 'reply-lookup', + sessionId: 'session-1', + requestId: 'perm-1', + errorName: 'Error', + error: 'ipc closed', + }, + 'Mini App Agent lookup failed before dispatch', + ); + + // The decision may already apply once `permissions.reply` was called. + const sent = new Error('reply lost'); + fake.options.permissions.reply.mockRejectedValueOnce(sent); + await expect(ports.replyPermission(input)).rejects.toBe(sent); + }); + + it('projects permission asks from the global event bus and stops on unsubscribe', () => { + const fake = fakes(); + const ports = createMiniAppAgentSessionPorts(fake.options); + const listener = vi.fn(); + + const unsubscribe = ports.subscribePermissionAsks(listener); + fake.publish({ + type: 'permission.ask', + timestamp: 1, + source: 'local-runtime', + payload: { + requestId: 'perm-1', + sessionId: 'session-1', + turnId: 'turn-1', + agentName: 'mavis', + toolName: 'bash', + ruleContents: ['Bash(ls)'], + toolInput: 'ls', + toolDescription: 'list', + reason: 'Run a shell command', + allowAlwaysSupported: true, + createdAt: 1, + }, + }); + fake.publish({ + type: 'permission.resolved', + timestamp: 1, + source: 'local-runtime', + payload: {}, + } as unknown as GlobalEvent); + unsubscribe(); + fake.publish({ + type: 'permission.ask', + timestamp: 2, + source: 'local-runtime', + payload: { + requestId: 'perm-2', + sessionId: 'session-1', + agentName: 'mavis', + toolName: 'bash', + ruleContents: [], + reason: 'r', + allowAlwaysSupported: false, + createdAt: 2, + }, + }); + + expect(listener.mock.calls).toEqual([ + [ + { + sessionId: 'session-1', + turnId: 'turn-1', + requestId: 'perm-1', + toolName: 'bash', + toolInput: 'ls', + reason: 'Run a shell command', + }, + ], + ]); + }); +}); + +/** Stream responses reach the frame as the JSON `RespData` string the agent projection writes. */ +function textChunk(text: string, turnId = 'turn-1'): string { + return JSON.stringify({ + type: RespDataType.AgentMessageChunk, + agent_message_chunk: { msg_id: 'm1', role: Role.Assistant, turn_id: turnId, msg_content: text }, + }); +} + +function frame( + kind: SessionFrame['kind'], + data: unknown, + turnId: string | null = 'turn-1', +): SessionFrame { + return { + identity: `${kind}-id`, + sessionId: 'session-1', + ...(turnId ? { turnId } : {}), + kind, + data, + cursor: `cursor:${kind}:${turnId ?? 'session'}`, + createdAtMs: 5, + }; +} + +function fakes() { + const sessions = new Map< + string, + { + agentName?: string; + workspaceDir?: string; + isDefaultWorkspace?: boolean; + parentSessionId?: string; + purpose?: string; + effectiveModel?: string | null; + effectiveModelVariant?: string | null; + effectiveModelThinking?: { effort?: string } | null; + } + >(); + const messages: Record[] = []; + const globalSubscribers = new Set<{ next(event: GlobalEvent): void }>(); + const queue: SessionFrame[] = []; + const waiters: Array<(result: IteratorResult) => void> = []; + let closed = false; + // Mirrors SessionResumeIterator: each resume() is a fresh iterator over the session's + // frames, and any turn-terminal closes it. + const iterators: { ended: boolean }[] = []; + const makeIterator = (): AsyncIterableIterator => { + const state = { ended: false }; + iterators.push(state); + const iterator: AsyncIterableIterator = { + [Symbol.asyncIterator]() { + return iterator; + }, + async next() { + if (state.ended) return { done: true, value: undefined }; + const value = + queue.shift() ?? + (closed + ? undefined + : await new Promise((resolve) => + waiters.push((result) => resolve(result.done ? undefined : result.value)), + )); + if (!value) return { done: true, value: undefined }; + if (value.kind === 'turn-terminal') state.ended = true; + return { done: false, value }; + }, + return() { + state.ended = true; + return Promise.resolve({ done: true, value: undefined }); + }, + }; + return iterator; + }; + const options = { + records: { + createInternalSession: vi.fn(async () => ({ sessionId: 'session-1' })), + }, + lifecycle: { + mutateSession: vi.fn(async (_sessionId: string, _fields: object) => ({})), + }, + repository: { get: vi.fn(async (sessionId: string) => sessions.get(sessionId)) }, + turns: { + steer: vi.fn(async () => ({ + delivered: true, + mode: 'activated', + turnId: 'turn-1', + completion: new Promise(() => undefined), + })), + abort: vi.fn(async () => ({ + status: 'aborted', + turnId: 'turn-1', + })), + }, + stream: { resume: vi.fn(() => makeIterator()) }, + messages: { listTurn: vi.fn(async () => messages) }, + questionnaires: { + getPending: vi.fn( + async () => undefined, + ), + reply: vi.fn(async () => ({ + ok: true, + })), + }, + permissions: { + listPending: vi.fn( + async () => [], + ), + reply: vi.fn(async () => ({ + processed: [], + })), + }, + eventBus: { + subscribe: vi.fn((subscriber: { next(event: GlobalEvent): void }) => { + globalSubscribers.add(subscriber); + return () => globalSubscribers.delete(subscriber); + }), + }, + logger: { warn: vi.fn() }, + modelConfig: vi.fn(() => RUNTIME_CONFIG), + models: { list: vi.fn(async () => []) }, + agents: { list: vi.fn(async () => []) }, + nowMs: () => 42, + }; + return { + publish(event: GlobalEvent) { + for (const subscriber of globalSubscribers) subscriber.next(event); + }, + options: options as typeof options & MiniAppAgentSessionPortsOptions, + sessions, + messages, + closed: () => iterators.length > 0 && iterators.every((state) => state.ended), + end() { + closed = true; + for (const resolve of waiters.splice(0)) resolve({ done: true, value: undefined }); + }, + push(value: SessionFrame) { + const resolve = waiters.shift(); + if (resolve) resolve({ done: false, value }); + else queue.push(value); + }, + }; +} + +describe('createMiniAppAgentSessionPorts catalogs', () => { + it('lists only enabled models, projecting variants and thinking efforts', async () => { + const fake = fakes(); + fake.options.models.list.mockResolvedValue([ + modelEntry({ + modelId: 'M3', + displayName: 'MiniMax M3', + effortOptions: ['low', 'high'], + defaultEffort: 'high', + supportedVariants: ['fast'], + }), + modelEntry({ modelId: 'plain', displayName: 'Plain', effortOptions: [] }), + modelEntry({ modelId: 'off', displayName: 'Off', enabled: false }), + ]); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect(ports.listModels()).resolves.toEqual([ + { + providerId: 'minimax', + modelId: 'M3', + displayName: 'MiniMax M3', + variants: ['fast'], + thinking: { efforts: ['low', 'high'], default: 'high' }, + }, + { providerId: 'minimax', modelId: 'plain', displayName: 'Plain' }, + ]); + }); + + it('lists agents by canonical name, without aliases or duplicates, marking mavis primary', async () => { + const fake = fakes(); + fake.options.agents.list.mockResolvedValue([ + { canonicalViewName: 'mavis', displayName: 'Mavis' }, + { canonicalViewName: 'writer', displayName: '' }, + { canonicalViewName: 'writer', displayName: 'Writer' }, + ]); + const ports = createMiniAppAgentSessionPorts(fake.options); + + await expect(ports.listAgents()).resolves.toEqual([ + { name: 'mavis', displayName: 'Mavis', primary: true }, + { name: 'writer', displayName: 'writer', primary: false }, + ]); + expect(fake.options.agents.list).toHaveBeenCalledWith({ includeAliases: false }); + }); +}); + +function modelEntry( + fields: Partial & + Pick, +): ModelProviderModelEntry { + return { + providerId: 'minimax', + modelConfigId: `minimax/${fields.modelId}`, + enabled: true, + selected: false, + providerSource: 'provider', + providerKind: 'minimax-managed', + providerName: 'MiniMax', + ...fields, + }; +} diff --git a/packages/local-runtime-v2/test/unit/miniapp/node-runtime-channel.test.ts b/packages/local-runtime-v2/test/unit/miniapp/node-runtime-channel.test.ts new file mode 100644 index 000000000..7b6dddaf7 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/node-runtime-channel.test.ts @@ -0,0 +1,312 @@ +import { EventEmitter } from 'node:events'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { ChildProcess } from 'node:child_process'; + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { + MINIAPP_LOOPBACK_HEALTH_PATH, + MINIAPP_LOOPBACK_TOKEN_HEADER, + type MiniAppHostConnectorSession, + type MiniAppHostConnectorSessionFactory, + type MiniAppNodeRuntime, + type ProcessMiniAppCandidate, +} from '../../../src/service/miniapp/contracts.js'; +import { createMiniAppNodeRuntimeAdapter } from '../../../src/service/miniapp/process/node-runtime/node-runtime.js'; +import type { MiniAppRunnerInitMessage } from '../../../src/service/miniapp/process/node-runtime/host-runner.js'; + +/** + * A scripted child. The parent/child channel is scripted so the test can observe exactly what the + * runtime sends and how it treats the child's own streams; nothing here spawns a node process. + */ +class ScriptedChild extends EventEmitter { + pid = 4242; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + connected = true; + readonly stdout = new EventEmitter(); + readonly stderr = new EventEmitter(); + readonly received: MiniAppRunnerInitMessage[] = []; + /** Reading this proves the runtime still treats stdin as transport. */ + stdinReads = 0; + + constructor(private readonly onExit: () => void) { + super(); + } + + get stdin(): EventEmitter { + this.stdinReads += 1; + return new EventEmitter(); + } + + /** + * The readiness probe is a real HTTP request, so the scripted child answers it for real: 401 without + * the minted credential and 204 on the health path with it. + */ + private async listen(init: MiniAppRunnerInitMessage): Promise { + const { createServer } = await import('node:http'); + const server = createServer((request, response) => { + const authorized = request.headers[MINIAPP_LOOPBACK_TOKEN_HEADER] === init.loopbackToken; + if (request.url?.startsWith(MINIAPP_LOOPBACK_HEALTH_PATH)) { + response.writeHead(authorized ? 204 : 401).end(); + return; + } + response.writeHead(authorized ? 200 : 401).end('plugin'); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(init.listen.port, init.listen.host, resolve); + }); + this.server = server; + } + + private server?: import('node:http').Server; + + send(message: unknown, callback?: (error: Error | null) => void): boolean { + const init = message as MiniAppRunnerInitMessage; + if (init?.type === 'miniapp:init') { + this.received.push(init); + void this.listen(init).then( + () => { + callback?.(null); + this.emit('message', { + type: 'miniapp:started', + pluginId: init.pluginId, + processGeneration: init.processGeneration, + readiness: init.readiness, + }); + }, + (error: Error) => callback?.(error), + ); + return true; + } + if (init?.type === 'miniapp:stop') { + this.server?.close(); + this.exit(0); + } + return true; + } + + exit(code: number): void { + if (this.exitCode !== null) return; + this.server?.close(); + this.exitCode = code; + this.connected = false; + this.onExit(); + this.emit('exit', code, null); + } + + kill(): boolean { + this.exit(0); + return true; + } +} + +let packageRoot = ''; +let sequence = 0; + +const candidate = ( + policy: ProcessMiniAppCandidate['hostConnectorPolicy'], + pluginId: string, + agentPolicy?: ProcessMiniAppCandidate['agentPolicy'], +): ProcessMiniAppCandidate => ({ + pluginId, + packageRoot, + packageDigest: 'package-digest', + clientDigest: 'client-digest', + nodeDigest: 'node-digest', + surfacePath: '/dashboard', + lifecycle: 'on-demand', + nodeEntry: 'miniapp/node/server.js', + mcpEndpoints: [], + hostConnectorPolicy: policy, + ...(agentPolicy ? { agentPolicy } : {}), +}); + +const allowlist = { kind: 'allowlist', providers: ['weather'] } as const; +const denyAll = { kind: 'deny-all' } as const; + +interface Harness { + readonly pluginId: string; + readonly runtime: MiniAppNodeRuntime; + readonly child: ScriptedChild; + readonly onLog: ReturnType; + readonly create: ReturnType; + readonly spawn: ReturnType; +} + +async function prepare(input: { + readonly policy: ProcessMiniAppCandidate['hostConnectorPolicy']; + readonly agentPolicy?: ProcessMiniAppCandidate['agentPolicy']; + readonly withFactory?: boolean; + readonly childConnected?: boolean; +}): Promise { + // The listen port is derived from the plugin id, so each case gets its own plugin and port. + const pluginId = `fixture.miniapp-${(sequence += 1)}`; + const dataDir = await mkdtemp(join(tmpdir(), 'miniapp data ')); + const child = new ScriptedChild(() => undefined); + child.connected = input.childConnected ?? true; + const onLog = vi.fn(); + const create = vi.fn( + (): MiniAppHostConnectorSession => ({ + startHandshake: vi.fn(), + activate: vi.fn(), + beginQuiesce: vi.fn(), + retire: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + }), + ); + const factory: MiniAppHostConnectorSessionFactory = { create }; + const spawn = vi.fn(() => child as unknown as ChildProcess); + const adapter = createMiniAppNodeRuntimeAdapter({ + resolvePluginDataDir: () => dataDir, + verifyCandidate: async () => true, + materializePackage: async ({ targetRoot }) => { + const target = join(targetRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'export const start = async () => undefined;\n'); + }, + isPortReserved: () => false, + makeLoopbackToken: () => 'credential-1', + connectPort: async () => undefined, + spawn, + createTerminator: () => ({ + stop: async () => { + child.exit(0); + return { proven: true }; + }, + }) as never, + ...(input.withFactory === false ? {} : { hostConnectorSessionFactory: factory }), + }); + const runtime = await adapter.prepare({ + candidate: candidate(input.policy, pluginId, input.agentPolicy), + processGeneration: 'generation-1', + signal: new AbortController().signal, + onLog, + }); + return { pluginId, runtime, child, onLog, create, spawn }; +} + +beforeAll(async () => { + packageRoot = await mkdtemp(join(tmpdir(), 'miniapp runtime ')); + const entry = join(packageRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(entry), { recursive: true }); + await writeFile(entry, 'export const start = async () => undefined;\n'); +}); + +afterAll(async () => { + await rm(packageRoot, { recursive: true, force: true }); +}); + +describe('Mini App Node runtime connector channel', () => { + it('ignores the child stdin so no protocol traffic can travel on it', async () => { + const harness = await prepare({ policy: allowlist }); + try { + expect(harness.spawn).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }), + ); + expect(harness.child.stdinReads).toBe(0); + } finally { + await harness.runtime.stop(); + } + }); + + it('binds the connector session to the child IPC channel instead of its streams', async () => { + const harness = await prepare({ policy: allowlist }); + try { + expect(harness.create).toHaveBeenCalledTimes(1); + const input = harness.create.mock.calls[0]?.[0] as Record; + expect(input.pluginId).toBe(harness.pluginId); + expect(input.processGeneration).toBe('generation-1'); + expect(input.providers).toEqual(['weather']); + expect(input.transport).toBe(harness.child); + expect(input).not.toHaveProperty('request'); + expect(input).not.toHaveProperty('response'); + expect(harness.child.stdinReads).toBe(0); + } finally { + await harness.runtime.stop(); + } + }); + + it('refuses a connector allowlist when the child has no IPC channel', async () => { + await expect(prepare({ policy: allowlist, childConnected: false })).rejects.toMatchObject({ + code: 'HOST_CONNECTOR_UNAVAILABLE', + }); + }); + + it('does not open the channel for a deny-all connector policy', async () => { + const harness = await prepare({ policy: denyAll }); + try { + expect(harness.create).not.toHaveBeenCalled(); + } finally { + await harness.runtime.stop(); + } + }); +}); + +describe('Mini App Node runtime Agent channel', () => { + it('opens the shared channel for an agent-only allowlist and forwards the declared policy', async () => { + const agentPolicy = { + kind: 'allowlist', + capabilities: ['session.run'], + maxConcurrent: 7, + } as const; + const harness = await prepare({ policy: denyAll, agentPolicy }); + try { + expect(harness.create).toHaveBeenCalledTimes(1); + const input = harness.create.mock.calls[0]?.[0] as Record; + expect(input.providers).toEqual([]); + expect(input.agentPolicy).toEqual(agentPolicy); + expect(input.transport).toBe(harness.child); + expect(harness.child.received[0]?.agentPolicy).toEqual(agentPolicy); + } finally { + await harness.runtime.stop(); + } + }); + + it('denies Agent access in the init message when the candidate declares no policy', async () => { + const harness = await prepare({ policy: denyAll }); + try { + expect(harness.create).not.toHaveBeenCalled(); + expect(harness.child.received[0]?.agentPolicy).toEqual({ kind: 'deny-all' }); + } finally { + await harness.runtime.stop(); + } + }); +}); + +describe('Mini App child stream logging', () => { + it('treats stdout and stderr as bounded user logs without aborting the runtime', async () => { + const harness = await prepare({ policy: denyAll }); + try { + harness.child.stdout.emit('data', Buffer.from('plugin.stdout\n')); + harness.child.stderr.emit('data', Buffer.from('plugin.stderr\n')); + expect(harness.onLog).toHaveBeenCalledWith( + expect.objectContaining({ level: 'info', message: 'plugin.stdout', byteLength: 14 }), + ); + expect(harness.onLog).toHaveBeenCalledWith( + expect.objectContaining({ level: 'error', message: 'plugin.stderr', byteLength: 14 }), + ); + // A logging plugin keeps running: the same runtime still stops normally. + await expect(harness.runtime.stop()).resolves.toEqual({ proven: true }); + } finally { + await harness.runtime.stop(); + } + }); + + it('bounds how many stream log bytes one runtime window accepts', async () => { + const harness = await prepare({ policy: denyAll }); + try { + for (let index = 0; index < 32; index += 1) { + harness.child.stdout.emit('data', Buffer.alloc(8 * 1024, 97)); + } + expect(harness.onLog.mock.calls.length).toBeLessThanOrEqual(17); + } finally { + await harness.runtime.stop(); + } + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/node-runtime-credential.test.ts b/packages/local-runtime-v2/test/unit/miniapp/node-runtime-credential.test.ts new file mode 100644 index 000000000..e37e5a45a --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/node-runtime-credential.test.ts @@ -0,0 +1,513 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { EventEmitter } from 'node:events'; +import type { ChildProcess } from 'node:child_process'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { MiniAppNodeRuntime, ProcessMiniAppCandidate } from '../../../src/service/miniapp/contracts.js'; +import { + createMiniAppNodeRuntimeAdapter, + deriveMiniAppNodePort, +} from '../../../src/service/miniapp/process/node-runtime/node-runtime.js'; +import { + startMiniAppModule, + type MiniAppRunnerInitMessage, + type RunningMiniAppModule, +} from '../../../src/service/miniapp/process/node-runtime/host-runner.js'; + +/** + * A scripted child: it is the real runner running in this process, so the readiness probe meets the + * real guard. Everything else about the child process is scripted, which lets the test observe the + * credential the runtime mints and hands down without spawning a node process per case. + */ +class ScriptedChild extends EventEmitter { + pid = 4242; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + connected = true; + readonly stdout = new EventEmitter(); + readonly stderr = new EventEmitter(); + readonly stdin = new EventEmitter() as unknown as ChildProcess['stdin']; + + readonly received: MiniAppRunnerInitMessage[] = []; + private runner: RunningMiniAppModule | undefined; + + constructor( + private readonly onExit: () => void, + /** A child that accepts the init message and never reports readiness. */ + private readonly silent = false, + private readonly beforeStart?: (init: MiniAppRunnerInitMessage) => Promise, + ) { + super(); + } + + async send(message: unknown, callback?: (error: Error | null) => void): Promise { + const init = message as MiniAppRunnerInitMessage; + if (init?.type === 'miniapp:init') { + this.received.push(init); + if (this.silent) return true; + try { + await this.beforeStart?.(init); + this.runner = await startMiniAppModule(init, { + loadModule: async () => ({ + start: async (context: { readonly listen: { host: string; port: number } }) => { + const { createServer } = await import('node:http'); + const server = createServer((_request, response) => { + response.writeHead(200).end('plugin'); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(context.listen.port, context.listen.host, resolve); + }); + return { + async dispose() { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; + }, + }), + }); + } catch (error) { + callback?.(error as Error); + const code = + typeof error === 'object' && error !== null && 'code' in error && error.code === 'EADDRINUSE' + ? 'EADDRINUSE' + : 'RUNNER_START_FAILED'; + this.emit('message', { type: 'miniapp:error', code, errorText: String(error) }); + return false; + } + callback?.(null); + this.emit('message', { + type: 'miniapp:started', + pluginId: init.pluginId, + processGeneration: init.processGeneration, + readiness: init.readiness, + }); + return true; + } + if (init?.type === 'miniapp:stop') { + await this.runner?.stop(); + this.exit(0); + } + return true; + } + + exit(code: number): void { + if (this.exitCode !== null) return; + this.exitCode = code; + this.connected = false; + this.onExit(); + this.emit('exit', code, null); + } + + kill(): boolean { + this.exit(0); + return true; + } +} + +let packageRoot = ''; + +const candidate = (pluginId = 'fixture.miniapp'): ProcessMiniAppCandidate => ({ + pluginId, + packageRoot, + packageDigest: 'package-digest', + clientDigest: 'client-digest', + nodeDigest: 'node-digest', + surfacePath: '/dashboard', + lifecycle: 'on-demand', + nodeEntry: 'miniapp/node/server.js', + mcpEndpoints: [], + hostConnectorPolicy: { kind: 'deny-all' }, +}); + +beforeAll(async () => { + packageRoot = await mkdtemp(join(tmpdir(), 'miniapp runtime ')); + const entry = join(packageRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(entry), { recursive: true }); + await writeFile(entry, 'export const start = async () => undefined;\n'); +}); + +afterAll(async () => { + await rm(packageRoot, { recursive: true, force: true }); +}); + +describe('Mini App loopback credential', () => { + it('mints one credential per preparation and hands that exact value down', async () => { + let minted = 0; + const child = new ScriptedChild(() => undefined); + const dataDir = await mkdtemp(join(tmpdir(), 'miniapp data ')); + const adapter = createMiniAppNodeRuntimeAdapter({ + resolvePluginDataDir: () => dataDir, + verifyCandidate: async () => true, + materializePackage: async ({ targetRoot }) => { + const target = join(targetRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'export const start = async () => undefined;\n'); + }, + isPortReserved: () => false, + makeLoopbackToken: () => { + minted += 1; + return `credential-${minted}`; + }, + spawn: () => child as unknown as ChildProcess, + createTerminator: () => ({ stop: async () => ({ proven: true }) }) as never, + }); + + let runtime: MiniAppNodeRuntime | undefined; + try { + runtime = await adapter.prepare({ + candidate: candidate(), + processGeneration: 'generation-1', + signal: new AbortController().signal, + onLog: () => undefined, + }); + + expect(minted).toBe(1); + expect(child.received).toHaveLength(1); + expect(child.received[0]?.loopbackToken).toBe('credential-1'); + // The handle carries the same value the guard was installed with, and readiness passed only + // because the guard answered 204 for it. + expect(runtime.loopbackToken).toBe('credential-1'); + } finally { + await runtime?.stop(); + await rm(dataDir, { recursive: true, force: true }); + } + }); +}); + +describe('Mini App process generations', () => { + it('gives a new process generation a different credential', async () => { + let minted = 0; + const tokens: string[] = []; + const token = () => { + minted += 1; + const value = `credential-${minted}`; + tokens.push(value); + return value; + }; + const dataDir = await mkdtemp(join(tmpdir(), 'miniapp data ')); + const build = (child: ScriptedChild) => + createMiniAppNodeRuntimeAdapter({ + resolvePluginDataDir: () => dataDir, + verifyCandidate: async () => true, + materializePackage: async ({ targetRoot }) => { + const target = join(targetRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'export const start = async () => undefined;\n'); + }, + isPortReserved: () => false, + makeLoopbackToken: token, + spawn: () => child as unknown as ChildProcess, + createTerminator: () => ({ stop: async () => ({ proven: true }) }) as never, + // Fails fast: this case only needs the init message a new generation would receive. + runnerStartTimeoutMs: 40, + nowMs: Date.now, + }); + + try { + const first = new ScriptedChild(() => undefined); + const firstRuntime = await build(first).prepare({ + candidate: { ...candidate(), pluginId: 'fixture.first' }, + processGeneration: 'generation-1', + signal: new AbortController().signal, + onLog: () => undefined, + }); + expect(first.received[0]?.loopbackToken).toBe(tokens[0]); + await firstRuntime.stop(); + + const second = new ScriptedChild(() => undefined, true); + await expect( + build(second).prepare({ + candidate: { ...candidate(), pluginId: 'fixture.first' }, + processGeneration: 'generation-2', + signal: new AbortController().signal, + onLog: () => undefined, + }), + ).rejects.toBeDefined(); + + expect(second.received).toHaveLength(1); + expect(second.received[0]?.loopbackToken).toBe(tokens[1]); + expect(new Set(tokens).size).toBe(2); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); +}); + +describe('Mini App ownership before spawn', () => { + it('re-probes after a bind race and does not retry on another port', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'miniapp external race ')); + let guardedServer: Server | undefined; + const child = new ScriptedChild(() => undefined, false, async (init) => { + guardedServer = createServer((_request, response) => { + response.writeHead(401).end(); + }); + await new Promise((resolve, reject) => { + guardedServer?.once('error', reject); + guardedServer?.listen(init.listen.port, '127.0.0.1', resolve); + }); + }); + const adapter = createMiniAppNodeRuntimeAdapter({ + resolvePluginDataDir: () => dataDir, + verifyCandidate: async () => true, + materializePackage: async ({ targetRoot }) => { + const target = join(targetRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'export const start = async () => undefined;\n'); + }, + isPortReserved: () => false, + maxPortAttempts: 1, + spawn: () => child as unknown as ChildProcess, + createTerminator: () => ({ stop: async () => ({ proven: true }) }) as never, + }); + + try { + await expect( + adapter.prepare({ + candidate: candidate(), + processGeneration: 'generation-bind-race', + preferredPort: deriveMiniAppNodePort(candidate().pluginId), + signal: new AbortController().signal, + onLog: () => undefined, + }), + ).rejects.toMatchObject({ code: 'OWNED_ELSEWHERE' }); + expect(child.received).toHaveLength(1); + } finally { + guardedServer?.closeAllConnections(); + if (guardedServer) { + await new Promise((resolve) => guardedServer?.close(() => resolve())); + } + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it('refuses a guarded derived port owned by another client before retrying on a new port', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'miniapp external owner ')); + let guardedServer: Server | undefined; + let guardedPort: number | undefined; + let ownerPluginId: string | undefined; + for (let attempt = 0; attempt < 100 && guardedServer === undefined; attempt += 1) { + const candidateId = `fixture.external-owner-${attempt}`; + const port = deriveMiniAppNodePort(candidateId); + const server = createServer((_request, response) => { + response.writeHead(401).end(); + }); + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', resolve); + }); + guardedServer = server; + guardedPort = port; + ownerPluginId = candidateId; + } catch { + server.closeAllConnections(); + } + } + if (!guardedServer || guardedPort === undefined || !ownerPluginId) + throw new Error('Could not reserve a derived test port'); + + const child = new ScriptedChild(() => undefined); + const adapter = createMiniAppNodeRuntimeAdapter({ + resolvePluginDataDir: () => dataDir, + verifyCandidate: async () => true, + materializePackage: async ({ targetRoot }) => { + const target = join(targetRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'export const start = async () => undefined;\n'); + }, + isPortReserved: () => false, + spawn: () => child as unknown as ChildProcess, + createTerminator: () => ({ stop: async () => ({ proven: true }) }) as never, + }); + + try { + const preparation = adapter.prepare({ + candidate: candidate(ownerPluginId), + processGeneration: 'generation-external-owner', + signal: new AbortController().signal, + onLog: () => undefined, + }); + + await expect(preparation).rejects.toMatchObject({ code: 'OWNED_ELSEWHERE' }); + expect(child.received).toHaveLength(0); + } finally { + guardedServer.closeAllConnections(); + await new Promise((resolve) => guardedServer.close(() => resolve())); + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it('checks deterministic-port ownership even when the Host reservation skips that port', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'miniapp reserved owner ')); + let guardedServer: Server | undefined; + let guardedPort: number | undefined; + let ownerPluginId: string | undefined; + for (let attempt = 0; attempt < 100 && guardedServer === undefined; attempt += 1) { + const candidateId = `fixture.reserved-owner-${attempt}`; + const port = deriveMiniAppNodePort(candidateId); + const server = createServer((_request, response) => response.writeHead(401).end()); + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', resolve); + }); + guardedServer = server; + guardedPort = port; + ownerPluginId = candidateId; + } catch { + server.closeAllConnections(); + } + } + if (!guardedServer || guardedPort === undefined || !ownerPluginId) + throw new Error('Could not reserve a derived test port'); + + const child = new ScriptedChild(() => undefined); + const adapter = createMiniAppNodeRuntimeAdapter({ + resolvePluginDataDir: () => dataDir, + verifyCandidate: async () => true, + materializePackage: async ({ targetRoot }) => { + const target = join(targetRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'export const start = async () => undefined;\n'); + }, + isPortReserved: ({ port }) => port === guardedPort, + maxPortAttempts: 1, + spawn: () => child as unknown as ChildProcess, + createTerminator: () => ({ stop: async () => ({ proven: true }) }) as never, + }); + + try { + const result = adapter.prepare({ + candidate: candidate(ownerPluginId), + processGeneration: 'generation-reserved-owner', + signal: new AbortController().signal, + onLog: () => undefined, + }); + const outcome = await result.then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); + await outcome.value?.stop(); + expect(outcome).toHaveProperty('error'); + expect('error' in outcome ? outcome.error : undefined).toMatchObject({ + code: 'OWNED_ELSEWHERE', + }); + expect(child.received).toHaveLength(0); + } finally { + guardedServer.closeAllConnections(); + await new Promise((resolve) => guardedServer?.close(() => resolve())); + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it('checks the derived ownership port when a different preferred listen port exists', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'miniapp preferred owner ')); + let guardedServer: Server | undefined; + let pluginId: string | undefined; + let guardedPort: number | undefined; + for (let attempt = 0; attempt < 100 && guardedServer === undefined; attempt += 1) { + const candidateId = `fixture.preferred-owner-${attempt}`; + const port = deriveMiniAppNodePort(candidateId); + const server = createServer((_request, response) => response.writeHead(401).end()); + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', resolve); + }); + guardedServer = server; + pluginId = candidateId; + guardedPort = port; + } catch { + server.closeAllConnections(); + } + } + if (!guardedServer || !pluginId || guardedPort === undefined) + throw new Error('Could not reserve a derived test port'); + const preferredPort = guardedPort === 65_535 ? 49_152 : guardedPort + 1; + const child = new ScriptedChild(() => undefined); + const adapter = createMiniAppNodeRuntimeAdapter({ + resolvePluginDataDir: () => dataDir, + verifyCandidate: async () => true, + materializePackage: async ({ targetRoot }) => { + const target = join(targetRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'export const start = async () => undefined;\n'); + }, + isPortReserved: () => false, + maxPortAttempts: 1, + spawn: () => child as unknown as ChildProcess, + createTerminator: () => ({ stop: async () => ({ proven: true }) }) as never, + }); + + try { + const outcome = await adapter.prepare({ + candidate: candidate(pluginId), + processGeneration: 'generation-preferred-owner', + preferredPort, + signal: new AbortController().signal, + onLog: () => undefined, + }).then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); + await outcome.value?.stop(); + expect(outcome).toHaveProperty('error'); + expect('error' in outcome ? outcome.error : undefined).toMatchObject({ + code: 'OWNED_ELSEWHERE', + }); + expect(child.received).toHaveLength(0); + } finally { + guardedServer.closeAllConnections(); + await new Promise((resolve) => guardedServer?.close(() => resolve())); + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it('refuses a Mini App another client owns, without spawning a process', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'miniapp owned ')); + const build = (child: ScriptedChild) => + createMiniAppNodeRuntimeAdapter({ + resolvePluginDataDir: () => dataDir, + verifyCandidate: async () => true, + materializePackage: async ({ targetRoot }) => { + const target = join(targetRoot, 'miniapp', 'node', 'server.js'); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'export const start = async () => undefined;\n'); + }, + isPortReserved: () => false, + ownerClient: 'webui', + spawn: () => child as unknown as ChildProcess, + createTerminator: () => ({ stop: async () => ({ proven: true }) }) as never, + }); + + const owner = new ScriptedChild(() => undefined); + const runtime = await build(owner).prepare({ + candidate: candidate(), + processGeneration: 'generation-1', + signal: new AbortController().signal, + onLog: () => undefined, + }); + try { + const second = new ScriptedChild(() => undefined); + await expect( + build(second).prepare({ + candidate: candidate(), + processGeneration: 'generation-2', + signal: new AbortController().signal, + onLog: () => undefined, + }), + ).rejects.toMatchObject({ code: 'OWNED_ELSEWHERE' }); + // The refusal happened before any spawn, so the second client never reached a process. + expect(second.received).toHaveLength(0); + } finally { + await runtime.stop(); + await rm(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/node-runtime-startup-error.test.ts b/packages/local-runtime-v2/test/unit/miniapp/node-runtime-startup-error.test.ts new file mode 100644 index 000000000..583a5e24a --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/node-runtime-startup-error.test.ts @@ -0,0 +1,143 @@ +import type { ChildProcess } from 'node:child_process'; +import { PassThrough } from 'node:stream'; + +import { describe, expect, it } from 'vitest'; + +import { + attachMiniAppRuntimeErrorDetail, + MINIAPP_RUNTIME_ERROR_TEXT_MAX_BYTES, + readMiniAppRuntimeErrorDetail, +} from '../../../src/service/miniapp/errors.js'; +import { + attachStartupFailureDetail, + captureStartupError, + isRunnerErrorMessage, + MiniAppNodeRuntimeError, +} from '../../../src/service/miniapp/process/node-runtime/node-runtime-startup-error.js'; + +describe('Mini App startup error boundary', () => { + it('retains only the typed BUSY reason on Node runtime admission failures', () => { + expect( + new MiniAppNodeRuntimeError('BUSY', 'plugin root exists', { + busyReason: 'operation_busy', + }), + ).toMatchObject({ code: 'BUSY', busyReason: 'operation_busy' }); + expect( + new MiniAppNodeRuntimeError('RUNNER_START_FAILED', 'not an admission failure', { + busyReason: 'capacity_busy', + }), + ).not.toHaveProperty('busyReason'); + }); + + it.each([ + { value: undefined, valid: false }, + { value: null, valid: false }, + { value: [], valid: false }, + { value: { type: 'other', code: 'RUNNER_START_FAILED', errorText: 'failure' }, valid: false }, + { value: { type: 'miniapp:error', code: 500, errorText: 'failure' }, valid: false }, + { + value: { type: 'miniapp:error', code: 'RUNNER_START_FAILED', errorText: 500 }, + valid: false, + }, + { + value: { + type: 'miniapp:error', + code: 'RUNNER_START_FAILED', + errorText: 'failure', + }, + valid: true, + }, + ])('accepts only the complete runner error envelope', ({ value, valid }) => { + expect(isRunnerErrorMessage(value)).toBe(valid); + }); + + it('copies an existing safe detail without reinterpreting it', () => { + const source = new Error('source'); + attachMiniAppRuntimeErrorDetail(source, { + errorText: 'safe startup error', + stderr: 'safe stderr', + }); + const target = new Error('target'); + + attachStartupFailureDetail(source, target); + + expect(readMiniAppRuntimeErrorDetail(target)).toEqual({ + errorText: 'safe startup error', + stderr: 'safe stderr', + }); + }); + + it('falls back to safe text for ordinary and non-convertible startup errors', () => { + const ordinary = new Error('spawn failed'); + ordinary.stack = undefined; + const ordinaryTarget = new Error('target'); + attachStartupFailureDetail(ordinary, ordinaryTarget); + expect(readMiniAppRuntimeErrorDetail(ordinaryTarget)).toEqual({ + errorText: 'Error: spawn failed', + }); + + const hostile = { + [Symbol.toPrimitive]() { + throw new Error('conversion failed'); + }, + }; + const hostileTarget = new Error('target'); + attachStartupFailureDetail(hostile, hostileTarget); + expect(readMiniAppRuntimeErrorDetail(hostileTarget)).toEqual({ + errorText: 'Runtime startup error could not be converted to text', + }); + }); + + it('keeps the bounded stderr tail when successive chunks wrap the ring buffer', () => { + const stderr = new PassThrough(); + const capture = captureStartupError({ stderr } as unknown as ChildProcess); + stderr.emit('data', Buffer.alloc(MINIAPP_RUNTIME_ERROR_TEXT_MAX_BYTES - 2, 0x61)); + stderr.emit('data', Buffer.from('TAIL')); + const target = new Error('target'); + + capture.attachFailure(target, new Error('startup failed')); + + const detail = readMiniAppRuntimeErrorDetail(target); + expect(detail?.stderr).toMatch(/^\[\.\.\. stderr truncated from/u); + expect(detail?.stderr?.endsWith('TAIL')).toBe(true); + expect(Buffer.byteLength(detail?.stderr ?? '', 'utf8')).toBeLessThanOrEqual( + MINIAPP_RUNTIME_ERROR_TEXT_MAX_BYTES, + ); + expect(stderr.listenerCount('data')).toBe(0); + }); + + it('accepts string stderr chunks and ignores empty chunks', () => { + const stderr = new PassThrough(); + const capture = captureStartupError({ stderr } as unknown as ChildProcess); + stderr.emit('data', Buffer.alloc(0)); + stderr.emit('data', 'string stderr'); + const target = new Error('target'); + + capture.attachFailure(target, new Error('startup failed')); + + expect(readMiniAppRuntimeErrorDetail(target)?.stderr).toBe('string stderr'); + }); + + it('starts an oversized single-chunk tail after a UTF-8 continuation boundary', () => { + const stderr = new PassThrough(); + const capture = captureStartupError({ stderr } as unknown as ChildProcess); + const prefixBytes = 16; + const originalBytes = MINIAPP_RUNTIME_ERROR_TEXT_MAX_BYTES + prefixBytes; + const largestNotice = `[... stderr truncated from ${originalBytes} bytes; showing last ${MINIAPP_RUNTIME_ERROR_TEXT_MAX_BYTES} bytes ...]\n`; + const continuationOffset = Buffer.byteLength(largestNotice, 'utf8'); + const retained = Buffer.alloc(MINIAPP_RUNTIME_ERROR_TEXT_MAX_BYTES, 0x61); + retained[continuationOffset] = 0x80; + retained[continuationOffset + 1] = 0xbf; + stderr.emit('data', Buffer.concat([Buffer.alloc(prefixBytes, 0x62), retained])); + const target = new Error('target'); + + capture.attachFailure(target, new Error('startup failed')); + + const captured = readMiniAppRuntimeErrorDetail(target)?.stderr; + expect(captured).toMatch(/^\[\.\.\. stderr truncated from/u); + expect(captured).not.toContain('�'); + expect(Buffer.byteLength(captured ?? '', 'utf8')).toBeLessThanOrEqual( + MINIAPP_RUNTIME_ERROR_TEXT_MAX_BYTES, + ); + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/owner-lease.test.ts b/packages/local-runtime-v2/test/unit/miniapp/owner-lease.test.ts new file mode 100644 index 000000000..93b7179fd --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/owner-lease.test.ts @@ -0,0 +1,96 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import net from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + acquireMiniAppOwner, + readMiniAppOwnerIdentity, + resolveMiniAppOwnerEndpoint, + type MiniAppOwnerAcquisition, +} from '../../../src/service/miniapp/process/node-runtime/owner-lease.js'; + +const roots: string[] = []; +const held: MiniAppOwnerAcquisition[] = []; + +afterEach(async () => { + for (const acquisition of held.splice(0)) { + if (acquisition.kind === 'acquired') await acquisition.release(); + } + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); +}); + +async function pluginDataDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'miniapp owner ')); + roots.push(dir); + return dir; +} + +async function acquire(dir: string, client = 'webui'): Promise { + const acquisition = await acquireMiniAppOwner({ + pluginDataDir: dir, + pluginId: 'fixture.miniapp', + identity: { client }, + }); + if (acquisition.kind === 'acquired') held.push(acquisition); + return acquisition; +} + +describe('Mini App ownership', () => { + it('acquires an unowned Mini App and can take it again after releasing', async () => { + const dir = await pluginDataDir(); + + const first = await acquire(dir); + expect(first.kind).toBe('acquired'); + if (first.kind === 'acquired') await first.release(); + + expect((await acquire(dir)).kind).toBe('acquired'); + }); + + it('refuses while another client holds it, and names that client', async () => { + const dir = await pluginDataDir(); + + expect((await acquire(dir, 'desktop')).kind).toBe('acquired'); + expect(await readMiniAppOwnerIdentity(dir)).toMatchObject({ client: 'desktop', pid: process.pid }); + + const second = await acquire(dir, 'webui'); + expect(second.kind).toBe('held'); + if (second.kind === 'held') { + expect(second.owner?.client).toBe('desktop'); + expect(second.owner?.pid).toBe(process.pid); + } + }); + + it('reclaims an endpoint left behind by an owner that died', async () => { + const dir = await pluginDataDir(); + const endpoint = resolveMiniAppOwnerEndpoint(dir, 'fixture.miniapp'); + // An owner that died without cleanup leaves the socket file and nothing listening on it. + const stale = net.createServer(); + await new Promise((resolve) => stale.listen(endpoint, resolve)); + await new Promise((resolve) => stale.close(() => resolve())); + + expect((await acquire(dir)).kind).toBe('acquired'); + }); + + it('lets two different Mini Apps be owned at the same time', async () => { + const first = await pluginDataDir(); + const second = await pluginDataDir(); + + expect((await acquire(first)).kind).toBe('acquired'); + expect((await acquire(second)).kind).toBe('acquired'); + }); + + it('names the endpoint after the plugin data directory, not a global path', async () => { + const first = await pluginDataDir(); + const second = await pluginDataDir(); + + expect(resolveMiniAppOwnerEndpoint(first, 'fixture.miniapp')).not.toBe( + resolveMiniAppOwnerEndpoint(second, 'fixture.miniapp'), + ); + expect(resolveMiniAppOwnerEndpoint(first, 'fixture.miniapp', 'win32')).toMatch( + /^\\\\\.\\pipe\\mcode-miniapp-owner-[a-f0-9]{16}$/u, + ); + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/port-registry.test.ts b/packages/local-runtime-v2/test/unit/miniapp/port-registry.test.ts new file mode 100644 index 000000000..abf6c5052 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/port-registry.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { HostMiniAppPortRegistry } from '../../../src/service/miniapp/process/node-runtime/port-registry.js'; + +describe('Mini App Node runtime port registry', () => { + it('keeps product ports and generation reservations mutually exclusive', () => { + const registry = new HostMiniAppPortRegistry(new Set([50_000])); + + expect(registry.isReserved({ pluginId: 'one', port: 50_000 })).toBe(true); + const first = registry.reserve({ + pluginId: 'one', + port: 50_001, + processGeneration: 'process-1', + }); + expect(first).toBeDefined(); + expect( + registry.reserve({ + pluginId: 'two', + port: 50_001, + processGeneration: 'process-2', + }), + ).toBeUndefined(); + + first?.release(); + first?.release(); + expect(registry.isReserved({ pluginId: 'one', port: 50_001 })).toBe(false); + }); +}); diff --git a/packages/local-runtime-v2/test/unit/miniapp/runtime-miniapp-options.test.ts b/packages/local-runtime-v2/test/unit/miniapp/runtime-miniapp-options.test.ts new file mode 100644 index 000000000..98040adc3 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/miniapp/runtime-miniapp-options.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveRuntimeMiniAppOptions } from '../../../src/service/miniapp/composition.js'; + +describe('resolveRuntimeMiniAppOptions', () => { + it('starts the service when the owner policy carries the cell', () => { + expect(resolveRuntimeMiniAppOptions({ miniAppService: true })).toEqual({}); + }); + + it('leaves the service off when the cell is absent or false', () => { + expect(resolveRuntimeMiniAppOptions({})).toBeUndefined(); + expect(resolveRuntimeMiniAppOptions({ miniAppService: false })).toBeUndefined(); + }); + + it('keeps explicit options reachable even when the policy cell is false', () => { + const miniApp = { dataDir: '/tmp/miniapp' } as Parameters< + typeof resolveRuntimeMiniAppOptions + >[0]['miniApp']; + + expect(resolveRuntimeMiniAppOptions({ miniApp })).toBe(miniApp); + expect(resolveRuntimeMiniAppOptions({ miniApp, miniAppService: false })).toBe(miniApp); + }); + +}); diff --git a/packages/local-runtime-v2/test/unit/plugin-system/miniapp-manifest.test.ts b/packages/local-runtime-v2/test/unit/plugin-system/miniapp-manifest.test.ts new file mode 100644 index 000000000..9d55dff2d --- /dev/null +++ b/packages/local-runtime-v2/test/unit/plugin-system/miniapp-manifest.test.ts @@ -0,0 +1,129 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { readOptionalMiniAppContribution } from '../../../src/service/plugin-system/plugin/package/miniapp/reader.js'; + +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); +}); + +async function writeTree(root: string, files: Record): Promise { + for (const [relativePath, content] of Object.entries(files)) { + const target = join(root, relativePath); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content); + } +} + +/** The smallest package the reader accepts: a process runtime and its two payload roots. */ +async function readManifest(miniapp: Record) { + const root = await mkdtemp(join(tmpdir(), 'miniapp manifest ')); + roots.push(root); + await writeTree(root, { + 'package.json': JSON.stringify({ + name: 'miniapp-fixture', + version: '0.0.0', + mcode: { schemaVersion: 2, miniApp: './miniapp/miniapp.json' }, + }), + 'miniapp/miniapp.json': JSON.stringify({ + schemaVersion: 1, + artifacts: { client: ['./miniapp/client'], node: ['./miniapp/node'] }, + runtime: { kind: 'process', entry: './miniapp/node/server.js' }, + surface: { path: '/dashboard' }, + ...miniapp, + }), + 'miniapp/client/index.html': '\n', + 'miniapp/node/server.js': 'export {};\n', + }); + return readOptionalMiniAppContribution(root, []); +} + +describe('Mini App manifest compatibility with the shipped generation', () => { + it('reads a manifest written for the shipped client', async () => { + const contribution = await readManifest({ + surface: { path: '/dashboard', window: { width: 800, height: 600, alwaysOnTop: true } }, + agentAccess: ['session.run'], + agentConcurrency: 4, + }); + + expect(contribution).toMatchObject({ + surface: { path: '/dashboard' }, + agentAccess: ['session.run'], + agentConcurrency: 4, + }); + }); + + it.each([ + ['a desktop window declaration', { width: 800, height: 600, frame: false }], + ['a declaration the dropped rule set would reject', { transparent: true }], + ['a value of the wrong type', 'window'], + ])('gives surface.window no effect: %s', async (_label, window) => { + const contribution = await readManifest({ surface: { path: '/dashboard', window } }); + + expect(contribution?.surface).toEqual({ path: '/dashboard' }); + }); + + it('deduplicates agentAccess', async () => { + const contribution = await readManifest({ agentAccess: ['session.run', 'session.run'] }); + + expect(contribution?.agentAccess).toEqual(['session.run']); + }); + + it('omits both agent keys when the manifest does not declare them', async () => { + const contribution = await readManifest({}); + + expect(contribution).not.toHaveProperty('agentAccess'); + expect(contribution).not.toHaveProperty('agentConcurrency'); + }); + + it.each([[['session.observe']], [['session.run', 'session.stop']], ['session.run']])( + 'fails closed for agentAccess %j', + async (agentAccess) => { + await expect(readManifest({ agentAccess })).rejects.toMatchObject({ + code: 'MINIAPP_AGENT_ACCESS_INVALID', + }); + }, + ); + + it.each([[1], [32], [4]])( + 'accepts agentConcurrency %j without agentAccess, where it has no effect', + async (agentConcurrency) => { + const contribution = await readManifest({ agentConcurrency }); + + expect(contribution?.agentConcurrency).toBe(agentConcurrency); + expect(contribution).not.toHaveProperty('agentAccess'); + }, + ); + + it.each([[0], [33], [4.5], ['4'], [null]])( + 'fails closed for agentConcurrency %j', + async (agentConcurrency) => { + await expect(readManifest({ agentConcurrency })).rejects.toMatchObject({ + code: 'MINIAPP_AGENT_CONCURRENCY_INVALID', + }); + }, + ); +}); + +describe('Mini App manifest allowlist', () => { + it('fails a manifest with an unknown top-level key', async () => { + await expect(readManifest({ detach: true })).rejects.toMatchObject({ + code: 'MINIAPP_MANIFEST_SCHEMA_INVALID', + }); + }); + + it('fails an unknown key beside surface.window', async () => { + await expect( + readManifest({ surface: { path: '/dashboard', window: {}, surfaceId: 'app' } }), + ).rejects.toMatchObject({ + // The message is part of the behaviour: a relaxed predicate must not read as a pass. + code: 'MINIAPP_SURFACE_INVALID', + message: 'MINIAPP_SURFACE_INVALID: surface must contain only path and window', + }); + }); +}); diff --git a/packages/local-runtime-v2/test/unit/turn-system/miniapp-approval-gate.test.ts b/packages/local-runtime-v2/test/unit/turn-system/miniapp-approval-gate.test.ts new file mode 100644 index 000000000..817325602 --- /dev/null +++ b/packages/local-runtime-v2/test/unit/turn-system/miniapp-approval-gate.test.ts @@ -0,0 +1,125 @@ +/** + * A Mini App's permission ask must be answerable by somebody. + * + * A Mini App session is created by a plugin, not by the user, so nobody is watching it unless the run + * that asked registered an event listener or a host chat has the session open in a side panel. When + * neither holds, waiting on the approval port leaves the turn parked on a prompt no one can answer and + * the plugin's queue never drains. The gate therefore consults the Mini App approval router before it + * asks, and denies with a reason the model can act on. + * + * Only the router is faked here; the gate itself is the production class. + */ +import { describe, expect, it, vi } from 'vitest'; + +import { + LocalTurnPermissionGate, + type LocalTurnPermissionDecisionSource, +} from '../../../src/service/turn-system/agent-host/runner/policy/local-turn-permission-gate.js'; + +const SESSION_LINEAGE_MINIAPP = { miniApp: true } as const; +const SESSION_LINEAGE_ORDINARY = { miniApp: false, parentSessionId: null } as const; + +function harness( + router: { shouldDenyApproval: ReturnType } | undefined, +) { + const approval = { request: vi.fn(async () => 'allowOnce' as const) }; + const decisions: LocalTurnPermissionDecisionSource = { + check: async () => ({ behavior: 'ask', reason: 'This tool needs approval.' }), + }; + const gate = new LocalTurnPermissionGate({ + decisions, + approval, + ...(router ? { miniAppApprovals: router as never } : {}), + }); + return { gate, approval }; +} + +function input(lineage?: unknown) { + return { + sessionId: 'session-miniapp', + turnId: 'turn-1', + agentName: 'mavis', + model: 'minimax/M3', + cwd: '/workspace', + toolContext: { + toolCall: { id: 'call-1', name: 'mcp_report', source: 'plugin' }, + args: { target: 'weekly' }, + }, + ...(lineage !== undefined ? { sessionLineage: lineage } : {}), + }; +} + +describe('LocalTurnPermissionGate Mini App approval routing', () => { + it('denies an ask nobody can answer instead of waiting on the approval port', async () => { + const router = { shouldDenyApproval: vi.fn(async () => true) }; + const { gate, approval } = harness(router); + + const result = await gate.beforeToolCall(input(SESSION_LINEAGE_MINIAPP) as never); + + expect(router.shouldDenyApproval).toHaveBeenCalledWith('session-miniapp', SESSION_LINEAGE_MINIAPP); + expect(approval.request).not.toHaveBeenCalled(); + expect(result).toMatchObject({ block: true, blockedBy: 'permission' }); + expect(result?.reason).toMatch(/nobody can answer/i); + expect(result?.reason).toContain('mcp_report'); + }); + + it('asks the user when a run listener or an open panel can answer', async () => { + const router = { shouldDenyApproval: vi.fn(async () => false) }; + const { gate, approval } = harness(router); + + const result = await gate.beforeToolCall(input(SESSION_LINEAGE_MINIAPP) as never); + + expect(router.shouldDenyApproval).toHaveBeenCalledTimes(1); + expect(approval.request).toHaveBeenCalledTimes(1); + expect(result).toBeUndefined(); + }); + + it('does not consult the router for an ordinary root Session', async () => { + const router = { shouldDenyApproval: vi.fn(async () => true) }; + const { gate, approval } = harness(router); + + const result = await gate.beforeToolCall(input(SESSION_LINEAGE_ORDINARY) as never); + + expect(router.shouldDenyApproval).not.toHaveBeenCalled(); + expect(approval.request).toHaveBeenCalledTimes(1); + expect(result).toBeUndefined(); + }); + + it('keeps asking when the runtime has no Mini App router', async () => { + const { gate, approval } = harness(undefined); + + const result = await gate.beforeToolCall(input(SESSION_LINEAGE_MINIAPP) as never); + + expect(approval.request).toHaveBeenCalledTimes(1); + expect(result).toBeUndefined(); + }); + + it('fails closed when the router cannot answer for a Mini App root', async () => { + const router = { + shouldDenyApproval: vi.fn(async () => { + throw new Error('routing unavailable'); + }), + }; + const { gate, approval } = harness(router); + + const result = await gate.beforeToolCall(input(SESSION_LINEAGE_MINIAPP) as never); + + expect(approval.request).not.toHaveBeenCalled(); + expect(result).toMatchObject({ block: true, blockedBy: 'permission' }); + }); + + it('asks normally when the router fails and the lineage is not known to be a Mini App', async () => { + const router = { + shouldDenyApproval: vi.fn(async () => { + throw new Error('routing unavailable'); + }), + }; + const { gate, approval } = harness(router); + + const result = await gate.beforeToolCall(input({ miniApp: false, parentSessionId: 'parent-1' }) as never); + + expect(router.shouldDenyApproval).toHaveBeenCalledTimes(1); + expect(approval.request).toHaveBeenCalledTimes(1); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/local-runtime/src/runtime/runtime-owner-policy.ts b/packages/local-runtime/src/runtime/runtime-owner-policy.ts index 551763782..899fc9d4f 100644 --- a/packages/local-runtime/src/runtime/runtime-owner-policy.ts +++ b/packages/local-runtime/src/runtime/runtime-owner-policy.ts @@ -58,6 +58,12 @@ export interface RuntimeWiring { readonly ownsV2Runtime: boolean; /** Replaces the inline `runtimeOwnerKind === 'electron'` scheduler gate. */ readonly schedulerHost: boolean; + /** + * Whether this host runs the Mini App service. A literal per row on purpose: the electron-capability + * predicate answers true for an owner that declared no kind, and a service enabled for an absent owner + * is a behaviour change no client asked for. + */ + readonly miniAppService: boolean; /** Replaces `ownsElectronRuntimeCapabilities` at the `services.ts` cron gate. */ readonly cronService: boolean; /** @@ -200,6 +206,7 @@ const UNKNOWN_OWNER_POLICY: Omit = { wiring: { ownsV2Runtime: true, schedulerHost: false, + miniAppService: false, cronService: false, channelService: false, browserActivation: 'explicit-config', @@ -254,6 +261,7 @@ export function resolveRuntimeOwnerPolicy( wiring: { ownsV2Runtime: isV2RuntimeOwner(kind, electronHost, cliEmbedded), schedulerHost: true, + miniAppService: true, cronService: ownsElectronRuntimeCapabilities(kind), channelService: ownsElectronRuntimeCapabilities(kind) && capabilityProfile !== 'cli', @@ -279,6 +287,7 @@ export function resolveRuntimeOwnerPolicy( wiring: { ownsV2Runtime: isV2RuntimeOwner(kind, electronHost, cliEmbedded), schedulerHost: false, + miniAppService: false, cronService: ownsElectronRuntimeCapabilities(kind), channelService: ownsElectronRuntimeCapabilities(kind) && capabilityProfile !== 'cli', @@ -307,6 +316,7 @@ export function resolveRuntimeOwnerPolicy( wiring: { ownsV2Runtime: isV2RuntimeOwner(kind, electronHost, cliEmbedded), schedulerHost: false, + miniAppService: false, cronService: ownsElectronRuntimeCapabilities(kind), channelService: ownsElectronRuntimeCapabilities(kind) && capabilityProfile !== 'cli', @@ -332,6 +342,8 @@ export function resolveRuntimeOwnerPolicy( // The raw option is undefined, so the compatibility gate rejects it. ownsV2Runtime: isV2RuntimeOwner(kind, electronHost, cliEmbedded), schedulerHost: false, + // A literal, not the predicate below: an absent owner must not inherit the service. + miniAppService: false, // ...while these read the raw undefined and answer true. cronService: ownsElectronRuntimeCapabilities(kind), channelService: @@ -364,6 +376,11 @@ export function resolveRuntimeOwnerPolicy( // layer and with it the questionnaire service. ownsV2Runtime: isV2RuntimeOwner('tui', electronHost, cliEmbedded), schedulerHost: true, + // Enabled with the presenter and the view that landed beside it (ADR 0020): + // the browser presenter records the presentation, and the workspace panel's + // `miniapp` tab frames the proxy URL the open operation answers with. Flipped + // earlier, the agent could start a Mini App that nothing can display. + miniAppService: true, cronService: true, channelService: false, browserActivation: 'explicit-config', diff --git a/packages/local-runtime/test/unit/runtime-owner-policy.test.ts b/packages/local-runtime/test/unit/runtime-owner-policy.test.ts index 9859670c1..d27a3bd3b 100644 --- a/packages/local-runtime/test/unit/runtime-owner-policy.test.ts +++ b/packages/local-runtime/test/unit/runtime-owner-policy.test.ts @@ -143,6 +143,7 @@ describe('resolveRuntimeOwnerPolicy', () => { 'browserActivation', 'channelService', 'cronService', + 'miniAppService', 'ownsV2Runtime', 'schedulerHost', ]); @@ -312,13 +313,15 @@ describe('resolveRuntimeOwnerPolicy', () => { expect(policy.execution.mavisFeatureBundle).toBe(true); }); - it("'webui' resolves to its own row, which differs from tui in six cells", () => { - // Read as "where WebUI differs from tui": seven cells are inherited so an + it("'webui' resolves to its own row, which differs from tui in seven cells", () => { + // Read as "where WebUI differs from tui": the remaining cells are inherited so an // identity change does not silently move turn-execution semantics. // // Compared as the two clients actually declare today: `tui` still carries // capabilityProfile 'cli', while the webui row sets mavisFeatureBundle explicitly. - // That is why it is one of the six differences rather than an inherited cell. + // That is why it is one of the differences rather than an inherited cell. + // `miniAppService` is another: it is enabled here because the presenter, the view + // and the cell landed together (ADR 0020), and `tui` has no Mini App surface at all. const webui = policyFor({ kind: 'webui', cliEmbedded: true, @@ -344,13 +347,15 @@ describe('resolveRuntimeOwnerPolicy', () => { 'execution.memoryFeature', 'execution.restricted', 'wiring.cronService', + 'wiring.miniAppService', 'wiring.schedulerHost', ].sort(), ); - // The six differences themselves, asserted so the count is not the only check. + // The differences themselves, asserted so the count is not the only check. expect(webui.wiring.ownsV2Runtime).toBe(true); expect(webui.wiring.schedulerHost).toBe(true); expect(webui.wiring.cronService).toBe(true); + expect(webui.wiring.miniAppService).toBe(true); expect(webui.wiring.channelService).toBe(false); expect(webui.wiring.browserActivation).toBe('explicit-config'); expect(webui.execution.restricted).toBe(false); diff --git a/packages/shared/src/miniapp-surface.ts b/packages/shared/src/miniapp-surface.ts index d4d8d743a..d17629285 100644 --- a/packages/shared/src/miniapp-surface.ts +++ b/packages/shared/src/miniapp-surface.ts @@ -1,3 +1,9 @@ +import type { + AskQuestionnaireMode, + AskQuestionnaireReplyAnswer, + AskQuestionnaireRequest, +} from './questionnaire.js'; + /** Renderer-safe Mini App metadata. Runtime launch coordinates never cross this contract. */ export type MiniAppRuntimeStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; @@ -6,10 +12,23 @@ export interface MiniAppSurfaceSummary { readonly displayName?: string; readonly description?: string; readonly iconUrl?: string; + /** + * Whether the runtime may start this package. + * + * Absent for a producer that lists only startable packages — which is what the + * launch path and the desktop client's catalogue do — and `false` for a row a + * management surface read through the installed listing, so that surface can + * offer to enable it instead of a start the runtime would refuse. + */ + readonly enabled?: boolean; readonly runtime: { readonly kind: 'process'; readonly status: MiniAppRuntimeStatus; readonly errorCode?: string; + /** The process guard proves this plugin is running in another client. */ + readonly ownership?: 'other-client'; + /** Label recorded by that live process owner; omitted when its record is unreadable. */ + readonly ownerClient?: string; }; } @@ -18,6 +37,15 @@ export interface MiniAppSurfaceTabSummary { readonly title: string; readonly surfaceKind: 'miniapp'; readonly miniApp: MiniAppSurfaceSummary; + /** True while the page is shown in its own window instead of the File Panel. */ + readonly detached: boolean; + /** + * Monotonic, bumped once per completed detach and once per completed attach. + * A fast detach->attach round trip can complete between two state polls, + * leaving `detached` unchanged on both reads; the renderer keys its + * presentation on this counter too so it still forces a fresh `show()`. + */ + readonly presentationRevision: number; } export type MiniAppSurfaceErrorCode = @@ -27,7 +55,9 @@ export type MiniAppSurfaceErrorCode = | 'busy' | 'refresh_failed' | 'stop_failed' - | 'start_failed'; + | 'start_failed' + | 'detach_failed' + | 'attach_failed'; /** Bounded, renderer-safe explanation for a retryable Mini App busy failure. */ export type MiniAppSurfaceBusyReason = 'capacity_busy' | 'operation_busy'; @@ -49,6 +79,909 @@ export type MiniAppOpenResponse = readonly busyReason?: never; }; +export type MiniAppStartResponse = + | { readonly success: true; readonly miniApp: MiniAppSurfaceSummary } + | Extract; + +export interface MiniAppPageOpenInput { + readonly sessionId: string; + readonly pluginId: string; + readonly openPage?: true; +} + +export interface MiniAppBackgroundOpenInput { + readonly pluginId: string; + readonly openPage: false; +} + +/** Keeps existing page-open callers precise while sharing one renderer bridge. */ +export interface MiniAppOpenApi { + (params: MiniAppPageOpenInput): Promise; + (params: MiniAppBackgroundOpenInput): Promise; +} + export type MiniAppControlResponse = | { readonly success: true } | { readonly success: false; readonly code: MiniAppSurfaceErrorCode }; + +/** + * Request header that carries the Host-issued loopback credential to a Mini App's own + * port. Only the name is shared; the value stays in Electron main and local-runtime. + */ +export const MINIAPP_LOOPBACK_TOKEN_HEADER = 'x-mavis-miniapp-token'; + +/** Bounds one untrusted page request; input behavior remains owned by the chat input. */ +export const MINIAPP_COMPOSER_MAX_TEXT_LENGTH = 100_000; + +const MINIAPP_COMPOSER_FAILURE_CODES = [ + 'user_cancelled', + 'invalid_request', + 'text_too_large', + 'not_allowed', + 'target_unavailable', + 'result_unknown', +] as const; + +/** Success acknowledges a draft append, never an Agent submission or a durable disk commit. */ +export type MiniAppComposerAppendResult = + | { readonly success: true } + | { + readonly success: false; + readonly code: (typeof MINIAPP_COMPOSER_FAILURE_CODES)[number]; + }; + +/** Both IPC boundaries use the same allowlist and discard all non-contract fields. */ +export function parseMiniAppComposerAppendResult( + value: unknown, +): MiniAppComposerAppendResult | null { + if (!value || typeof value !== 'object') return null; + const result = value as Record; + if (result.success === true) return { success: true }; + const code = MINIAPP_COMPOSER_FAILURE_CODES.find((candidate) => candidate === result.code); + return result.success === false && code ? { success: false, code } : null; +} + +/** Main-to-Host-renderer only. None of these identity fields are supplied by the MiniApp. */ +export interface MiniAppComposerRequest { + readonly requestId: string; + readonly sessionId: string; + readonly pluginId: string; + readonly pluginName: string; + readonly text: string; +} + +/** Host-to-Main result for one requested append. */ +export interface MiniAppComposerReply { + readonly requestId: string; + readonly result: MiniAppComposerAppendResult; +} + +/** Host-renderer-only confirmation channel; never exposed to an embedded page. */ +export interface MiniAppComposerHostAPI { + onAppendRequest( + handler: (request: MiniAppComposerRequest) => Promise, + ): () => void; +} + +/** Bounds one untrusted page Agent request before it crosses into Main. */ +export const MINIAPP_AGENT_MAX_TEXT_LENGTH = 100_000; + +export const MINIAPP_AGENT_FAILURE_CODES = [ + 'invalid_request', + 'not_permitted', + 'busy', + 'session_not_found', + 'runtime_unavailable', + 'cancelled', + 'result_unknown', + 'request_not_found', +] as const; + +export type MiniAppAgentFailureCode = (typeof MINIAPP_AGENT_FAILURE_CODES)[number]; + +/** + * Why a `run` / `answer` failed as `busy`: its session is already running, or the Mini App's + * queue of waiting calls is full. + */ +export type MiniAppAgentBusyReason = 'session_busy' | 'queue_full'; + +export function isMiniAppAgentBusyReason(value: unknown): value is MiniAppAgentBusyReason { + return value === 'session_busy' || value === 'queue_full'; +} + +/** `reason` is set only with `busy`. */ +export interface MiniAppAgentFailure { + readonly ok: false; + readonly code: MiniAppAgentFailureCode; + readonly reason?: MiniAppAgentBusyReason; +} + +/** Bounds a page-chosen request id; it is only a per-page correlation key. */ +export const MINIAPP_AGENT_MAX_REQUEST_ID_LENGTH = 128; + +/** What a Mini App sends and receives for a model; the composer's selection minus provider internals. */ +export interface MiniAppAgentModelSelection { + readonly providerId: string; + readonly modelId: string; + readonly variant?: string; + readonly thinking?: { readonly effort: string }; +} + +/** One enabled model of the runtime catalog. */ +export interface MiniAppAgentModelOption { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly variants?: readonly string[]; + readonly thinking?: { readonly efforts: readonly string[]; readonly default?: string }; +} + +export interface MiniAppAgentModels { + readonly models: readonly MiniAppAgentModelOption[]; + /** What a session without a host chat model is created with. */ + readonly default: MiniAppAgentModelSelection; + /** The host chat's current model; absent for a tab without a host chat. */ + readonly host?: MiniAppAgentModelSelection; +} + +export interface MiniAppAgentAgentOption { + /** The agent's canonical name; what `agent` takes. */ + readonly name: string; + readonly displayName: string; + /** The default agent. */ + readonly primary: boolean; +} + +export interface MiniAppAgentAgents { + readonly agents: readonly MiniAppAgentAgentOption[]; +} + +/** Page payload. Plugin and run identity are resolved by Main from the sending tab. */ +export interface MiniAppAgentRunRequest { + readonly text: string; + readonly sessionId?: string; + /** Page-chosen id for `cancel(requestId)`; generated when absent. */ + readonly requestId?: string; + /** Switches the session to this model before the turn. */ + readonly model?: MiniAppAgentModelSelection; + /** Only with a new session (no `sessionId`): the agent it is created for. */ + readonly agent?: string; +} + +/** Page payload for `create`: a session without a turn. */ +export interface MiniAppAgentCreateRequest { + readonly agent?: string; +} + +export interface MiniAppAgentCreateResult { + readonly sessionId: string; +} + +/** Non-empty, bounded and free of control characters; checked on both sides of the IPC. */ +export function isMiniAppAgentRequestId(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MINIAPP_AGENT_MAX_REQUEST_ID_LENGTH && + !/[\u0000-\u001f\u007f]/u.test(value) + ); +} + +export type MiniAppAgentPermissionDecision = 'allowOnce' | 'deny'; + +/** Page payload for a pending permission; `requestId` is the Host permission request id. */ +export interface MiniAppAgentReplyRequest { + readonly sessionId: string; + readonly requestId: string; + readonly decision: MiniAppAgentPermissionDecision; +} + +/** + * Page payload answering a questionnaire: `questionId` is the questionnaire request id and + * `requestId` is the page-chosen id for `cancel(requestId)`, as in `MiniAppAgentRunRequest`. + */ +export interface MiniAppAgentAnswerRequest { + readonly sessionId: string; + readonly questionId: string; + readonly answers: readonly AskQuestionnaireReplyAnswer[]; + readonly requestId?: string; + /** Switches the session to this model before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; +} + +/** Bounds one untrusted questionnaire answer list before it crosses into Main. */ +export const MINIAPP_AGENT_MAX_ANSWERS = 64; +export const MINIAPP_AGENT_MAX_ANSWER_TEXT_LENGTH = 4_096; + +export function isMiniAppAgentPermissionDecision( + value: unknown, +): value is MiniAppAgentPermissionDecision { + return value === 'allowOnce' || value === 'deny'; +} + +function isBoundedAnswerText(value: unknown): value is string { + return typeof value === 'string' && value.length <= MINIAPP_AGENT_MAX_ANSWER_TEXT_LENGTH; +} + +const ANSWER_KEYS: ReadonlySet = new Set([ + 'stepId', + 'selectedOptionIds', + 'selectedOther', + 'otherText', + 'skipped', +]); + +function readMiniAppAgentAnswer(value: unknown): AskQuestionnaireReplyAnswer | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const input = value as Record; + if (!Object.keys(input).every((key) => ANSWER_KEYS.has(key))) return null; + const { stepId, selectedOptionIds, selectedOther, otherText, skipped } = input; + if (!isNonEmptyString(stepId) || !isBoundedAnswerText(stepId)) return null; + if ( + !Array.isArray(selectedOptionIds) || + selectedOptionIds.length > MINIAPP_AGENT_MAX_ANSWERS || + !selectedOptionIds.every(isBoundedAnswerText) + ) { + return null; + } + if (typeof selectedOther !== 'boolean') return null; + if (otherText !== undefined && !isBoundedAnswerText(otherText)) return null; + if (skipped !== undefined && typeof skipped !== 'boolean') return null; + return { + stepId, + selectedOptionIds: [...selectedOptionIds], + selectedOther, + ...(otherText !== undefined ? { otherText } : {}), + ...(skipped !== undefined ? { skipped } : {}), + }; +} + +/** Closed-key, bounded copy of questionnaire answers; null when any entry is malformed. */ +export function readMiniAppAgentAnswers(value: unknown): AskQuestionnaireReplyAnswer[] | null { + if (!Array.isArray(value) || value.length > MINIAPP_AGENT_MAX_ANSWERS) return null; + const answers: AskQuestionnaireReplyAnswer[] = []; + for (const entry of value) { + const answer = readMiniAppAgentAnswer(entry); + if (!answer) return null; + answers.push(answer); + } + return answers; +} + +/** Page `reply` payload, checked in the preload and again in Main; null when malformed. */ +export function readMiniAppAgentReplyRequest(value: unknown): MiniAppAgentReplyRequest | null { + if (!value || typeof value !== 'object') return null; + const { sessionId, requestId, decision } = value as Record; + return isNonEmptyString(sessionId) && + isNonEmptyString(requestId) && + isMiniAppAgentPermissionDecision(decision) + ? { sessionId, requestId, decision } + : null; +} + +/** + * Wire-only `observe` flag of a turn-following request (`run`, `answer`): the preload sets it + * when the page passed `onEvent`, and only then does Main subscribe the run to events (which + * the runtime also counts as "someone can answer this run's asks"). Absent means `false`; + * null when present but not a boolean. + */ +export function readMiniAppAgentObserveFlag(value: unknown): boolean | null { + if (value === undefined) return false; + return typeof value === 'boolean' ? value : null; +} + +/** + * Page `answer` payload with its (page-chosen or preload-generated) `requestId` and the + * wire-only `observe` flag, checked in the preload and again in Main; null when malformed. + */ +export function readMiniAppAgentAnswerRequest(value: unknown): MiniAppAgentAnswerPayload | null { + if (!value || typeof value !== 'object') return null; + const input = value as Record; + const answers = readMiniAppAgentAnswers(input.answers); + const observe = readMiniAppAgentObserveFlag(input.observe); + const model = readOptionalModel(input.model); + const { sessionId, questionId, requestId } = input; + return isNonEmptyString(sessionId) && + isNonEmptyString(questionId) && + answers !== null && + observe !== null && + model !== null && + isMiniAppAgentRequestId(requestId) + ? { sessionId, questionId, answers, requestId, observe, ...(model ? { model } : {}) } + : null; +} + +type MiniAppAgentAnswerPayload = Required> & { + readonly model?: MiniAppAgentModelSelection; + readonly observe: boolean; +}; + +type MiniAppAgentRunPayload = Required> & + Pick & { readonly observe: boolean }; + +/** Page `run` payload with its (page-chosen or preload-generated) `requestId`; null when malformed. */ +export function readMiniAppAgentRunRequest(value: unknown): MiniAppAgentRunPayload | null { + if (!value || typeof value !== 'object') return null; + const input = value as Record; + const { text, sessionId, requestId, agent } = input; + const observe = readMiniAppAgentObserveFlag(input.observe); + const model = readOptionalModel(input.model); + if (typeof text !== 'string' || text.length > MINIAPP_AGENT_MAX_TEXT_LENGTH) return null; + if (!isMiniAppAgentRequestId(requestId) || observe === null || model === null) return null; + if (!isOptionalNonEmptyString(sessionId) || !isOptionalNonEmptyString(agent)) return null; + return { + requestId, + text, + ...(sessionId !== undefined ? { sessionId } : {}), + ...(model ? { model } : {}), + ...(agent !== undefined ? { agent } : {}), + observe, + }; +} + +/** `undefined` when absent, `null` when present but malformed. */ +function readOptionalModel(value: unknown): MiniAppAgentModelSelection | null | undefined { + return value === undefined ? undefined : readMiniAppAgentModelSelection(value); +} + +/** + * Copies a model selection from untrusted input, dropping extra keys. Mirrors the runtime's + * reader: `providerId` / `modelId` are non-empty strings, an empty `variant` is kept (thinking explicitly off). + */ +export function readMiniAppAgentModelSelection(value: unknown): MiniAppAgentModelSelection | null { + if (!isPlainRecord(value)) return null; + const { providerId, modelId, variant } = value; + if (!isNonEmptyString(providerId) || !isNonEmptyString(modelId)) return null; + if (variant !== undefined && typeof variant !== 'string') return null; + const thinking = readSelectionThinking(value.thinking); + if (thinking === null) return null; + return { + providerId, + modelId, + ...(variant === undefined ? {} : { variant }), + ...(thinking ? { thinking } : {}), + }; +} + +/** `undefined` when absent, `null` when present but malformed. */ +function readSelectionThinking(value: unknown): MiniAppAgentModelSelection['thinking'] | null { + if (value === undefined) return undefined; + return isPlainRecord(value) && typeof value.effort === 'string' ? { effort: value.effort } : null; +} + +function readStringList(value: unknown): string[] | null { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string') + ? [...(value as string[])] + : null; +} + +/** `undefined` when absent, `null` when present but malformed. */ +function readOptionThinking(value: unknown): MiniAppAgentModelOption['thinking'] | null { + if (value === undefined) return undefined; + if (!isPlainRecord(value)) return null; + const efforts = readStringList(value.efforts); + const defaultEffort = value.default; + if (!efforts || (defaultEffort !== undefined && typeof defaultEffort !== 'string')) return null; + return { efforts, ...(defaultEffort !== undefined ? { default: defaultEffort } : {}) }; +} + +function readModelOption(value: unknown): MiniAppAgentModelOption | null { + if (!isPlainRecord(value)) return null; + const { providerId, modelId, displayName } = value; + if (!isNonEmptyString(providerId) || !isNonEmptyString(modelId)) return null; + if (typeof displayName !== 'string') return null; + const variants = value.variants === undefined ? undefined : readStringList(value.variants); + const thinking = readOptionThinking(value.thinking); + if (variants === null || thinking === null) return null; + return { + providerId, + modelId, + displayName, + ...(variants ? { variants } : {}), + ...(thinking ? { thinking } : {}), + }; +} + +/** Allowlist copy of the model catalog; null when any part is malformed. */ +export function readMiniAppAgentModels(value: unknown): MiniAppAgentModels | null { + if (!isPlainRecord(value) || !Array.isArray(value.models)) return null; + const models: MiniAppAgentModelOption[] = []; + for (const entry of value.models) { + const option = readModelOption(entry); + if (!option) return null; + models.push(option); + } + const fallback = readMiniAppAgentModelSelection(value.default); + const host = readOptionalModel(value.host); + if (!fallback || host === null) return null; + return { models, default: fallback, ...(host ? { host } : {}) }; +} + +function readAgentOption(value: unknown): MiniAppAgentAgentOption | null { + if (!isPlainRecord(value)) return null; + const { name, displayName, primary } = value; + return isNonEmptyString(name) && typeof displayName === 'string' && typeof primary === 'boolean' + ? { name, displayName, primary } + : null; +} + +/** Allowlist copy of the agent list; null when any entry is malformed. */ +export function readMiniAppAgentAgents(value: unknown): MiniAppAgentAgents | null { + if (!isPlainRecord(value) || !Array.isArray(value.agents)) return null; + const agents: MiniAppAgentAgentOption[] = []; + for (const entry of value.agents) { + const option = readAgentOption(entry); + if (!option) return null; + agents.push(option); + } + return { agents }; +} + +/** Page `create` payload (`{ agent? }`), checked in the preload and again in Main. */ +export function readMiniAppAgentCreateRequest(value: unknown): MiniAppAgentCreateRequest | null { + if (!isPlainRecord(value) || !isOptionalNonEmptyString(value.agent)) return null; + return value.agent !== undefined ? { agent: value.agent } : {}; +} + +export function readMiniAppAgentCreateResponse(value: unknown): MiniAppAgentCreateResult | null { + return isPlainRecord(value) && isNonEmptyString(value.sessionId) + ? { sessionId: value.sessionId } + : null; +} + +/** Page payload for showing one of the plugin's own sessions in the host chat's side panel. */ +export interface MiniAppAgentOpenRequest { + readonly sessionId: string; +} + +/** Page `open` payload, checked in the preload and again in Main; null when malformed. */ +export function readMiniAppAgentOpenRequest(value: unknown): MiniAppAgentOpenRequest | null { + if (!value || typeof value !== 'object') return null; + const { sessionId } = value as Record; + return isNonEmptyString(sessionId) ? { sessionId } : null; +} + +/** What the Host UI is asked to show: a Mini App session as a tab in `hostSessionId`'s side panel. */ +export interface MiniAppAgentOpenSessionRequest { + readonly hostSessionId: string; + readonly sessionId: string; +} + +/** Copies a Main `open` request for the Host renderer; null when malformed. */ +export function readMiniAppAgentOpenSessionRequest( + value: unknown, +): MiniAppAgentOpenSessionRequest | null { + if (!value || typeof value !== 'object') return null; + const { hostSessionId, sessionId } = value as Record; + return isNonEmptyString(hostSessionId) && isNonEmptyString(sessionId) + ? { hostSessionId, sessionId } + : null; +} + +/** + * Main-to-Host-renderer request, sent after the runtime confirmed ownership. The Host + * preload answers every message with a `MiniAppAgentOpenSessionReply` carrying the same + * `requestId`, so Main learns whether the tab was actually shown (mirrors the composer + * request/reply channel). + */ +export interface MiniAppAgentOpenSessionMessage extends MiniAppAgentOpenSessionRequest { + readonly requestId: string; +} + +/** + * Host renderer's answer to an open request. `target_unavailable`: no Host UI handler is + * installed yet (startup, reload); `result_unknown`: the handler threw. + */ +export type MiniAppAgentOpenSessionResult = + | { readonly ok: true } + | { readonly ok: false; readonly code: 'target_unavailable' | 'result_unknown' }; + +export interface MiniAppAgentOpenSessionReply { + readonly requestId: string; + readonly result: MiniAppAgentOpenSessionResult; +} + +export function readMiniAppAgentOpenSessionReply( + value: unknown, +): MiniAppAgentOpenSessionReply | null { + if (!value || typeof value !== 'object') return null; + const { requestId, result } = value as Record; + if (!isNonEmptyString(requestId) || !result || typeof result !== 'object') return null; + const { ok, code } = result as Record; + if (ok === true) return { requestId, result: { ok: true } }; + if (ok === false && (code === 'target_unavailable' || code === 'result_unknown')) { + return { requestId, result: { ok: false, code } }; + } + return null; +} + +/** Host-renderer-only side-panel channel; never exposed to an embedded page. */ +export interface MiniAppAgentSessionsHostAPI { + /** + * The handler returns whether the tab is now shown; its result (or a throw) is reported + * back to Main, which reports it to the page's `agent.open` and, on success, to the + * runtime as "a panel can answer this session's asks". + */ + onOpenRequest( + handler: (request: MiniAppAgentOpenSessionRequest) => boolean | Promise, + ): () => void; + /** The user closed the tab; the runtime stops waiting on the panel for this session's asks. */ + notifyClosed(request: MiniAppAgentOpenSessionRequest): void; +} + +export interface MiniAppAgentRunResult { + readonly sessionId: string; + /** Absent when no turn ran (an accepted `answer` without a continuation, or an early cancel). */ + readonly turnId?: string; + /** The model the turn was dispatched with; absent when no turn ran. */ + readonly model?: MiniAppAgentModelSelection; + readonly status: 'finished' | 'error' | 'aborted'; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + /** Set when the turn ended waiting on a questionnaire; continue with `answer`. */ + readonly pendingQuestion?: { + readonly requestId: string; + readonly mode: AskQuestionnaireMode; + readonly request: AskQuestionnaireRequest; + }; +} + +/** + * Progress of one run, projected by the Host from its own session stream. Every event but + * `run.queued` names its session so `reply` / `answer` work before the run that created the + * session returns. Consumers must ignore unknown `type` values so the vocabulary can grow + * additively. + */ +export type MiniAppAgentRunEvent = + /** + * The call waits for a run slot at 1-based `position`; sent once, before admission, so + * `sessionId` is present only when the call continues an existing session. + */ + | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'turn.started'; + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + } + | { + readonly type: 'text.delta'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'message.committed'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'tool.started'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + } + | { + readonly type: 'tool.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + readonly ok: boolean; + } + | { + readonly type: 'permission.requested'; + readonly sessionId: string; + /** Absent when the runtime did not attribute the ask to a turn. */ + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; + } + | { + readonly type: 'question.requested'; + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly mode: AskQuestionnaireMode; + readonly request: AskQuestionnaireRequest; + } + | { + readonly type: 'turn.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly status: MiniAppAgentRunResult['status']; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + }; + +export type MiniAppAgentRunResponse = + | { readonly ok: true; readonly result: MiniAppAgentRunResult } + | MiniAppAgentFailure; + +export type MiniAppAgentReplyResponse = { readonly ok: true } | MiniAppAgentFailure; + +export type MiniAppAgentOpenResponse = MiniAppAgentReplyResponse; + +const MINIAPP_AGENT_RUNTIME_ERROR_CODES: Readonly> = { + NOT_PERMITTED: 'not_permitted', + BUSY: 'busy', + SESSION_NOT_FOUND: 'session_not_found', + INVALID_ARGUMENTS: 'invalid_request', + AGENT_UNAVAILABLE: 'runtime_unavailable', + REQUEST_CANCELLED: 'cancelled', + AGENT_OUTCOME_UNKNOWN: 'result_unknown', + REQUEST_NOT_FOUND: 'request_not_found', +}; + +/** Maps a runtime `MiniAppAgentErrorCode`; unknown codes return undefined. */ +export function toMiniAppAgentFailureCode( + runtimeCode: unknown, +): MiniAppAgentFailureCode | undefined { + return typeof runtimeCode === 'string' && + Object.prototype.hasOwnProperty.call(MINIAPP_AGENT_RUNTIME_ERROR_CODES, runtimeCode) + ? MINIAPP_AGENT_RUNTIME_ERROR_CODES[runtimeCode] + : undefined; +} + +export function isMiniAppAgentFailureCode(value: unknown): value is MiniAppAgentFailureCode { + return MINIAPP_AGENT_FAILURE_CODES.some((code) => code === value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isOptionalNonEmptyString(value: unknown): value is string | undefined { + return value === undefined || isNonEmptyString(value); +} + +/** Allowlist copy of a run result; returns null when a required field is malformed. */ +export function readMiniAppAgentRunResult(value: unknown): MiniAppAgentRunResult | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const input = value as Record; + const { sessionId, turnId } = input; + if (!isNonEmptyString(sessionId) || !isOptionalNonEmptyString(turnId)) return null; + const outcome = readRunOutcome(input); + if (!outcome) return null; + // A malformed model is dropped rather than failing a turn that already ran. + const model = readOptionalModel(input.model); + const pending = input.pendingQuestion; + const pendingQuestion = + pending && typeof pending === 'object' + ? readPendingQuestion(pending as Record) + : null; + if (pending !== undefined && !pendingQuestion) return null; + return { + sessionId, + ...(turnId !== undefined ? { turnId } : {}), + ...(model ? { model } : {}), + ...outcome, + ...(pendingQuestion ? { pendingQuestion } : {}), + }; +} + +type MiniAppAgentPendingQuestion = NonNullable; + +/** Reads `requestId` / `mode` / `request` of a run result's or event's questionnaire. */ +function readPendingQuestion(input: Record): MiniAppAgentPendingQuestion | null { + const { requestId, mode, request } = input; + return isNonEmptyString(requestId) && + isMiniAppAgentQuestionMode(mode) && + isQuestionnaireRequest(request) + ? { requestId, mode, request } + : null; +} + +export function isMiniAppAgentQuestionMode(value: unknown): value is AskQuestionnaireMode { + return value === 'questionnaire' || value === 'feature-enable' || value === 'plan'; +} + +/** Envelope check only; the steps are passed through as the Host chat renders them. */ +function isQuestionnaireRequest(value: unknown): value is AskQuestionnaireRequest { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const request = value as Record; + return ( + request.schemaVersion === 2 && isNonEmptyString(request.id) && Array.isArray(request.steps) + ); +} + +function readRunOutcome( + input: Record, +): Pick | null { + if (input.status !== 'finished' && input.status !== 'error' && input.status !== 'aborted') { + return null; + } + if (input.text !== undefined && typeof input.text !== 'string') return null; + let error: MiniAppAgentRunResult['error']; + if (input.error !== undefined) { + if (!input.error || typeof input.error !== 'object') return null; + const { code, message } = input.error as Record; + if (typeof code !== 'string' || typeof message !== 'string') return null; + error = { code, message }; + } + return { + status: input.status, + ...(input.text !== undefined ? { text: input.text } : {}), + ...(error ? { error } : {}), + }; +} + +/** Allowlist copy of one run event; unknown `type`s and malformed events return null. */ +export function readMiniAppAgentRunEvent(value: unknown): MiniAppAgentRunEvent | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const input = value as Record; + if (input.type === 'run.queued') return readRunQueuedEvent(input); + const { sessionId } = input; + if (!isNonEmptyString(sessionId)) return null; + if (input.type === 'question.requested') return readQuestionRequestedEvent(sessionId, input); + if (input.type === 'permission.requested') { + return readPermissionRequestedEvent(sessionId, input); + } + const { turnId } = input; + if (!isNonEmptyString(turnId)) return null; + switch (input.type) { + case 'turn.started': { + const model = readMiniAppAgentModelSelection(input.model); + return model ? { type: 'turn.started', sessionId, turnId, model } : null; + } + case 'text.delta': + case 'message.committed': + return typeof input.text === 'string' + ? { type: input.type, sessionId, turnId, text: input.text } + : null; + case 'tool.started': + return typeof input.name === 'string' + ? { type: 'tool.started', sessionId, turnId, name: input.name } + : null; + case 'tool.finished': + return typeof input.name === 'string' && typeof input.ok === 'boolean' + ? { type: 'tool.finished', sessionId, turnId, name: input.name, ok: input.ok } + : null; + case 'turn.finished': { + const outcome = readRunOutcome(input); + return outcome ? { type: 'turn.finished', sessionId, turnId, ...outcome } : null; + } + default: + return null; + } +} + +function readRunQueuedEvent(input: Record): MiniAppAgentRunEvent | null { + const { sessionId, position } = input; + if (!isOptionalNonEmptyString(sessionId)) return null; + if (typeof position !== 'number' || !Number.isSafeInteger(position) || position < 1) return null; + return { type: 'run.queued', ...(sessionId !== undefined ? { sessionId } : {}), position }; +} + +function readPermissionRequestedEvent( + sessionId: string, + input: Record, +): MiniAppAgentRunEvent | null { + const { turnId, requestId, toolName, toolInput, reason } = input; + if ( + !isOptionalNonEmptyString(turnId) || + !isNonEmptyString(requestId) || + typeof toolName !== 'string' || + typeof reason !== 'string' || + (toolInput !== undefined && typeof toolInput !== 'string') + ) { + return null; + } + return { + type: 'permission.requested', + sessionId, + ...(turnId !== undefined ? { turnId } : {}), + requestId, + toolName, + ...(toolInput !== undefined ? { toolInput } : {}), + reason, + }; +} + +function readQuestionRequestedEvent( + sessionId: string, + input: Record, +): MiniAppAgentRunEvent | null { + const { turnId } = input; + if (!isOptionalNonEmptyString(turnId)) return null; + const question = readPendingQuestion(input); + if (!question) return null; + return { + type: 'question.requested', + sessionId, + ...(turnId !== undefined ? { turnId } : {}), + ...question, + }; +} + +/** + * Both IPC boundaries parse the same envelope. Anything unrecognised is `result_unknown`: the + * turn may already have run, so callers must not retry it automatically. + */ +export function parseMiniAppAgentRunResult(value: unknown): MiniAppAgentRunResponse { + if (value && typeof value === 'object') { + const response = value as Record; + if (response.ok === true) { + const result = readMiniAppAgentRunResult(response.result); + if (result) return { ok: true, result }; + } else if (response.ok === false) { + return readMiniAppAgentFailure(response); + } + } + return { ok: false, code: 'result_unknown' }; +} + +/** Same envelope rules as `parseMiniAppAgentRunResult`; a reply has no result payload. */ +export function parseMiniAppAgentReplyResult(value: unknown): MiniAppAgentReplyResponse { + if (value && typeof value === 'object') { + const response = value as Record; + if (response.ok === true) return { ok: true }; + if (response.ok === false) return readMiniAppAgentFailure(response); + } + return { ok: false, code: 'result_unknown' }; +} + +/** An unknown code is `result_unknown`; a malformed or non-`busy` `reason` is dropped. */ +function readMiniAppAgentFailure(response: Record): MiniAppAgentFailure { + const { code, reason } = response; + if (!isMiniAppAgentFailureCode(code)) return { ok: false, code: 'result_unknown' }; + return code === 'busy' && isMiniAppAgentBusyReason(reason) + ? { ok: false, code, reason } + : { ok: false, code }; +} + +export type MiniAppAgentModelsResponse = + | { readonly ok: true; readonly result: MiniAppAgentModels } + | MiniAppAgentFailure; +export type MiniAppAgentAgentsResponse = + | { readonly ok: true; readonly result: MiniAppAgentAgents } + | MiniAppAgentFailure; +export type MiniAppAgentCreateResponse = + | { readonly ok: true; readonly result: MiniAppAgentCreateResult } + | MiniAppAgentFailure; + +function parseMiniAppAgentResultEnvelope( + value: unknown, + read: (result: unknown) => T | null, +): { readonly ok: true; readonly result: T } | MiniAppAgentFailure { + if (value && typeof value === 'object') { + const response = value as Record; + if (response.ok === true) { + const result = read(response.result); + if (result) return { ok: true, result }; + } else if (response.ok === false) { + return readMiniAppAgentFailure(response); + } + } + return { ok: false, code: 'result_unknown' }; +} + +/** Envelope of the `models` / `agents` / `create` invokes; malformed is `result_unknown`. */ +export function parseMiniAppAgentModelsResult(value: unknown): MiniAppAgentModelsResponse { + return parseMiniAppAgentResultEnvelope(value, readMiniAppAgentModels); +} + +export function parseMiniAppAgentAgentsResult(value: unknown): MiniAppAgentAgentsResponse { + return parseMiniAppAgentResultEnvelope(value, readMiniAppAgentAgents); +} + +export function parseMiniAppAgentCreateResult(value: unknown): MiniAppAgentCreateResponse { + return parseMiniAppAgentResultEnvelope(value, readMiniAppAgentCreateResponse); +} + +/** Same envelope as a reply acknowledgement. */ +export function parseMiniAppAgentOpenResult(value: unknown): MiniAppAgentOpenResponse { + return parseMiniAppAgentReplyResult(value); +} + +/** + * Hoisted out of the omitted window block, which declares this guard between its + * own rules; the agent parsers above need it. Nothing else from that block is here. + */ +function isPlainRecord(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/packages/webui/docs/adr/0020-webui-miniapp-view-and-runtime-seams.md b/packages/webui/docs/adr/0020-webui-miniapp-view-and-runtime-seams.md index 003cce85e..81b6b2a1e 100644 --- a/packages/webui/docs/adr/0020-webui-miniapp-view-and-runtime-seams.md +++ b/packages/webui/docs/adr/0020-webui-miniapp-view-and-runtime-seams.md @@ -1,16 +1,16 @@ # WebUI Mini App view and its runtime seams -**Status:** Accepted as product direction; implementation pending. -**Date:** 2026-10-20. +**Status:** Product direction accepted and implemented in the Mini App hosting stack. +**Date:** 2026-10-20, revised 2026-10-26. The WebUI will host Mini Apps. A Mini App's page is rendered as an iframe the client loads from a loopback origin that the WebUI service proxies. The Mini App's own page bridge — the `window.mcode.miniapp.*` namespace a Mini App page uses to call its host — is provided by a -browser rewrite of the desktop client's preload bridge, injected by that same proxy. +browser build of the desktop client's preload bridge, injected by that same proxy. -There are two presentations. **Inline**, the Mini App view occupies a view inside the WebUI -workspace panel. **Standalone**, a browser tab is opened through a URL deep link; the tab runs the -full WebUI client, but the only content it shows is the Mini App view. The standalone view has no +Both presentations belong to the client. **Inline**, the Mini App view occupies a tab inside the +WebUI workspace panel. **Standalone**, a browser tab is opened through a URL deep link; the tab runs +the full WebUI client, but the only content it shows is the Mini App view. The standalone view has no in-page close control: the tab is the Mini App's own. The deep link selects a session and a Mini App, and the service injects its runtime configuration into every page it serves, so a tab opened this way is an ordinary client with the same credential as any other. @@ -24,10 +24,10 @@ client is three capabilities, and each has a browser-side counterpart. 1. **Injecting the loopback credential.** The desktop client injects the Host-issued credential into the Mini App's own requests with `session.webRequest.onBeforeSendHeaders`. A browser - cannot add a header to an iframe navigation or to the page's subresources, so where the - contract keeps the credential guard, a Mini App reached directly is rejected. The WebUI - service therefore proxies the Mini App's origin, and injects the credential there; whether it - injects one at all follows from the contract baseline below. + cannot add a header to an iframe navigation or to the page's subresources, so a Mini App + reached directly is rejected by the guard. The WebUI service therefore proxies the Mini App's + origin, and injects the credential there. The guard is carried over into this repository as part + of this port, so the proxy injects into a contract that exists here rather than one it invents. 2. **Injecting the page bridge.** The desktop client injects the bridge with a preload (`contextBridge.exposeInMainWorld`). A parent page cannot script a cross-origin iframe, so the bridge cannot be installed from the client. The proxy rewrites the Mini App's HTML and injects @@ -47,6 +47,17 @@ sandboxed; its isolation comes from origin separation, not from a sandbox attrib opposite of the workspace file preview, which deliberately renders an untrusted document in a sandboxed, opaque-origin iframe. +The bridge travels to the page through the proxy's HTML rewrite, and its calls travel from the page +to the client over `postMessage`. The client is the host of that conversation: it validates that the +message's source is the iframe it created and that its origin is that Mini App's origin. The obvious +alternative — the proxy exposing the bridge's endpoints on the Mini App's own origin, so the page can +`fetch` them same-origin — is rejected and recorded here so it is not re-proposed. The guard strips +the credential from the page's own requests before plugin code sees them, so those endpoints would +carry no identity of their own: any local process that computes the Mini App's deterministic port +could drive a host-authorised call through them, which is a strictly larger hole than the one the +guard closes. Closing it would need a second per-page secret, and the page and its host would then +hold two credentials instead of one. + ## What is not carried over - **Native window semantics.** The desktop client's detached presentation is a separate operating @@ -54,16 +65,101 @@ sandboxed, opaque-origin iframe. and persisted window bounds. A browser tab cannot express any of them. The WebUI keeps the part that a browser can honour — the Mini App alone on screen, and a tab the user can drag to a second display — and drops the native window options. -- **The page-side window handle.** `window.mcode.miniapp.window` and its `inline`/`window` mode - switch have no browser equivalent that is worth carrying. The standalone view is a presentation - the client chooses, not a mode a Mini App page can request. +- **The page-side window handle.** Dropped from the contract rather than emulated, because the + reason is stronger than "the browser cannot". The shipped generation adds a third part to the + bridge namespace — `window.mcode.miniapp.window` with `getState`, `update` and `focus`, a manifest + `surface.window` whitelist of size, `frame`, `transparent`, `alwaysOnTop`, `clickThrough` and + `skipTaskbar`, and a Chromium argv surface id — and that block is the page asking for its own + native window, bounds and visibility. Beyond the browser's inability to express any of it, the + block grants the page a say in *how it is presented*, and presentation is this client's decision. + A Mini App appears in its own tab because the client opened one, never because the page asked. - **Host-to-page injection.** The desktop client can inject into an embedded page: the agent cursor overlay and the comment bridge. A cross-origin Mini App page cannot be reached that way, so a Mini App page does not get them. - **Moving one live page between presentations.** The desktop client moves the same `WebContents` into a window and back into the File Panel, so the page is one instance with one state. A browser tab is a separate document. A Mini App shown in two places is two documents: - server-side state is shared, in-page state is not. + server-side state is shared, in-page state is not. The two presentations are therefore two client + documents, and the service keeps the record of which one is live. + +## The contract baseline + +The Mini App contract in this repository was one generation behind the Mini App implementation +shipped in the desktop client: 54 lines here against 1267 there. This record takes the shipped +generation, minus the window block. + +The mechanics are cheap, and the borrow is auditable rather than a blanket import of internal code. +The shipped file's only import is +`./questionnaire.js`, and this repository's copy of that file is byte-identical, so the borrowing is a +content edit to one existing file: no new path, no workspace dependency, no inventory regeneration. +`diff` shows the newer generation to be a strict superset of this repository's file from line 7 +onward, and it is taken as one contiguous copy of the shipped leading part rather than as scattered +patches — but that part is not line-for-line identical to today's 54 lines. The shipped file opens +with a six-line type-only import, and it adds `detached` and `presentationRevision` to the tab +summary and `detach_failed` and `attach_failed` to the error codes, which moves +`MiniAppOpenResponse` and `MiniAppControlResponse` below the new start and page-open types. Nothing +here has a consumer for the response types whose shape changes — `MiniAppSurfaceTabSummary`, +`MiniAppListResponse`, `MiniAppOpenResponse` and `MiniAppControlResponse` are declared and unused — +so adding required fields and error codes breaks no call site. + +What the borrowing brings: + +- The split between starting a Mini App and opening its page: `MiniAppStartResponse`, + `MiniAppPageOpenInput`, `MiniAppBackgroundOpenInput` and one `MiniAppOpenApi` with two overloads. + This client needs it as soon as it prewarms a Mini App the way the desktop client does, and the + runtime seam below depends on it. +- `detached` and `presentationRevision` on the tab summary, plus the `detach_failed` and + `attach_failed` codes. In this client they are not dead fields: `detached` reports whether a + standalone presentation is live, and the revision increments on each acquisition. +- `MINIAPP_LOOPBACK_TOKEN_HEADER`, the name of the credential the proxy injects. +- The composer block: the request, the reply, the bounded text limit and the one validator both ends + of the bridge share. +- The agent block: every request and result shape, the nine run events, the failure codes and their + mapping from the runtime's own codes, and the allowlist validators the shipped preload and broker + run on both sides of the boundary. + +What the borrowing leaves behind is the window block: the argv surface id, the `surface.window` +manifest whitelist, and the window state and patch contracts that the page's +`window.mcode.miniapp.window` handle (`getState`, `update`, `focus`, defined in the preload rather than +here) is built on — `MINIAPP_SURFACE_ID_ARGUMENT_PREFIX`, `MINIAPP_SURFACE_WINDOW_*`, +`MiniAppWindowState`, `MiniAppWindowPatch` and their parsers. It is the last contiguous +region of the file, so a future synchronization reads as one omitted tail rather than as a series of +local divergences. One private helper has to come out of that tail: `isPlainRecord` is declared +inside the window block but used nine times by the agent parsers above it, so it is kept at the seam +between the copied part and the omission, and it is the one local divergence to reconcile against a +future copy. + +Two consequences follow for the layers around the contract. The manifest reader must accept the +three keys the newer generation declares and this repository's reader does not know: `surface.window`, +`agentAccess` and `agentConcurrency`. Its field sets are strict, so an unknown key fails the package +as `MANIFEST_SCHEMA_INVALID` — a package built for the shipped client is rejected before any of this +work can matter. Accepting is not supporting: `surface.window` is read and ignored here, because its +effect was just dropped from the contract, while the two agent fields are validated and preserved here +so the agent service can enforce them when it lands. + +## The credential guard is carried over + +The shipped client guards the Mini App's own HTTP server from inside the Mini App's process: the +runner patches `http.Server.prototype.emit` before the plugin entry is imported, so any server the +plugin creates is covered. A request event must carry the exact loopback `Host` and a timing-safe +match of the credential header, or it is answered 401 with no body; `upgrade` and `connect` sockets +are destroyed instead. On success the header is removed from both `headers` and `rawHeaders`, so +plugin code never observes it. One reserved path is answered 204 by the guard itself, which is what +lets the readiness probe prove the guard is in the path rather than merely that a socket accepts: +readiness becomes "anonymous must be 401, credentialed must be 204" instead of a TCP connect. + +This port carries that guard. The previous revision treated it as an option, on the grounds that +having no guard is not worse than having no Mini App service at all. That comparison holds only while +the service is off. The moment this client's row enables it, the exposure is new, and it is not +theoretical: the Mini App's port is deterministic — `49152 + sha256(pluginId)` across the range, with +a persisted preferred port first — so a local process can compute it rather than scan for it. A +credential is also the only thing that can distinguish the proxy's own requests from those of every +other local process, and the proxy is the only party that can inject it. + +The cost is bounded and lands in the runtime: the guard, one credential per process generation +threaded through the runner's init message, the supervisor's launch credential so the proxy has +something to inject, and the readiness change above. The credential never reaches the page, and it +never reaches the client: the proxy holds it. ## Runtime seams @@ -79,76 +175,295 @@ new domain behaviour; they expose what the runtime already owns. already drives the other owner-derived service gates — `wiring.cronService`, `wiring.channelService`, `wiring.schedulerHost` — from that one descriptor; the Mini App gate is decided outside it, which is why a change to this client's row cannot reach it. The Mini App - service becomes a wiring cell, this client's row carries it, and `resolveRuntimeMiniAppOptions` - reads the cell rather than the raw kind string. Two consequences follow. The explicit Mini App - options the function already accepts become reachable for every row that carries the cell, not - only the Electron row. And the two places that read the function — the service composition and - the `miniappAvailable` capability flag — are driven by the same cell, so the flag can no longer - disagree with the service. + service becomes `wiring.miniAppService`, this client's row carries it, and both places that read + the function — the service composition and the `miniappAvailable` capability flag — read the cell + instead, so the flag can no longer disagree with the service. + + The cell carries one trap. Every row must state it as a literal: the existing + `ownsElectronRuntimeCapabilities` predicate answers true for an owner that declared no kind, and a + service enabled for an absent owner is a behaviour change no client asked for. This is also the one + owner-derived gate where the Mini App service is stricter than cron or channels. The explicit + Mini App options the function already accepts stay reachable for every row that carries the cell, + because a test builds a runtime with them and no declared kind and expects the supervisor to exist; + `resolveRuntimeMiniAppOptions` keeps that override and takes the cell as its other input. The owner + policy's own suite pins the wiring key set and the count of cells where this client's row differs + from `tui`, so both move with the cell. - **The control plane enters the harness as a forwarding facade.** The desktop client keeps the Mini App control plane in its Electron main process, behind IPC channels that reach a utility broker; there is no Mini App method on `CliService` there or here. The runtime already owns the behaviour in `packages/local-runtime-v2/src/application/session/process-local-miniapp-application.ts`, which - lists and opens Mini Apps. The client entry is a forwarding method on `CliService` over that + lists, opens and stops Mini Apps. Half of that already crosses: the management operation's `listApps` + action resolves to this application's `list`, so the client receives rows with their runtime phase as + soon as the service exists. Only `open` and `stop` still need a client action. The client entry is a + forwarding method on `CliService` over that application, bound as a WebUI operation in the usual five places. The WebUI does not reimplement Mini App lifecycle logic in its server, and it does not reach around the facade into the application bag. + + Two additions to the application follow from the other decisions here. It needs a start that does + not open a page: this client prewarms a Mini App without showing it, which the contract expresses as + `openPage: false` and which the shipped client reaches through a start-if-cold path rather than + through `open`. And it needs to hand the credential and origin to the + server: `launch` is documented as server-only coordinates that must never be forwarded to a + renderer, and this design honours that literally. The client receives the proxy's own view URL, + including the runtime-selected surface path needed to load the page, but never the Mini App's + origin or its credential. Everything that touches a Mini App's real origin stays on the server + side of the facade. - **The presenter stays a host-owned port.** `MiniAppPresenter` is documented as the desktop client's native presenter and is absent outside it. The WebUI supplies the browser implementation: its `open` means the client should show that Mini App, and its `close` means the client stops showing it. The port's shape — open and close by session and plugin — is already platform-neutral, so the browser implementation fits it without changing the contract. -## The contract baseline is an open question - -The Mini App contract in this repository is one generation behind the Mini App implementation -shipped in the desktop client. The facts, verified against both: - -- `packages/shared/src/miniapp-surface.ts` here is 54 lines. The shipped client's is 1267, and adds - the loopback credential header (`x-mavis-miniapp-token`), the page-bridge contracts for the - composer, the agent and the window handle, and the split between starting a Mini App and - opening its page. -- The shipped client guards the Mini App's own HTTP server: every request must carry the - Host-issued credential and the exact loopback host, or it is answered 401, and the same guard - covers WebSocket upgrades. This repository's runtime has no such guard. -- This repository and the public upstream are identical on these files, and the newer contract - appears nowhere in the upstream tree. It exists only in the internal tree the desktop client was - built from, so no upstream synchronization will deliver it. - -This matters because the WebUI shares the data directory with the desktop client (ADR 0006), so -the Mini App packages installed on a machine are the newer generation, and their pages call the -newer page bridge. The bridge is therefore required for those packages to work. The credential -guard is a separate question: without it the Mini App's loopback port answers any local process -that finds it, which is weaker than the desktop client but is not worse than this repository's -current behaviour, which is to have no Mini App service at all. - -The options are to forward-port the page-bridge contracts and the browser bridge only, leaving the -guard for later; to forward-port the whole contract including the runtime guard, at the cost of -bringing an unpublished internal contract into this repository ahead of upstream; or to build the -carrying surface first and settle the contract when the work starts. The bridge rewrite cannot be -finished before this is settled, because the API surface it must implement is defined by the -contract. This ADR records the question; it does not settle it. - -## Open implementation questions - -1. **The contract baseline.** Which of the three options above is taken, and therefore whether the - proxy injects a credential at all. -2. **The bridge rewrite.** Which page-bridge methods the browser build implements, which - error codes it reproduces exactly, and how the agent request identifiers, the permission reply - and the questionnaire answer path cross the `postMessage` boundary. -3. **The proxy's own shape.** Its loopback origin and route layout, what it forwards, how it - handles the Mini App's WebSocket connections, and which response headers it must drop so the - page can be framed at all. -4. **The entry point.** Where a user opens a Mini App from in this client. The plugin business - already lists installed plugins; a Mini App entry needs its own home, and the composer and - agent paths that open Mini Apps in the desktop client need a counterpart. -5. **The standalone view's session semantics.** Which session the deep link selects, what the view - shows when that session has ended, and how the tab behaves when the Mini App's runtime stops - while the tab is open. -6. **The inline view's home.** Which workspace panel view hosts the Mini App, how it coexists with - the file tree, the preview and the terminal, and what happens to a Mini App open when the panel - is closed. + The ordering matters more than it looks. The agent tool's own `open` action throws + `OPEN_UNAVAILABLE` when the runtime has no presenter, while `list`, `init`, `publish`, `restart` and + `stop` start working the moment the service cell is flipped. Enabling the service before the + presenter exists would therefore ship a half-live capability: the agent could publish and start a + Mini App that nothing can display. The presenter, the view it opens and the cell belong to one + change. + +## The agent capability is in scope + +A Mini App page's agent calls are part of this port. They are also the largest piece of it, and the +one place where the work is not a client-side rewrite of something the runtime already does. + +The acceptance standard here is parity with the desktop client's page: an installed Mini App must +render and work in this client as it does there. A page that probes the bridge when it loads must not +find a method missing, because the visible result is a dead panel rather than a graceful degradation. +That is why the composer alone is not a smaller first version of this decision — it is a different, +insufficient one. + +The shipped client's agent path is a runtime capability this repository does not have: a Mini App +gateway that owns per-plugin arenas, queues and turn lineage; a session-ports layer through which a +plugin creates and drives host sessions; a projection that decides what a plugin may see of them; the +application facade that fronts all of it; and the child-side client that the plugin's own process +calls. None of those files exist here, and the plugin manifest fields that gate them — +`agentAccess` (only `session.run` today) and `agentConcurrency` — are rejected by this repository's +reader as unknown keys. + +The connector's wire has to move with it, and the reason is not a preference. In this repository the +host connector *is* the child's stdout: the child's stdout is the request stream and its stdin the +response stream, framed as strict NDJSON. When the connector is disabled, a single byte on stdout +aborts the whole Mini App runtime as a protocol violation; when it is enabled, the same byte corrupts +a frame. The child does already have a parent channel — every Mini App is spawned with a fourth IPC +file descriptor, and the runner's own comment says what it is for: that channel controls lifecycle, +while the standard streams are reserved for the connector. The shipped runtime keeps the channel and +changes what rides on it: connector frames are multiplexed onto the same descriptor by message type, +the child's stdin is dropped entirely, and stdout and stderr become logs. An agent-enabled Mini App +logs — it runs turns, tools and model calls — so the two protocol families cannot keep sharing one +stream. Adding a second channel for agent calls alone while leaving tool calls on NDJSON would still +break the tool channel on the first log line, and it is a shape the shipped client never had, so +nothing upstream would ever confirm it works. The connector therefore moves onto the existing +channel for every Mini App, not only the agent-enabled ones: one channel carries both message +families, told apart by the message type, the child's stdin stops being used at all, and stdout and +stderr become logs. That is this record's decision rather than an assumption, and the +dual-transport alternative stands rejected for the reason just given. + +What that costs, measured against the shipped package rather than estimated: roughly 2900 lines of +runtime service (gateway, session ports, contracts, projection, model selection), a rewrite of the +host connector from 1700-odd lines across four files into 2900 across six, the transport binding +itself, and a new application file. It is the single largest forward port in this record, and it +carries its own review: the connector is shared with tool calls, so a defect there is not confined to +Mini Apps. + +One surface inside it has no counterpart that looks like the shipped one: a page's `open` call. It is +not a cosmetic button. In the shipped runtime the host chat's side panel is what makes a +Mini-App-created session's permission asks answerable — the gateway denies an approval for such a +session unless some host chat is currently showing it. This client has no side panel, and the workspace +panel cannot be one: it hosts the selected session's own resources, while the client renders one +selected session at a time, so a second transcript beside the first would need a second conversation +surface rather than one more tab kind. + +The mapping is the session rail plus the standalone presentation. `open` selects that session through +the client's ordinary selection path; while it is the selected session the service reports the panel as +showing it, and switching away or closing it reports the panel as closed. `hostSessionId` is the +session whose workspace panel carries the inline view — or, for a standalone tab, the session its deep +link carried. The arrangement a person gets is not the shipped client's side-by-side chat and panel: +selecting the Mini App's session replaces the workspace panel, and with it the inline view. That is why +the standalone presentation belongs to this decision rather than following it — with the Mini App in its +own tab, the rail and the page are on screen at once, which is the nearest equivalent of the shipped +arrangement. + +## Where the inline view lives + +The workspace panel's tab content is a closed union: a tab is a stable client id plus a content +record, and the record's kind fixes the renderer. A Mini App becomes one more member — +`{kind: 'miniapp', sessionId, pluginId}` — and the cost is the union's six touch points: the content +union and its kind table, the command that opens a tab with a payload, the reducer case, the render +branch, the tab strip's glyph, and the tile on the launcher page. That last one is the entry point: +this client's "add a tab" surface already is a page of tiles, which is the counterpart of the desktop +client's File Panel "+", and the tile leads to the plugin's picker. + +Two properties of that picker are worth stating. Its rows and the plugin catalogue's apps area are the +same rows: the existing `listApps` action already resolves to the runtime's Mini App listing, phases +included, and answers an empty list today only because the service is gated off. So the catalogue is +missing a surface and a launch action, not data. And the capacity answer is real: three Mini Apps run +at once, so opening a fourth has to name the running ones and let the user stop one. + +The plugin catalogue is the other half of the entry, and it belongs to this version: a person installs a +Mini App as a plugin, so the surface that installs plugins has to show what is installed. That area +owns the list and the lifecycle — install, uninstall, enable, disable, stop, and the open action — while +the workspace panel owns the display. Rows there carry runtime phases, so the same surface names what +is running without a second query. + +The view component itself belongs to the Mini App business, which publishes it through a +`*.public.tsx` module — the same crossing the browser panel uses to mount a live-stream view in the +panel. The panel stays the layout owner and learns nothing about Mini App state. + +Two behaviours follow from the panel's own rules rather than from this decision. The panel's state is +per session and refuses to exist without one, so a Mini App tab belongs to a session, and closing the +panel hides the tab without destroying its place in the state. The iframe does unmount, though, and +with it the page: a tab is a presentation, while the runtime is plugin-scoped. Reopening the panel +points the tab at the same origin again, so the Mini App's server-side state is intact and its +in-page state is gone. That is the same consequence as moving a page between presentations on the +desktop client, and it should be stated in the tab rather than discovered. + +## The client's Mini App is one business + +Everything this client knows about Mini Apps is one business: the view, the picker, the apps list, the +presentation lease and the host side of the bridge. It qualifies on the same grounds every other +business here does — it has a lifetime of its own per session, state of its own (which Mini Apps are +open, what phase each one is in, which presentation lease is live, which bridge requests are pending), +several surfaces, and a protocol of its own. Splitting it would put per-session Mini App state and a +`postMessage` protocol inside the plugin catalogue, whose lifetime is installing and enabling packages, +or inside the workspace panel, whose lifetime is layout. + +Three crossings, each one line wide: + +- The workspace panel mounts the published view for its `miniapp` tab kind, the way it mounts the + browser panel host today. +- The plugin catalogue mounts the published apps list for its apps area and holds no Mini App state of + its own. +- The business dispatches the workspace panel's own tab command to open a view; it never reaches into + the panel's state. + +Two properties keep that boundary honest. A tab's existence stays in the workspace panel state, because +that is layout, while the Mini App business owns the runtime and bridge facts about that tab, because +that is capability. And the view URL is produced by the service and returned by the open operation: the +client never assembles a Mini App origin and never carries its credential, which is the rule the +workspace file preview already follows. + +## The standalone presentation + +The client router resolves exactly two outcomes today — `/` and `/archon` both to the client, anything +else to 404 — and the session travels in the `#session=` hash. A standalone Mini App is +`/miniapp?plugin=&session=`, which the service maps to the same document, so the runtime-configuration +injection applies unchanged and the tab is an ordinary client. The shell needs one new concept to +serve it: a presentation mode in which the full client renders only the Mini App view. That is a +smaller change than the desktop client's, which has to move a native view; here it is which component +the shell mounts. + +The link that opens it is produced by the inline tab, which carries the action that moves this Mini App +into its own browser tab; nothing else can produce a deep link a person would not type. The standalone +view offers no control back to the inline one — that would be the in-page close control it deliberately +does not have — so returning is closing the tab, which releases the lease. + +The page is independent of the session named by its route: a confirmed missing session is reported as +ended while the Mini App still shows. If session lookup fails for another reason, the page reports +that the state is unresolved rather than claiming the session ended or dropping the presentation. +A runtime that stops while the tab is open clears the view and reports the stop reason; it does not +restart the Mini App, because the tab may be unattended and a retry would start work the user did not +ask for. A retry is a user action. + +### The standalone presentation is a lease + +Because the two presentations are two client documents that share nothing but the service, the service +holds the record of which one is live. A standalone client declares its plugin, its session and its +presentation when it connects, and the record is released when that client's socket closes. If the +watch connection drops unexpectedly while the document is still open, the client reconnects with +backoff and declares a new lease; this restores presentation ownership but never starts the Mini App +runtime. Closing the tab stops the watcher and releases the lease. The inline view reads the record, +which is what lets it show that a Mini App is already open elsewhere and offer to switch instead of +opening a second tab. + +That record is also what gives `detached` and `presentationRevision` meaning in this client, rather +than leaving them as fields this port always answers with `false` and `0`. `detached` is "a standalone +lease exists for this plugin"; the revision increments each time a lease is acquired, which is the +same property it has on the desktop client — a fast detach and attach that a poll would otherwise miss. + +## The proxy is one listener per Mini App + +A Mini App's page refers to its own origin by absolute path — the scaffold serves `/dashboard`, and a +real page also fetches `/api/…`, its assets and relative URLs for the rest. A single proxy origin with +a per-Mini-App path prefix would rewrite exactly the URLs the page asks for, and patching them back +means intercepting `fetch`, `XMLHttpRequest` and every absolute URL in the document. The proxy is +therefore one loopback listener per running Mini App: paths are forwarded unchanged to the Mini App's +origin, the credential is injected on the way out, the document is rewritten on the way back to carry +the bridge, and WebSocket upgrades are forwarded under the same rules. At most three Mini Apps run at +once, so at most three listeners exist, which is also how the runtime already gives each Mini App a +port of its own. That listener's port is allocated per plugin rather than in order of opening, for the +same reason the runtime's own is deterministic: the page's origin is what keys its `localStorage` and +`IndexedDB`, so a port that moved between runs would silently discard the page's own state. + +## One owner per Mini App across clients + +The WebUI and the desktop client share a data directory (ADR 0006), and a Mini App's port is derived +from its plugin id, so without a rule both clients compute the same first port and the second one +starts a second process over the same plugin data. This record decides ownership rather than +arbitration: **one client owns a Mini App at a time, and the other refuses to start it.** + +Ownership is per plugin, not global, so one client can own one Mini App while the other owns a +different one. The owner is whatever starts the runtime first, recorded in the shared data directory +with a liveness proof that the operating system releases when the owner dies, rather than a timestamp +alone — a stale record must be reclaimable, and a live one must not be taken by mistake. + +That proof is an exclusively bound endpoint per Mini App, in its own plugin data directory: a socket +file on POSIX and a named pipe on Windows, named from the plugin id the way the OAuth lease endpoint is +named from its data directory (`packages/oauth-lease-protocol/src/endpoints.ts`). Binding is the +ownership: a second bind fails while the owner lives, the operating system releases it when the owner +dies, and the record beside it states which client holds it and since when. The endpoint is never +unlinked before binding — unlinking first would steal a live owner's endpoint, which is the mistake the +OAuth lease server is entitled to make with its own path and this record is not. + +The shipped desktop client writes no such record, so the record cannot be the only signal. For anything +already running, including that client, the signal is the Mini App's own port: the client probes the +derived port before starting, and a listener that answers an unauthenticated request the way the guard +does means the Mini App is already up somewhere. The client then refuses with the same message instead +of spawning a process that cannot bind, or worse, one that can. + +The user-visible result is a row and a picker entry that say another client is running this Mini App, +with the open action disabled. The consequence is accepted and stated rather than hidden: a Mini App +running in the desktop client cannot be shown in the WebUI, because the credential that reaches it +never leaves the process that minted it. What the rule buys is the opposite guarantee — only one process +per Mini App ever writes its plugin data. + +## Delivery order + +The order is fixed by three dependencies rather than by preference. The contract arrives first, +because the bridge and the operations are typed against it. The guard arrives before the proxy +injects anything, because a proxy that injects a credential into a service with no guard proves +nothing. The service cell, the presenter and the view land in one change, for the half-live reason +above. The plugin catalogue's apps area rides with that change rather than after it: it is the surface +that shows what is installed and what is running, so without it the view has nothing to be opened from. The agent capability follows the view: it is the only part that can be reviewed against a +working page, since a plugin's agent calls are only observable from a page this client can display. + +### The proxy makes the document framable + +The page is framed by this client, on a different origin, so a page must not be able to refuse. The proxy +therefore drops `X-Frame-Options` from every response it forwards and removes the `frame-ancestors` +directive from a page's own CSP while leaving its other directives alone — the Mini App's origin is a +separate origin from the client's, so the rest of its policy governs a document that cannot reach the +client anyway. Nothing is added: no header the page did not ask for, no CSP of the proxy's own. + +The document is the one response the proxy rewrites. The page bridge has to be in the document before the +page's own scripts run, so the proxy injects a script tag for it before ``, or after the opening +`` tag when a page has no head, or ahead of the document when it has neither, and restates the +`Content-Length` because the body it sends is not the body it received. Everything else — paths, methods, +status codes, the page's own headers — passes through unchanged. + +## Bridge request and error behavior + +The bridge preserves the page's `requestId` through the `postMessage` request, the host operation +and the response or event, so concurrent calls settle only the promise that started them. The host +validates the source frame and its origin before it accepts a request. Malformed payloads answer +`invalid_request`; a duplicate in-flight request id answers `busy`; a page without a host session +answers `not_permitted`; and a runtime without the Agent capability answers `runtime_unavailable`. +For supported calls, the service's documented failure code is preserved. If transport fails after a +turn or call may have taken effect, the bridge answers `result_unknown` rather than inviting an unsafe +retry. The shared contract and runtime Agent contract define the accepted failure codes and the +`busy` reasons (`packages/shared/src/miniapp-surface.ts` and +`packages/local-runtime-v2/src/service/miniapp/agent/contracts.ts`). ## Research findings @@ -179,3 +494,70 @@ contract. This ADR records the question; it does not settle it. (`packages/local-runtime-v2/src/services.ts`, `packages/local-runtime-v2/src/service/llm-context-inspector/initialize.ts`, `packages/webui/docs/known-gaps.md` §2). +- The shipped contract is a strict superset of this repository's: `diff` reproduces the 54 lines here + as its first 54, its only import is `./questionnaire.js` which is byte-identical here, and its build + output carries no `require` at all. +- The shipped guard patches `http.Server.prototype.emit` before the plugin entry is imported, covers + `request`, `checkContinue`, `checkExpectation` and destroys `upgrade` and `connect` sockets, strips + its own header from `headers` and `rawHeaders` on success, and answers one reserved health path with + 204 so readiness proves the guard is in the path. The shipped readiness probe is + "anonymous must be 401, credentialed must be 204"; this repository's is a TCP connect + (`.../service/miniapp/process/node-runtime/node-runtime.ts`, `host-runner.ts`). +- The credential is minted per process generation and threaded through the runner's init message; the + supervisor exposes it as a launch credential, and the application's `launch` object is documented as + server-only: "Never forward this object to a renderer" + (`packages/local-runtime-v2/src/application/session/process-local-application-contract.ts`). +- The application exposes only `list`, `open` and `stop` here; the shipped generation adds a + read-only `credential` and returns the credential with `open`. +- The shipped runtime's agent path is a whole absent directory here — + `.../service/miniapp/agent/` (contracts, gateway, session ports, projection, model selection, + roughly 2900 lines without tests) — plus `process-local-miniapp-agent-application.ts`, and the + plugin manifest fields that gate it. +- Every Mini App is a separate child process: the node runtime spawns one with a fourth IPC file + descriptor, and at most three run at once. In this repository that descriptor carries lifecycle + messages only (`miniapp:ready`, `miniapp:error`, `miniapp:stopped`, stop and parent-loss + handling) and the runner states in its own comment that the standard streams are reserved for the + connector wire; the connector reads the child's stdout and writes its stdin as strict NDJSON, and + the runtime aborts on any stdout byte while the connector is disabled + (`packages/local-runtime-v2/src/service/miniapp/process/node-runtime/host-runner.ts`, + `node-runtime.ts`). The shipped runtime uses the same descriptor for both, distinguished by + message type, drops the child's stdin, and logs stdout and stderr instead. +- The shipped manifest reader allows `agentAccess`, `agentConcurrency` and `surface.window`; this + repository's allows none of the three and fails unknown keys as `MANIFEST_SCHEMA_INVALID` + (`packages/local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.ts`). +- A runtime can be built with explicit Mini App options and no declared owner kind, and the supervisor + is expected to exist, so the explicit-options override survives the wiring-cell change + (`packages/local-runtime-v2/src/services.test.ts`). +- The owner policy's suite pins the wiring key set and the count of cells where this client's row + differs from `tui` (`packages/local-runtime/test/unit/runtime-owner-policy.test.ts`). +- The workspace panel's tab content is a closed union, the open-tab command admits three kinds today, + the panel's state is per session and refuses to exist without one, and another business mounts into + the panel only through a `*.public.tsx` module + (`packages/webui/src/client/features/workspace/projection/workspace-panel-state.ts`, + `packages/webui/src/client/features/workspace/contracts/panel-command.ts`, + `packages/webui/src/client/features/browser/ui/BrowserPanelHost.public.tsx`). +- The client router resolves `/` and `/archon` and nothing else, and carries the session in a hash + (`packages/webui/src/client/projection/common/router.ts`); the service maps those paths to the same + document (`packages/webui/src/server/http/router.ts`). +- The server holds no outbound HTTP client, no HTML rewriting except the runtime-configuration + injection into `index.html`, and no response-header policy except the workspace file route's + `Content-Security-Policy: sandbox` (`packages/webui/src/server/http/`). +- The plugin business lists Mini Apps already and has no surface to open one: the listing area exists + in the workflow and is reachable only programmatically, and its `listApps` action already resolves to + the runtime Mini App application's `list`, so rows arrive with a runtime phase the moment the service + exists — the empty answer today is the gate, not the query + (`packages/webui/src/client/features/plugins/application/plugin-workflows.ts`, + `packages/local-runtime-v2/src/application/cli-management-application.ts`). Plugin install, + uninstall, enable and disable are existing management actions, so installing a Mini App adds no + plumbing. +- Only one thing rides the child's standard streams today: the host connector's three frames + (`connector.list`, `connector.call`, `connector.cancel`), which give a Mini App access to + host-provided tools under host-held credentials with an allowlist, a schema-checked call and an + audited result. stderr is already logs — a bounded tail feeds startup-failure diagnostics — and a + byte on stdout aborts the runtime whenever the connector is disabled + (`packages/local-runtime-v2/src/service/miniapp/process/host-connector/host-connector.ts`, + `process/node-runtime/node-runtime.ts`, `node-runtime-startup-error.ts`). +- The Mini App service here carries no tests of its own: `packages/local-runtime-v2/src/service/miniapp` + holds no `*.test.ts` file and `test/vitest-suites.json` registers no Mini App suite, while the + shipped package carries its connector, gateway and session-port suites with it. A forward port + therefore arrives with a regression net to adopt and register rather than with a gap to fill. diff --git a/packages/webui/docs/architecture/design.md b/packages/webui/docs/architecture/design.md index 3c134490e..7188ec0bc 100644 --- a/packages/webui/docs/architecture/design.md +++ b/packages/webui/docs/architecture/design.md @@ -191,13 +191,13 @@ One declaration, three consumers, and a compiler that keeps them agreeing: can choose a member: group, method and operation name are fixed in the declaration, and the argument tuple and the result mapper are checked against that method's parameters and the operation's response type. No `any`, no assertion, no non-null assertion — a wrong group, method, tuple or - mapper fails to compile. The thirteen operations that shape a response, degrade when a capability is + mapper fails to compile. The sixteen operations that shape a response, degrade when a capability is absent, or carry a stream, terminal or command shape keep hand-written handlers in `src/server/operation/handlers/`. 4. **`src/runtime/port.ts`** declares `WebuiHarnessPort`: one method per operation, typed with the `shared/contracts/.ts` DTOs, and implemented under `src/runtime/harness/` by `createHarnessPortFromHost`. `bind-handlers.ts` derives its `Pick` from the port, so the roster and - the registry cannot drift: the counts are pinned by tests (86 bindings, 13 handlers, 99 operations), + the registry cannot drift: the counts are pinned by tests (93 bindings, 16 handlers, 109 operations), and a change that adds or drops one stops `pnpm typecheck:webui` compiling until the roster moves as a unit. diff --git a/packages/webui/docs/architecture/module-map.md b/packages/webui/docs/architecture/module-map.md index 1aebe3461..c30a44262 100644 --- a/packages/webui/docs/architecture/module-map.md +++ b/packages/webui/docs/architecture/module-map.md @@ -40,7 +40,7 @@ business is — and what it is not — is in `../client/ownership.md`. | `runtime/assembly.ts` | The composition root. Creates and wires the process resources, forwards the runtime-owner options, and enriches the host handle with the credential-backed slots. Creates no OAuth state machine, no close ordering. | | `runtime/lifecycle.ts` | Resource registration, close ordering and startup-failure recovery. Wraps `apiHost.close` so runtime and capability owners close exactly once. | | `runtime/port.ts` | `WebuiHarnessPort` — the boundary to the harness: the callable, `AsyncIterable` and cancellation types plus one method per operation. It declares no wire DTO; those live in `shared/contracts/.ts`. | -| `runtime/harness/` | The per-domain adapters that implement the port by delegating to the host handle: `host-contract.ts` (`WebuiRuntimeHostHandle`, `WebuiRuntimeCliService`), `adapter.ts` (`createHarnessPortFromHost`), plus `account`, `execution`, `interactions`, `models-plugins`, `requirements`, `sessions`, `settings`, `workspace`. | +| `runtime/harness/` | The per-domain adapters that implement the port by delegating to the host handle: `host-contract.ts` (`WebuiRuntimeHostHandle`, `WebuiRuntimeCliService`), `adapter.ts` (`createHarnessPortFromHost`), plus `account`, `execution`, `interactions`, `miniapp`, `models-plugins`, `requirements`, `sessions`, `settings`, `workspace`. | | `runtime/auth-session.ts`, `runtime/auth-context.ts` | The one OAuth core with its lease and refresh timer, and the per-request auth context. | | `runtime/account-login.ts`, `runtime/check-in.ts`, `runtime/usage-quota.ts`, `runtime/usage-quota-projection.ts`, `runtime/reset-card-projection.ts`, `runtime/signing.ts` | Account login, daily check-in, the account-billing client (the quota, the credit fallback and the reset cards) and its two pure projections, request signing. | | `runtime/terminal.ts` | The terminal process manager. One per WebUI service, not a host-wide singleton. | @@ -80,7 +80,7 @@ business is — and what it is not — is in `../client/ownership.md`. | `server/operation/.ts` | The operation descriptors (name + `validate`) grouped by domain. | | `server/operation/bind-handlers.ts` | `WEBUI_OPERATION_BINDINGS` — the statically declared operation→capability mapping — plus `createBindingEntries` and `DEDICATED_OPERATION_NAMES`. | | `server/operation/binding-mechanism.ts` | The binding machinery around `OperationSpec`. | -| `server/operation/handlers/` | The thirteen operations that keep a hand-written handler: `account`, `commands`, `messages`, `stream`, `terminal`, `workspace`. | +| `server/operation/handlers/` | The sixteen operations that keep a hand-written handler: `account`, `commands`, `messages`, `miniapp`, `stream`, `terminal`, `workspace`. | | `server/operation/operation-dispatch.ts` | `dispatchWebuiFrame` — one inbound frame → validate → look up → handle → response, stream or error frame. | | `server/operation/operations.ts` | `createOperationRegistry`, the single `registerOperation` call site, and the registry order. | @@ -91,7 +91,7 @@ The binding roster and the compiler enforcement that keeps it in step with the p | Module | Role | | --- | --- | -| `shared/contracts/.ts` | The request/result DTOs both sides of the boundary share — account, account-billing (the credit fallback and the reset cards), canvas, commands, goal, interactions, messages, models, personalization, queue, review, session, stream, terminal, usage-quota, version, workspace. | +| `shared/contracts/.ts` | The request/result DTOs both sides of the boundary share — account, account-billing (the credit fallback and the reset cards), canvas, commands, goal, interactions, messages, miniapp, models, personalization, queue, review, session, stream, terminal, usage-quota, version, workspace. | | `shared/envelope.ts` | Frame shape, protocol version, error codes. | | `shared/operation-names.ts` | Every `*_OPERATION_NAME` constant and the `OperationSpec` map that unary request types are derived from. | | `shared/plugin-management.ts` | `WebuiPluginManagementAction` and the plugin wire shapes. | diff --git a/packages/webui/docs/known-gaps.md b/packages/webui/docs/known-gaps.md index 2cadcadb5..fdc219d85 100644 --- a/packages/webui/docs/known-gaps.md +++ b/packages/webui/docs/known-gaps.md @@ -4,7 +4,8 @@ Capabilities the WebUI does not have, verified against the tree. Each entry says how, and what remains unverified. "Not implemented" and "not decided" are different states and are kept apart here; the undecided ones are also in `open-questions.md`. -Last verified: 2026-10-26, against `webui` at `88c83655`. This list covers capabilities; open +Last verified: 2026-10-27, against the Mini App hosting stack merged with `origin/webui` at +`ba946c06`. This list covers capabilities; open decisions are in [`open-questions.md`](open-questions.md). ## 1. Connector and App tools depend on a beta feature, and on the model finding them @@ -43,11 +44,12 @@ them. They are listed so a future reader does not rediscover them as wiring gaps | Site | Gate | What the WebUI does not get | | --- | --- | --- | -| `local-runtime-v2/src/service/miniapp/composition.ts` | `runtimeOwnerKind !== 'electron'` → `undefined` | The miniapp service | | `local-runtime-v2/src/runtime.ts` (`greetingEnabled`) | `runtimeOwnerKind === 'electron'` | The startup greeting | | `local-runtime-v2/src/service/llm-context-inspector/initialize.ts` | `runtimeOwnerKind === 'tui'` plus `MAVIS_TUI_LLM_CONTEXT_INSPECTOR=1` | The context inspector (the terminal client can opt in; the WebUI cannot) | -The miniapp and inspector gates are service-availability decisions, not client-policy bugs. +The inspector gate is a service-availability decision, not a client-policy bug. Mini App service +availability is driven by the runtime owner's `wiring.miniAppService` cell, including the WebUI row; +the service composition no longer compares `runtimeOwnerKind` directly. ## 3. The data directory is displayed, not relocated @@ -118,35 +120,7 @@ The bridge's **restricted action set is otherwise the same 28 actions** the isol executes, and both are executed by Browser Core — one implementation over two transports. See [`browser-use/10-browser-core-integration.md`](browser-use/10-browser-core-integration.md). -## 9. The Mini App capability is not reachable from this client - -The runtime owns a Mini App service — a supervisor, a node runtime that spawns the Mini App's own -process, a plugin package reader, and a session Mini App lifecycle. None of it is reachable from -the WebUI: - -- `local-runtime-v2/src/service/miniapp/composition.ts` returns no options unless the runtime owner - kind is `electron`, so in the WebUI process the Mini App supervisor is never initialized and - `miniappAvailable` is false. -- `CliService` has no Mini App method, so there is no client entry for the capability the runtime - already owns in `application/session/process-local-miniapp-application.ts`. -- The client has no Mini App view, no proxy, and no page bridge. - -The Mini App contract in this repository is also one generation behind the implementation shipped -in the desktop client: `packages/shared/src/miniapp-surface.ts` here is 54 lines against 1267 -there, and this repository's runtime has no credential guard on the Mini App's own HTTP server. -The newer contract is in neither this repository nor the public upstream tree, so it does not -arrive by synchronization. The accepted direction for this client — which generation of the contract -is taken, what the bridge carries, where the view lives and how a standalone presentation is -tracked — is in [ADR 0020](adr/0020-webui-miniapp-view-and-runtime-seams.md). - -The owner-kind gate is also decided in the wrong place. The runtime owner policy resolves once per -host and drives the other owner-derived service gates — cron, channels, the scheduler — from its -cells, while the Mini App gate compares the raw kind string inside its own service -(`local-runtime-v2/src/service/miniapp/composition.ts`). That is why §2 lists it as a branch no -change to this client's row can reach. The accepted shape moves the Mini App service into the -policy as a wiring cell this client's row carries. - -## 10. A symlink out of the workspace is not detectable by the diff boundary +## 9. A symlink out of the workspace is not detectable by the diff boundary **Implemented, with a stated limit.** A turn's diff can name a file outside the workspace, and the client now withholds that file's body: the entry keeps its name, its status and its line counts, diff --git a/packages/webui/docs/runtime/harness-boundary.md b/packages/webui/docs/runtime/harness-boundary.md index 2cfa6c161..d0f91e158 100644 --- a/packages/webui/docs/runtime/harness-boundary.md +++ b/packages/webui/docs/runtime/harness-boundary.md @@ -19,13 +19,14 @@ rather than by an assertion: Drop a port member without dropping its binding, or add one without reaching the registry, and `pnpm typecheck:webui` stops compiling. The current roster is pinned in -`test/unit/webui-operation-binding.test.ts`: **86** bindings, **13** dedicated handlers, **99** +`test/unit/webui-operation-binding.test.ts`: **93** bindings, **16** dedicated handlers, **109** operations. A new operation breaks all three counts, so bump them in the same change — after checking that the roster really grew. -The thirteen operations that keep a hand-written handler live in `server/operation/handlers/` -(`account`, `commands`, `messages`, `stream`, `terminal`, `workspace`). They are the ones that cannot -be expressed as a single capability call. +The sixteen operations that keep a hand-written handler live in `server/operation/handlers/` +(`account`, `commands`, `messages`, `miniapp`, `stream`, `terminal`, `workspace`). They are the ones +that cannot be expressed as a single capability call — a shaped response, a second runtime surface +of their own, or a capability the host may not have. ## Host types and wire projections diff --git a/packages/webui/scripts/host-event-catalogue.mjs b/packages/webui/scripts/host-event-catalogue.mjs index f6716dd90..1604ebae2 100644 --- a/packages/webui/scripts/host-event-catalogue.mjs +++ b/packages/webui/scripts/host-event-catalogue.mjs @@ -7,6 +7,22 @@ const packageRoot = fileURLToPath(new URL("../", import.meta.url)); const hostRoot = path.resolve(packageRoot, "../local-runtime-v2/src"); const output = path.join(packageRoot, "src/shared/contracts/host-event-types.generated.ts"); const eventName = /^(?:session|message|agent|rotation|content|turn|stream|permission|questionnaire)\.[a-z_.]+$/u; +/** + * The Mini App agent module declares and emits its own run-event vocabulary + * (`turn.started`, `message.committed`, `permission.requested`, ...). Those events are + * projected to the Mini App page bridge, never written to the host event bus, so a name + * shared with a host event does not make one. Excluded per file, not per directory: a + * genuine host producer added to the same folder must still be catalogued. + */ +const excludedSources = [ + path.join("service", "miniapp", "agent", "contracts.ts"), + path.join("service", "miniapp", "agent", "gateway.ts"), + path.join("service", "miniapp", "agent", "session-ports.ts"), + path.join("service", "miniapp", "agent", "session-ports-projection.ts"), + // The child IPC connector answers those same run frames over the parent/child channel. + path.join("service", "miniapp", "process", "host-connector", "host-connector-agent.ts"), +]; + /** Collect actual literal declarations with the TypeScript parser, including typed producers. */ export function collectHostEventTypes(root = hostRoot, read = readFileSync) { @@ -15,8 +31,12 @@ export function collectHostEventTypes(root = hostRoot, read = readFileSync) { function walk(directory) { for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { const filename = path.join(directory, entry.name); - if (entry.isDirectory()) { walk(filename); continue; } + if (entry.isDirectory()) { + walk(filename); + continue; + } if (!entry.name.endsWith(".ts") || entry.name.endsWith(".test.ts")) continue; + if (excludedSources.includes(path.relative(root, filename))) continue; files.push(filename); } } diff --git a/packages/webui/src/client/application/create-application.ts b/packages/webui/src/client/application/create-application.ts index 40e0f58ed..77dc9fe84 100644 --- a/packages/webui/src/client/application/create-application.ts +++ b/packages/webui/src/client/application/create-application.ts @@ -40,6 +40,7 @@ import { createWebuiAccountWorkflows } from "../features/account/application/acc import { createWebuiModelsWorkflows } from "../features/models/application/models-workflows.js"; import { createWebuiSettingsWorkflows } from "../features/settings/application/settings-workflows.js"; import { createWebuiPluginWorkflows } from "../features/plugins/application/plugin-workflows.js"; +import { createWebuiMiniAppWorkflows } from "../features/miniapp/application/miniapp-workflows.js"; import type { WebuiPluginManagementAction } from "../../shared/plugin-management.js"; import { createWebuiComposerPluginCatalogue } from "../features/plugins/application/composer-catalogue.js"; import { createWebuiPluginActions } from "../features/plugins/contracts/plugin-actions.js"; @@ -352,6 +353,8 @@ export interface WebuiApplication { readonly modelsWorkflows: ReturnType; /** The plugin manager's owner. */ readonly pluginWorkflows: ReturnType; + /** The Mini App business's owner: catalogue, presentations and refusals. */ + readonly miniAppWorkflows: ReturnType; /** The composer's plugin/skill read model, separate from the manager's. */ readonly composerPluginCatalogue: ReturnType; /** The plan-review reply workflow. */ @@ -859,6 +862,19 @@ export function createWebuiApplication( } }, }); + // The Mini App business's owner. It reads the Mini App capability and the + // package lifecycle, because the catalogue's apps area performs both: a Mini App + // is a plugin, so enabling or uninstalling one is a plugin write. + const miniAppWorkflows = createWebuiMiniAppWorkflows({ + port: slices.miniapp, + // The store is the shell's selection owner: a rail click, a fork and a delete + // all land there, and none of them touches the URL hash. The Mini App business + // reads it so a page's shown session is closed the moment the person leaves it. + selection: { + current: () => store.getSelectedSessionId(), + subscribe: store.subscribe, + }, + }); // The composer's view of what is installed and what the agent can run: a // separate read model from `pluginWorkflows` on purpose, so neither overwrites // the other when one reloads. @@ -1002,6 +1018,7 @@ export function createWebuiApplication( settingsWorkflows, modelsWorkflows, pluginWorkflows, + miniAppWorkflows, composerPluginCatalogue, activeTurnProbe, planReviewWorkflow, diff --git a/packages/webui/src/client/application/port-slices.ts b/packages/webui/src/client/application/port-slices.ts index 54e5136cd..13a0a70f4 100644 --- a/packages/webui/src/client/application/port-slices.ts +++ b/packages/webui/src/client/application/port-slices.ts @@ -53,6 +53,8 @@ import type { TurnExecutionPort } from "../features/turn/contracts/execution-por import type { TerminalPort } from "../features/terminal/contracts/terminal-port.js"; import type { BrowserPort } from "../features/browser/contracts/browser-port.js"; import type { WorkspacePort } from "../features/workspace/contracts/workspace-port.js"; +import type { WebuiMiniAppPortSlice } from "../features/miniapp/application/miniapp-workflows.js"; +import type { MiniAppPort } from "../features/miniapp/contracts/miniapp-port.js"; import { createWebuiTurnCapabilitySlice, type WebuiTurnCapabilitySlice, @@ -77,7 +79,8 @@ export interface WebuiPortSliceSource ModelsPort, AccountPort, PluginPort, - BrowserPort {} + BrowserPort, + MiniAppPort {} // The key lists below are the old-to-new mapping as data. Each is checked // against its port by `satisfies`, so a renamed or removed member stops the @@ -234,6 +237,16 @@ const ACCOUNT_KEYS = [ const SHELL_KEYS = ["watchEvents"] as const satisfies readonly (keyof ShellEventPort)[]; +const MINIAPP_KEYS = [ + "watchMiniAppPresenter", + "listMiniApps", + "openMiniApp", + "stopMiniApp", + "watchMiniAppPresentation", + "miniappStandaloneUrl", + "pluginManagement", +] as const satisfies readonly (keyof MiniAppPort | keyof PluginPort)[]; + export type WebuiSessionsSlice = Pick; export type WebuiTranscriptSlice = Pick; export type WebuiWorkspaceSlice = Pick & @@ -250,6 +263,7 @@ export type WebuiSettingsSlice = Pick; export type WebuiShellSlice = Pick; export type WebuiBrowserSlice = Pick; +export type WebuiMiniAppSlice = WebuiMiniAppPortSlice; export type WebuiTurnSlice = WebuiTurnCapabilitySlice; /** One slice per business. */ @@ -266,6 +280,7 @@ export interface WebuiPortSlices { readonly account: WebuiAccountSlice; readonly shell: WebuiShellSlice; readonly browser: WebuiBrowserSlice; + readonly miniapp: WebuiMiniAppSlice; readonly turn: WebuiTurnSlice; } @@ -305,6 +320,7 @@ export function createWebuiPortSlices( account: cut(source, ACCOUNT_KEYS), shell: cut(source, SHELL_KEYS), browser: cut(source, BROWSER_KEYS), + miniapp: cut(source, MINIAPP_KEYS), // `turn`'s published slice, built by its own factory so there is exactly one // construction site for it. That factory drops an absent member // instead of storing `undefined` under it, so `turn` is the one slice whose diff --git a/packages/webui/src/client/contracts/transport.ts b/packages/webui/src/client/contracts/transport.ts index a3b01e728..7e5e75b9a 100644 --- a/packages/webui/src/client/contracts/transport.ts +++ b/packages/webui/src/client/contracts/transport.ts @@ -1,7 +1,7 @@ // Browser transport — the single bag of methods the foundation app and the // composer consume. // -// Composed from the fourteen business capability ports. It +// Composed from the fifteen business capability ports. It // declares no method of its own: every operation lives in exactly one port and // this type only joins them. Every method stays optional — `undefined` means // "the operation is not wired" (the panel renders the affected area @@ -28,6 +28,7 @@ import type { AccountPort } from "../features/account/contracts/account-port.js" import type { PluginPort } from "../features/plugins/contracts/plugin-port.js"; import type { TerminalPort } from "../features/terminal/contracts/terminal-port.js"; import type { BrowserPort } from "../features/browser/contracts/browser-port.js"; +import type { MiniAppPort } from "../features/miniapp/contracts/miniapp-port.js"; export interface WebuiTransport extends SessionPort, @@ -43,4 +44,5 @@ export interface WebuiTransport AccountPort, PluginPort, TerminalPort, - BrowserPort {} + BrowserPort, + MiniAppPort {} diff --git a/packages/webui/src/client/features/composer/application/composer-controller.ts b/packages/webui/src/client/features/composer/application/composer-controller.ts index 3b5f57b6e..467211e8e 100644 --- a/packages/webui/src/client/features/composer/application/composer-controller.ts +++ b/packages/webui/src/client/features/composer/application/composer-controller.ts @@ -3,6 +3,7 @@ import type { WebuiComposerController, WebuiComposerControllerSnapshot, WebuiCom import type { WebuiComposerStore } from "./composer-store.js"; import type { WebuiComposerSubmitIntent } from "../contracts/composer-controller.js"; import type { WebuiComposerStagingState } from "../contracts/composer-state.js"; +import { WEBUI_COMPOSER_HOME_KEY } from "../contracts/composer-state.js"; import type { WebuiRunCommandRequest, WebuiCommandWorkflows } from "./command-workflows.js"; import type { WebuiConversationTurnCommands } from "../../turn/contracts/turn-commands.js"; import type { WebuiSubmissionCompletion, WebuiSubmissionTicket } from "../../turn/contracts/submission.js"; @@ -55,7 +56,7 @@ export interface WebuiComposerControllerDeps { readonly clearHomeTurn: () => void; } -const HOME_KEY = "__webui_composer_home__"; +const HOME_KEY = WEBUI_COMPOSER_HOME_KEY; /** Application coordinator for the existing composer store and owners. */ export function createWebuiComposerController(deps: WebuiComposerControllerDeps): WebuiComposerController { diff --git a/packages/webui/src/client/features/composer/contracts/composer-state.ts b/packages/webui/src/client/features/composer/contracts/composer-state.ts index 49ffdfa82..c1b9df503 100644 --- a/packages/webui/src/client/features/composer/contracts/composer-state.ts +++ b/packages/webui/src/client/features/composer/contracts/composer-state.ts @@ -7,6 +7,16 @@ export interface WebuiComposerPersisted { readonly history: Readonly>; } +/** + * The composer slot that stands for "no session yet". + * + * Declared here rather than in the controller that used to spell it, because a + * second party now writes a draft: a Mini App's confirmed append targets the + * composer of the session it is shown in, and with no session that is this slot. + * One literal, so the two writers cannot drift onto different slots. + */ +export const WEBUI_COMPOSER_HOME_KEY = "__webui_composer_home__"; + export interface WebuiComposerQueueState { readonly items: readonly WebuiQueueItem[]; readonly paused: boolean; diff --git a/packages/webui/src/client/features/miniapp/application/miniapp-agent-bridge.ts b/packages/webui/src/client/features/miniapp/application/miniapp-agent-bridge.ts new file mode 100644 index 000000000..575885f58 --- /dev/null +++ b/packages/webui/src/client/features/miniapp/application/miniapp-agent-bridge.ts @@ -0,0 +1,446 @@ +// The page bridge's host half: what the client that framed a Mini App does with +// its `window.mcode.miniapp.agent` calls. +// +// The server owns authority — plugin, generation, runtime policy — and the page +// owns nothing. This module owns the part in between: deciding that a message came +// from the frame this view created, adding the identity the client does know (the +// host session, the connection generation and the page's correlation id), and +// carrying one request's answer and events back to that same document. +// +// Three rules make it safe to point at an untrusted page: +// +// * **The sender is checked, not trusted.** A message is read only when its +// `source` is the frame this view created and its origin is the one this view +// framed. Any window can post to this one; a bridge that answered them would let +// any page in the browser spend the person's turn. +// * **Identity is the host's.** The page names content and a correlation id. The +// plugin, the host session, the connection generation — and every runtime fact +// behind them — are added here, from the view. +// * **One request id is one call.** A second request naming a live id is answered +// `busy` rather than sharing that id's events and settlement, and a document +// that goes away detaches every turn it had open. +// +// Framework-free: it takes a `post` function and a commands object, so a test drives +// the real routing against a fake frame without a browser. + +import { + isMiniAppAgentFailureCode, + isMiniAppAgentPermissionDecision, + isMiniAppAgentRequestId, + MINIAPP_AGENT_MAX_TEXT_LENGTH, + readMiniAppAgentAnswers, + readMiniAppAgentCreateRequest, + readMiniAppAgentModelSelection, + readMiniAppAgentOpenRequest, + readMiniAppAgentReplyRequest, + readMiniAppAgentRunRequest, + type MiniAppAgentModelSelection, +} from "@mavis/shared/miniapp-surface"; + +import { + isWebuiMiniAppAgentTurnMethod, + readWebuiMiniAppAgentBridgeRequest, + WEBUI_MINIAPP_AGENT_MAX_ID_LENGTH, + WEBUI_MINIAPP_BRIDGE_AGENT_REQUEST, + WEBUI_MINIAPP_BRIDGE_AGENT_EVENT, + WEBUI_MINIAPP_BRIDGE_AGENT_RESULT, + type WebuiMiniAppAgentBridgeRequest, + type WebuiMiniAppAgentBridgeResult, + type WebuiMiniAppAgentCallRequest, + type WebuiMiniAppAgentCallResponse, + type WebuiMiniAppAgentTurnFrame, + type WebuiMiniAppAgentTurnRequest, +} from "../../../../shared/contracts/miniapp-agent.js"; +import { webuiMiniAppBridgeSenderAllowed } from "./miniapp-bridge.js"; + +/** What the bridge asks the business to do. */ +export interface WebuiMiniAppAgentBridgeCommands { + /** One short call, refusal included in its body. */ + readonly call: (request: WebuiMiniAppAgentCallRequest) => Promise; + /** + * One followed turn. The detach is the teardown: it closes this turn's stream + * and nothing else. + */ + readonly watchTurn: ( + request: WebuiMiniAppAgentTurnRequest, + onFrame: (frame: WebuiMiniAppAgentTurnFrame) => void, + onFailure: (error: Error) => void, + ) => () => void; +} + +export interface CreateWebuiMiniAppAgentBridgeInput { + readonly pluginId: string; + /** The session whose view framed the page; the runtime's `hostSessionId`. */ + readonly hostSessionId: string; + /** The generation the service issued when it answered `openMiniApp`. */ + readonly connection: number; + /** The proxy's URL this view framed, so its origin is the one allowed. */ + readonly viewUrl: string; + /** Posts one frame into the page. Supplied by the view, which holds the frame. */ + readonly post: (message: unknown) => void; + readonly commands: WebuiMiniAppAgentBridgeCommands; +} + +export interface WebuiMiniAppAgentBridgeEvent { + readonly data: unknown; + readonly origin: string; + readonly source: unknown; +} + +export interface WebuiMiniAppAgentBridgeHost { + /** + * Handles one message. `frame` is the browser object the view created, so the + * check can compare it with the message's source. + */ + readonly handleMessage: (event: WebuiMiniAppAgentBridgeEvent, frame: unknown) => void; + /** + * The framed document was replaced by a navigation. + * + * The page's pending map dies with it, so the turns it opened are cancelled + * through the service and detached here. Leaving them attached would keep the + * runtime counting a listener that no longer exists, and the replacement document + * would inherit a settlement addressed to nobody. + */ + readonly handleDocumentReplaced: () => void; + /** Detaches every turn this document had open and stops answering. */ + readonly close: () => void; + /** The turn ids in flight, for teardown and for a test. */ + readonly inFlight: () => readonly string[]; +} + +function isBoundedId(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= WEBUI_MINIAPP_AGENT_MAX_ID_LENGTH + ); +} + +/** The model a page may name, or nothing when it named none. */ +function modelOf(payload: Record): MiniAppAgentModelSelection | undefined { + if (payload.model === undefined) return undefined; + return readMiniAppAgentModelSelection(payload.model) ?? undefined; +} + +/** + * Builds the host half for one mounted view. + * + * The host is per document: a replacement view gets a new one with the new + * generation, and the old one's `close` is what stops its turns. That is the + * client-side half of generation fencing — the service enforces the other half by + * refusing a call whose connection it never issued. + */ +export function createWebuiMiniAppAgentBridge( + input: CreateWebuiMiniAppAgentBridgeInput, +): WebuiMiniAppAgentBridgeHost { + /** One detach per in-flight turn, so a teardown can end exactly those. */ + const turns = new Map void>(); + /** Request ids are shared across short calls and streamed turns. */ + const inFlightIds = new Set(); + let closed = false; + + function postToPage(message: unknown): void { + if (closed) return; + input.post(message); + } + + function answer(requestId: string, result: WebuiMiniAppAgentBridgeResult): void { + postToPage({ type: WEBUI_MINIAPP_BRIDGE_AGENT_RESULT, requestId, result }); + } + + /** The identity every body carries: resolved here, never page-supplied. */ + function base(requestId: string) { + return { + pluginId: input.pluginId, + hostSessionId: input.hostSessionId, + connection: input.connection, + requestId, + } as const; + } + + /** + * One turn, followed to its settlement. + * + * The settlement is the page's promise: a `settled` frame resolves it with the + * run's result and a `failure` frame rejects it with the code the service chose. + * A stream that fails at the transport level (the service went away, the socket + * dropped) is `result_unknown`, because the turn may already have been accepted. + */ + function startTurn( + request: WebuiMiniAppAgentBridgeRequest, + body: WebuiMiniAppAgentTurnRequest, + ): void { + const requestId = request.requestId; + /** + * Whether the watch is still open. + * + * A watch can settle synchronously — a host with no capability, a view with no + * host session — and it settles before it has returned its detach. Recording + * the id first would leave a settled turn in flight, where it would answer a + * retry `busy` and forward a cancellation for a turn that never started. + */ + let live = true; + const settle = (result: WebuiMiniAppAgentBridgeResult): void => { + live = false; + turns.delete(requestId); + inFlightIds.delete(requestId); + answer(requestId, result); + }; + const detach = input.commands.watchTurn( + body, + (frame) => { + if (frame.kind === "event") { + postToPage({ + type: WEBUI_MINIAPP_BRIDGE_AGENT_EVENT, + requestId, + event: frame.event, + }); + return; + } + settle( + frame.kind === "settled" + ? { ok: true, value: frame.result } + : { ok: false, code: frame.code, ...(frame.reason ? { reason: frame.reason } : {}) }, + ); + }, + (error) => { + // A refused or broken stream: the runtime may have accepted the turn before + // the failure, so it is never reported as a clean rejection. + const failureCode = (error as { code?: unknown } | undefined)?.code; + settle({ + ok: false, + code: isMiniAppAgentFailureCode(failureCode) ? failureCode : "result_unknown", + }); + }, + ); + if (live) turns.set(requestId, detach); + else detach(); + } + + /** Turns one page payload into the wire body the service validates. */ + function turnBody( + request: WebuiMiniAppAgentBridgeRequest, + ): WebuiMiniAppAgentTurnRequest | undefined { + const payload = request.payload; + if (request.method === "run") { + const read = readMiniAppAgentRunRequest({ + text: payload.text, + sessionId: payload.sessionId, + model: payload.model, + agent: payload.agent, + requestId: request.requestId, + }); + if (!read) return undefined; + const model = modelOf(payload); + return { + ...base(request.requestId), + method: "run", + observe: request.observe, + text: read.text.slice(0, MINIAPP_AGENT_MAX_TEXT_LENGTH), + ...(read.sessionId !== undefined ? { targetSessionId: read.sessionId } : {}), + ...(read.agent !== undefined ? { agent: read.agent } : {}), + ...(model ? { model } : {}), + }; + } + if (!isBoundedId(payload.sessionId)) return undefined; + if (!isBoundedId(payload.questionId ?? payload.requestId)) return undefined; + const answers = readMiniAppAgentAnswers(payload.answers); + if (!answers) return undefined; + const model = modelOf(payload); + return { + ...base(request.requestId), + method: "answer", + observe: request.observe, + targetSessionId: payload.sessionId, + questionRequestId: String(payload.questionId ?? payload.requestId), + answers: answers as unknown as readonly Record[], + ...(model ? { model } : {}), + }; + } + + /** Turns one short-call payload into the wire body the service validates. */ + function callBody( + request: WebuiMiniAppAgentBridgeRequest, + ): WebuiMiniAppAgentCallRequest | undefined { + const payload = request.payload; + switch (request.method) { + case "models": + case "agents": + return { ...base(request.requestId), method: request.method }; + case "cancel": + return { ...base(request.requestId), method: "cancel" }; + case "create": { + const read = readMiniAppAgentCreateRequest(payload); + if (!read) return undefined; + return { + ...base(request.requestId), + method: "create", + ...(read.agent !== undefined ? { agent: read.agent } : {}), + }; + } + case "open": { + const read = readMiniAppAgentOpenRequest(payload); + if (!read) return undefined; + return { ...base(request.requestId), method: "open", targetSessionId: read.sessionId }; + } + case "reply": { + const read = readMiniAppAgentReplyRequest(payload); + if (!read) return undefined; + if (!isMiniAppAgentPermissionDecision(read.decision)) return undefined; + return { + ...base(request.requestId), + method: "reply", + targetSessionId: read.sessionId, + questionRequestId: read.requestId, + decision: read.decision, + }; + } + default: + return undefined; + } + } + + function dispatch(request: WebuiMiniAppAgentBridgeRequest): void { + // A cancellation names a turn this document already opened — so it is the one + // method whose request id is *expected* to be in flight, and it is decided + // before the duplicate guard rather than being refused by it. + if (request.method === "cancel") { + if (!turns.has(request.requestId)) { + answer(request.requestId, { ok: false, code: "request_not_found" }); + return; + } + void input.commands + .call({ ...base(request.requestId), method: "cancel" }) + .then((response) => { + if (!response.ok) answer(request.requestId, response); + // A successful cancellation is answered by the turn's own failure frame, + // which is the settlement the page's promise is waiting on. + }) + .catch(() => undefined); + return; + } + // Two calls sharing an id would also share its events and its settlement. + if (inFlightIds.has(request.requestId)) { + answer(request.requestId, { ok: false, code: "busy" }); + return; + } + if (isWebuiMiniAppAgentTurnMethod(request.method)) { + const body = turnBody(request); + if (!body) { + answer(request.requestId, { ok: false, code: "invalid_request" }); + return; + } + inFlightIds.add(request.requestId); + startTurn(request, body); + return; + } + const body = callBody(request); + if (!body) { + answer(request.requestId, { ok: false, code: "invalid_request" }); + return; + } + inFlightIds.add(request.requestId); + void input.commands.call(body).then( + (response) => { + answer( + request.requestId, + response.ok + ? { ok: true, value: pageValueOf(response) } + : { + ok: false, + code: response.code, + ...(response.reason ? { reason: response.reason } : {}), + }, + ); + }, + () => { + // A transport failure on a call that may have applied: the code says so. + answer(request.requestId, { ok: false, code: "result_unknown" }); + }, + ).finally(() => inFlightIds.delete(request.requestId)); + } + + return { + handleMessage(event, frame) { + if (closed) return; + if ( + !webuiMiniAppBridgeSenderAllowed({ + origin: event.origin, + viewUrl: input.viewUrl, + source: event.source, + frame, + }) + ) { + return; + } + const request = readWebuiMiniAppAgentBridgeRequest(event.data); + if (request) { + dispatch(request); + return; + } + if ( + event.data && + typeof event.data === "object" && + !Array.isArray(event.data) + ) { + const raw = event.data as Record; + if ( + raw.type === WEBUI_MINIAPP_BRIDGE_AGENT_REQUEST && + isMiniAppAgentRequestId(raw.requestId) + ) { + answer(raw.requestId, { ok: false, code: "invalid_request" }); + } + } + }, + handleDocumentReplaced() { + if (closed) return; + // Cancel before detaching: the service settles the abort and frees the id, so + // the replacement document cannot inherit a turn it never opened. + for (const requestId of [...turns.keys()]) { + void input.commands + .call({ + ...base(requestId), + method: "cancel", + }) + .catch(() => undefined); + } + for (const detach of turns.values()) detach(); + turns.clear(); + inFlightIds.clear(); + }, + close() { + if (closed) return; + closed = true; + for (const detach of turns.values()) detach(); + turns.clear(); + inFlightIds.clear(); + }, + inFlight: () => [...turns.keys()], + }; +} + +/** + * What a page's method resolves. + * + * The shipped preload resolves the payload of a call, not the envelope around it: + * `models()` answers the catalogue with its default, `agents()` answers the agent + * list, `create()` answers the created session, and the calls with nothing to + * return answer nothing. A page written against the desktop client destructures + * those values, so an extra layer here is a page that breaks on its first line. + */ +function pageValueOf( + response: Extract, +): unknown { + switch (response.method) { + case "models": + return response.models; + case "agents": + return response.agents; + case "create": + return { sessionId: response.sessionId }; + default: + // `open`, `reply`, the two panel reports and `cancel` settle with nothing to + // read, exactly as the preload's `invoke` returns for them. + return undefined; + } +} diff --git a/packages/webui/src/client/features/miniapp/application/miniapp-bridge.ts b/packages/webui/src/client/features/miniapp/application/miniapp-bridge.ts new file mode 100644 index 000000000..5825613b3 --- /dev/null +++ b/packages/webui/src/client/features/miniapp/application/miniapp-bridge.ts @@ -0,0 +1,105 @@ +// The page bridge's host side: what the WebUI client does with a Mini App's +// `postMessage`. +// +// The proxy injects the page half (`server/miniapp/bridge.ts`); this is the half +// that runs in the client and answers. Three rules make it safe to point at an +// untrusted page: +// +// * **The sender is checked, not trusted.** A message is only read when its +// origin is the Mini App's own proxy origin and its source is the frame this +// view created. Any other window can post to this one, and a bridge that +// answered them would let any page in the browser append to the composer. +// * **The text is bounded and never sent.** An append writes the composer's +// draft; it never submits, so a page cannot spend the person's turn. +// * **A person confirms it.** The confirmation is injected so this module has +// no DOM, and declining is `user_cancelled` with the draft untouched. +// +// The identity fields the runtime's contract mentions (`sessionId`, `pluginId`, +// `pluginName`) are supplied by the host from the view, never by the page: a page +// that could name another Mini App's session would be a page that writes into a +// transcript it cannot see. + +import { + WEBUI_MINIAPP_BRIDGE_APPEND_REQUEST, + WEBUI_MINIAPP_COMPOSER_MAX_TEXT_LENGTH, + type WebuiMiniAppBridgeAppendRequest, + type WebuiMiniAppComposerResult, +} from "../../../../shared/contracts/miniapp.js"; + +/** Reads one append request out of a message's data, or nothing. */ +export function webuiMiniAppBridgeRequest( + data: unknown, +): WebuiMiniAppBridgeAppendRequest | undefined { + if (!data || typeof data !== "object" || Array.isArray(data)) return undefined; + const record = data as Record; + if (record.type !== WEBUI_MINIAPP_BRIDGE_APPEND_REQUEST) return undefined; + const requestId = record.requestId; + const text = record.text; + if (typeof requestId !== "string" || !requestId) return undefined; + if (typeof text !== "string") return undefined; + return { requestId, text }; +} + +/** + * Whether a message came from the frame this view created, on the origin this + * view framed. + * + * `viewUrl` is the proxy's URL the server answered with, so its origin is the one + * the browser reports for the document: comparing them is what tells the frame + * this view owns from a second frame, or from the page's own window. + */ +export function webuiMiniAppBridgeSenderAllowed(input: { + readonly origin: string; + readonly viewUrl: string; + readonly source: unknown; + readonly frame: unknown; +}): boolean { + if (!input.frame || input.source !== input.frame) return false; + try { + return new URL(input.viewUrl).origin === input.origin; + } catch { + // A view URL that cannot be parsed frames nothing, so nothing is allowed. + return false; + } +} + +/** + * The draft an append produces. + * + * Existing text is kept and the appended text follows on a line of its own: a + * page that appends "/review this" beside a half-written sentence would be + * editing something the person wrote, and an append is not an edit. + */ +export function webuiMiniAppDraftWithAppend(draft: string, text: string): string { + if (!draft) return text; + return draft.endsWith("\n") ? `${draft}${text}` : `${draft}\n${text}`; +} + +/** + * One append, decided. + * + * The order matters: size before identity, identity before confirmation, and the + * confirmation before anything is written. A declined append performs no write + * at all, which is the property the ticket's criterion names. + */ +export async function webuiMiniAppBridgeAppend(input: { + readonly request: WebuiMiniAppBridgeAppendRequest; + /** The session whose composer takes the draft; absent means "no target". */ + readonly sessionId?: string; + readonly pluginName: string; + readonly confirm: (question: string) => boolean | Promise; + readonly readDraft: () => string; + readonly writeDraft: (text: string) => void; +}): Promise { + const { text } = input.request; + if (text.length > WEBUI_MINIAPP_COMPOSER_MAX_TEXT_LENGTH) { + return { success: false, code: "text_too_large" }; + } + if (!input.sessionId) return { success: false, code: "target_unavailable" }; + const confirmed = await input.confirm( + `Mini App「${input.pluginName}」要把内容追加到输入框,是否允许?`, + ); + if (!confirmed) return { success: false, code: "user_cancelled" }; + input.writeDraft(webuiMiniAppDraftWithAppend(input.readDraft(), text)); + return { success: true }; +} diff --git a/packages/webui/src/client/features/miniapp/application/miniapp-workflows.ts b/packages/webui/src/client/features/miniapp/application/miniapp-workflows.ts new file mode 100644 index 000000000..1c2f797e0 --- /dev/null +++ b/packages/webui/src/client/features/miniapp/application/miniapp-workflows.ts @@ -0,0 +1,928 @@ +// The Mini App business's owner. +// +// Everything this client knows about Mini Apps that is not layout: the +// catalogue, the view each Mini App is presented in, why a view was cleared, and +// the refusals a start earned. ADR 0020 puts it here rather than in the plugin +// catalogue (whose lifetime is installing packages) or the workspace panel +// (whose lifetime is layout), because the state is per session and the protocol +// is its own. +// +// Framework-free: no React, no DOM, no `window`. A component reads the snapshot +// and submits these commands; a test drives the same commands against a scripted +// port. +// +// Two rules the state exists to enforce: +// +// * A refused start is a fact about a row, not a toast. The refusal is kept +// under the plugin id it was about, so the catalogue's apps area and the +// launcher's picker — two surfaces, one listing — both show it. +// * A stopped runtime clears the view and says why, and nothing starts again +// on its own. Only `open` starts a Mini App, and only a person calls it. + +import type { PluginPort } from "../../plugins/contracts/plugin-port.js"; +import { createWebuiPluginActions } from "../../plugins/contracts/plugin-actions.js"; +import type { + WebuiMiniAppCatalogue, + WebuiMiniAppCatalogueRow, + WebuiMiniAppPresentationIntent, +} from "../../../../shared/contracts/miniapp.js"; +import type { + WebuiMiniAppAgentCallRequest, + WebuiMiniAppAgentCallResponse, + WebuiMiniAppAgentTurnFrame, + WebuiMiniAppAgentTurnRequest, +} from "../../../../shared/contracts/miniapp-agent.js"; +import type { MiniAppPort } from "../contracts/miniapp-port.js"; +import { + webuiMiniAppRows, + webuiMiniAppStopReason, + type WebuiMiniAppRow, +} from "../projection/miniapp-view.js"; + +/** The port members this business drives: its own five, and the package lifecycle. */ +export type WebuiMiniAppPortSlice = Pick< + MiniAppPort, + | "listMiniApps" + | "watchMiniAppPresenter" + | "openMiniApp" + | "stopMiniApp" + | "watchMiniAppPresentation" + | "miniappStandaloneUrl" + | "watchMiniAppAgentTurn" + | "miniAppAgentCall" +> & + Pick; + +const MINIAPP_PRESENTATION_RECONNECT_GRACE_MS = 300; + +/** + * What this client is showing for one Mini App. + * + * `open` carries the proxy's view URL and nothing else — not the Mini App's + * origin, not its surface path, not the credential its guard demands. `stopped` + * is a cleared view that remembers why, which is what the panel renders instead + * of a dead frame. + */ +export type WebuiMiniAppPresentation = + | { readonly kind: "opening" } + | { + readonly kind: "open"; + readonly viewUrl: string; + /** + * The generation the service issued for this start. + * + * Every page-bridge call echoes it, so a request from a replaced frame is + * refused by the service rather than answered as the live one. It is the + * client's fencing key and the server's, and it is per start, not per plugin. + */ + readonly connection: number; + /** Standalone lease revision observed when this connection was issued. */ + readonly presentationRevision: number; + } + | { readonly kind: "stopped"; readonly reason: string }; + +/** A structural refusal, kept under the plugin it was about. */ +export interface WebuiMiniAppRefusal { + /** The runtime's own wire code, e.g. `OWNED_ELSEWHERE`, `BUSY`. */ + readonly code: string; + /** The runtime's own message. */ + readonly message: string; +} + +export interface WebuiMiniAppListing { + readonly rows: readonly WebuiMiniAppRow[]; + readonly loading: boolean; + readonly error?: string; +} + +/** + * The presentation this document holds, when it is the standalone one. + * + * Only a standalone page has this: it is the frame the service answered its lease + * with, so it carries what that lease is (the revision) and what the deep link + * named (the session, and whether the runtime still answers for it). + */ +export interface WebuiMiniAppStandalone { + readonly pluginId: string; + readonly sessionId: string; + readonly presentationRevision: number; + readonly session: "live" | "ended" | "unresolved"; +} + +export interface WebuiMiniAppWorkflowsState { + readonly listing: WebuiMiniAppListing; + /** Per plugin: the presentation this client holds. */ + readonly presentations: Readonly>; + /** Per plugin: the last refusal a start of it earned. */ + readonly refusals: Readonly>; + /** The plugin whose start is in flight, so its row can say so. */ + readonly opening?: string; + /** + * The standalone presentation this document declared, once the service answered. + * + * Absent on an inline client, and absent on a standalone one until the lease's + * first frame arrives — a host that does not wire the operation never sets it, + * and the page then renders without a presentation notice rather than refusing. + */ + readonly standalone?: WebuiMiniAppStandalone; + /** + * Why the standalone lease could not be established, when it could not. + * + * Distinct from an absent `standalone`: that is "no answer yet", this is "the + * host answered with a failure". Neither is reported as an ended session. + */ + readonly standaloneFailure?: string; + /** + * The capability's absence, when the host has no Mini App cell at all. + * + * A different answer from an empty catalogue, and it is said once here rather + * than deduced by each surface from a missing port member. + */ + readonly unavailable?: string; + /** + * The Mini App session the rail is showing because a page asked to see it. + * + * "Shown" is a claim about the client's selection, so it is recorded only after + * the view selected the session and the service was told. When the person + * leaves that session this is what makes the close report possible — and a + * session nobody shows must not stay answerable, or its permission asks wait on + * a panel that is gone. + */ + readonly agentShown?: WebuiMiniAppAgentPanel; + /** + * The presentation the page asked for, closed because the person left it. + * + * Kept so coming back re-reports it: the runtime denies an ask while nothing + * shows the session, and "the rail has it selected again" is a fact only a new + * `panelOpened` can convey. Dropped on unmount, navigation and stop, because + * those mean the page is not coming back. + */ + readonly agentResting?: WebuiMiniAppAgentPanel; +} + +/** One Mini App session a page asked the host to show, and where it is shown. */ +export interface WebuiMiniAppAgentPanel { + readonly pluginId: string; + readonly sessionId: string; + readonly hostSessionId: string; + readonly connection: number; +} + +export const initialWebuiMiniAppListing: WebuiMiniAppListing = { + rows: [], + loading: false, +}; + +export const initialWebuiMiniAppWorkflowsState: WebuiMiniAppWorkflowsState = { + listing: initialWebuiMiniAppListing, + presentations: {}, + refusals: {}, +}; + +/** The code a refused call carries, when it carries one. */ +function refusalOf(error: unknown): WebuiMiniAppRefusal { + const code = + error && typeof error === "object" && typeof (error as { code?: unknown }).code === "string" + ? ((error as { code: string }).code) + : "harness_error"; + return { + code, + message: error instanceof Error ? error.message : String(error), + }; +} + +export interface WebuiMiniAppWorkflows { + readonly getSnapshot: () => WebuiMiniAppWorkflowsState; + readonly subscribe: (listener: () => void) => () => void; + /** Whether the host exposes the capability at all. */ + readonly available: boolean; + /** Follow host-owned presenter requests for the shell to enact. */ + readonly watchPresenter: ( + onFrame: (intent: WebuiMiniAppPresentationIntent) => void, + onFailure?: (error: Error) => void, + ) => () => void; + + /** Read the catalogue. Rows carry the runtime's state; nothing is cached by key. */ + readonly loadListing: () => Promise; + /** + * Start one Mini App and keep its view URL. + * + * `false` means the start was refused: the refusal is in the snapshot under + * this plugin id, and both surfaces render it. + */ + readonly open: (pluginId: string) => Promise; + /** Reopen an inline frame after another document has replaced its connection. */ + readonly refreshInline: (pluginId: string) => Promise; + /** Stop one Mini App and clear its view, recording why. */ + readonly stop: (pluginId: string) => Promise; + /** + * Reconcile the open views with the runtime's own answer. + * + * A Mini App can stop without this client asking — the process exits, the + * agent stops it, another window stops it. The view it was shown in has to go, + * and the reason has to be said, so this is one read plus the transitions it + * implies. It never starts anything. + */ + readonly syncPresentations: () => Promise; + + /** + * Present this Mini App inline, or wait for the standalone tab that holds it. + * + * The result is what the caller's loop does next: + * + * * `"presented"` — this client now frames it (or already did), so stop asking. + * * `"waiting"` — a standalone presentation is live, or the catalogue could not + * be read. Ask again: the lease ends when that tab closes, and a fresh read + * is the only thing that notices. + * * `"blocked"` — a start was refused or the app was stopped. Restarting either + * is the person's act, never a timer's. + * + * Always a fresh catalogue read, which is the point: a view mounted while another + * document holds the presentation has no presentation of its own for + * `syncPresentations` to reconcile. + */ + readonly presentInline: ( + pluginId: string, + ) => Promise<"presented" | "waiting" | "blocked">; + + /** + * The standalone page for one Mini App, or `undefined` when the host does not + * wire the route. + * + * The URL is built by the wire because it carries the service token; a UI that + * assembled it would have to hold the credential, which is the one thing the + * client is never given. + */ + readonly standaloneUrl: (input: { + readonly pluginId: string; + readonly sessionId: string; + }) => string | undefined; + /** + * Declare this document as the standalone presentation, and hold the lease. + * + * Returns the release. The service ties the lease to the connection this opens, + * so calling it is what makes `detached` true for the inline view, and the + * release — a closed tab, an unmount — is what makes it false again. + */ + readonly claimStandalone: (input: { + readonly pluginId: string; + readonly sessionId: string; + }) => () => void; + + /** + * One followed turn for a page: the transport is the stream, `observe` is the + * page's own claim and travels inside the body. + */ + readonly watchAgentTurn: ( + request: WebuiMiniAppAgentTurnRequest, + onFrame: (frame: WebuiMiniAppAgentTurnFrame) => void, + onFailure: (error: Error) => void, + ) => () => void; + /** One short call. A host without the capability answers `runtime_unavailable`. */ + readonly agentCall: ( + request: WebuiMiniAppAgentCallRequest, + ) => Promise; + /** + * Report that the client now shows `sessionId` for `pluginId`. + * + * Called after the rail has actually selected it, never on the capability call: + * the runtime treats a shown session as answerable, so claiming it early makes + * its asks wait on a panel that is not on screen. + */ + readonly reportShown: (input: { + readonly pluginId: string; + readonly sessionId: string; + readonly hostSessionId: string; + readonly connection: number; + }) => Promise; + /** + * The selection the shell resolved. + * + * Leaving the shown session is the close report; staying on it changes nothing. + * The call is idempotent because a selection can be re-announced. + */ + readonly noteSelectedSession: (sessionId: string | undefined) => void; + /** + * Resolve once the client's selection is `sessionId`. + * + * A page's `open` becomes "shown" only when the rail has it, so the view waits + * for the selection to land before reporting. `false` means it did not land + * within the bound, and nothing was reported: claiming shown on a selection that + * never happened is what leaves the runtime waiting on a panel that is not there. + */ + readonly awaitSelection: (sessionId: string, timeoutMs?: number) => Promise; + /** The view is gone: nothing shows it any more, so stop counting it as shown. */ + readonly releaseShown: (pluginId: string) => void; + /** Package lifecycle, for the catalogue's apps area. */ + readonly setPluginEnabled: (pluginId: string, enabled: boolean) => Promise; + readonly uninstall: (pluginId: string) => Promise; +} + +/** + * The client's ordinary session selection, as the Mini App business follows it. + * + * Injected rather than discovered because selection belongs to the application + * store, and its owner is the shell's `select` command: that is what a rail click, + * a fork, an import and a delete all go through. A `hashchange` listener would miss + * every one of them — the rail selects through the store and `history.replaceState`, + * which fires no navigation event at all. + */ +export interface WebuiMiniAppSelectionSource { + readonly current: () => string | undefined; + /** Notified whenever the client's selection may have changed. */ + readonly subscribe: (listener: () => void) => () => void; +} + +export function createWebuiMiniAppWorkflows(deps: { + readonly port: WebuiMiniAppPortSlice; + readonly selection?: WebuiMiniAppSelectionSource; +}): WebuiMiniAppWorkflows { + const { port } = deps; + const available = + typeof port.listMiniApps === "function" && + typeof port.openMiniApp === "function" && + typeof port.stopMiniApp === "function"; + const pluginActions = createWebuiPluginActions({ pluginManagement: port.pluginManagement }); + const shownEpochs = new Map(); + let state: WebuiMiniAppWorkflowsState = available + ? initialWebuiMiniAppWorkflowsState + : { + ...initialWebuiMiniAppWorkflowsState, + unavailable: "当前运行时没有提供 Mini App 能力。", + }; + const listeners = new Set<() => void>(); + const refreshingInline = new Set(); + /** One catalogue read at a time: a second read while one is open is that read. */ + let inFlight: Promise | undefined; + + const set = ( + update: (current: WebuiMiniAppWorkflowsState) => WebuiMiniAppWorkflowsState, + ): void => { + const next = update(state); + if (next === state) return; + state = next; + for (const listener of listeners) listener(); + }; + + const setPresentation = ( + pluginId: string, + presentation: WebuiMiniAppPresentation | undefined, + ): void => { + set((current) => { + const presentations = { ...current.presentations }; + if (presentation) presentations[pluginId] = presentation; + else delete presentations[pluginId]; + return { ...current, presentations }; + }); + }; + + const setRefusal = (pluginId: string, refusal: WebuiMiniAppRefusal | undefined): void => { + set((current) => { + const refusals = { ...current.refusals }; + if (refusal) refusals[pluginId] = refusal; + else delete refusals[pluginId]; + return { ...current, refusals }; + }); + }; + + const readCatalogue = async (): Promise => { + if (!port.listMiniApps) return undefined; + const list = await port.listMiniApps(); + // A host that answers nothing, or something else, is an empty catalogue rather + // than a thrown render: a panel that cannot read "no rows" takes the whole + // workspace down with it. + const rows = webuiMiniAppRows(Array.isArray(list?.miniApps) ? list.miniApps : []); + set((current) => ({ ...current, listing: { rows, loading: false } })); + return list; + }; + + const loadListing = async (): Promise => { + if (!available) return; + set((current) => ({ ...current, listing: { ...current.listing, loading: true } })); + try { + inFlight = inFlight ?? readCatalogue(); + await inFlight; + } catch (error) { + set((current) => ({ + ...current, + listing: { rows: current.listing.rows, loading: false, error: refusalOf(error).message }, + })); + } finally { + inFlight = undefined; + } + }; + + const open = async (pluginId: string): Promise => { + if (!available || !port.openMiniApp) return false; + set((current) => ({ ...current, opening: pluginId })); + setPresentation(pluginId, { kind: "opening" }); + setRefusal(pluginId, undefined); + try { + const view = await port.openMiniApp({ pluginId }); + setPresentation(pluginId, { + kind: "open", + viewUrl: view.viewUrl, + connection: view.connection ?? 0, + presentationRevision: state.listing.rows.find((row) => row.pluginId === pluginId) + ?.presentationRevision ?? 0, + }); + // The catalogue can open a Mini App without mounting its panel view, so + // nothing else would refresh the runtime status after a successful start. + await loadListing(); + const revision = state.listing.rows.find((row) => row.pluginId === pluginId)?.presentationRevision; + if (revision !== undefined) { + const current = state.presentations[pluginId]; + if (current?.kind === "open" && current.connection === (view.connection ?? 0)) { + setPresentation(pluginId, { ...current, presentationRevision: revision }); + } + } + return true; + } catch (error) { + const refusal = refusalOf(error); + setPresentation(pluginId, undefined); + setRefusal(pluginId, refusal); + // The runtime refused to start, so the row it refused about is stale: a + // capacity refusal names the running apps, and those names come from here. + await loadListing(); + setRefusal(pluginId, refusal); + return false; + } finally { + set((current) => { + if (current.opening !== pluginId) return current; + const next = { ...current }; + delete next.opening; + return next; + }); + } + }; + + const refreshInline: WebuiMiniAppWorkflows["refreshInline"] = async (pluginId) => { + const current = state.presentations[pluginId]; + const row = state.listing.rows.find((candidate) => candidate.pluginId === pluginId); + if ( + current?.kind !== "open" || + row?.detached || + state.opening === pluginId || + refreshingInline.has(pluginId) || + row === undefined || + row.presentationRevision <= current.presentationRevision + ) { + return; + } + refreshingInline.add(pluginId); + try { + const listMiniApps = port.listMiniApps; + if (!listMiniApps) return; + let first: WebuiMiniAppCatalogueRow | undefined; + try { + first = (await listMiniApps()).miniApps.find((candidate) => candidate.pluginId === pluginId); + if (!first || first.detached || first.presentationRevision <= current.presentationRevision) return; + await new Promise((resolve) => setTimeout(resolve, MINIAPP_PRESENTATION_RECONNECT_GRACE_MS)); + const confirmed = (await listMiniApps()).miniApps.find((candidate) => candidate.pluginId === pluginId); + // A just-disconnected standalone tab gets a short reconnect window. Only + // reacquire when two fresh reads agree that its lease is still gone. + if ( + !confirmed || + confirmed.detached || + confirmed.presentationRevision !== first.presentationRevision || + confirmed.presentationRevision <= current.presentationRevision + ) return; + } catch { + // A failed confirmation says nothing about lease ownership. Leave the + // current presentation in place; the next catalogue refresh can retry. + return; + } + await open(pluginId); + } finally { + refreshingInline.delete(pluginId); + } + }; + + const stop = async (pluginId: string): Promise => { + if (!available || !port.stopMiniApp) return; + try { + await port.stopMiniApp({ pluginId }); + setPresentation(pluginId, { kind: "stopped", reason: "运行已停止。" }); + set((current) => { + const refusals = { ...current.refusals }; + for (const [refusedPluginId, refusal] of Object.entries(refusals)) { + if (refusedPluginId === pluginId || refusal.code === "BUSY") { + delete refusals[refusedPluginId]; + } + } + return { ...current, refusals }; + }); + } catch (error) { + setRefusal(pluginId, refusalOf(error)); + return; + } + await loadListing(); + }; + + const syncPresentations = async (): Promise => { + if (!available) return; + const openPlugins = Object.entries(state.presentations) + .filter(([, presentation]) => presentation.kind === "open") + .map(([pluginId]) => pluginId); + if (openPlugins.length === 0) return; + try { + await loadListing(); + } catch { + // A read that failed says nothing about a view that is up; the next one does. + return; + } + set((current) => { + let presentations: Record | undefined; + for (const pluginId of openPlugins) { + const row = current.listing.rows.find((candidate) => candidate.pluginId === pluginId); + const reason = webuiMiniAppStopReason(row); + if (!reason) continue; + presentations = presentations ?? { ...current.presentations }; + presentations[pluginId] = { kind: "stopped", reason }; + } + return presentations ? { ...current, presentations } : current; + }); + }; + + /** + * The standalone page, when the wire can build one. + * + * `undefined` rather than a hand-built path when the member is unwired: a link + * the client assembled itself would either omit the service token or carry one + * this layer does not hold. + */ + const standaloneUrl = (input: { + readonly pluginId: string; + readonly sessionId: string; + }): string | undefined => port.miniappStandaloneUrl?.(input); + + /** + * Take the standalone lease, and keep it until the returned release is called. + * + * The first frame is the service's answer about the lease; it is kept in the + * snapshot so the standalone surface can say what its deep link named. Nothing + * here starts the Mini App — the view does that, through `open`, exactly as the + * inline one does. + */ + const claimStandalone = (input: { + readonly pluginId: string; + readonly sessionId: string; + }): (() => void) => { + if (!available || !port.watchMiniAppPresentation) return () => undefined; + return port.watchMiniAppPresentation( + input, + (frame) => { + if (frame.pluginId !== input.pluginId) return; + set((current) => { + const next: WebuiMiniAppWorkflowsState = { + ...current, + standalone: { + pluginId: frame.pluginId, + sessionId: frame.sessionId, + presentationRevision: frame.presentationRevision, + session: frame.session, + }, + }; + if (current.standaloneFailure === undefined) return next; + const cleared = { ...next }; + delete cleared.standaloneFailure; + return cleared; + }); + }, + // A refused lease is a failure this surface reports. It is deliberately not + // stored as `standalone.session = "ended"`: nothing here knows what the + // session is, only that the service could not say. + (error) => { + set((current) => ({ + ...current, + standaloneFailure: error.message, + })); + }, + ); + }; + + /** + * Present here unless another document holds it, and keep asking while it does. + * + * The read is the mechanism, not an optimisation: the standalone lease is + * recorded by the service and released when that tab's socket closes, so a fresh + * catalogue read is the only way this document can learn it is free again. + */ + const presentInline = async ( + pluginId: string, + ): Promise<"presented" | "waiting" | "blocked"> => { + if (!available) return "blocked"; + const current = state; + const existing = current.presentations[pluginId]; + if (existing?.kind === "opening") return "presented"; + // A cleared presentation or a refusal is the person's to retry, never a timer's. + if (existing?.kind === "stopped" || current.refusals[pluginId] !== undefined) return "blocked"; + let catalogue: WebuiMiniAppCatalogue | undefined; + try { + catalogue = await readCatalogue(); + } catch { + // A read that failed says nothing about the lease, and starting on a guess + // could present a Mini App another tab is already showing. Ask again. + return "waiting"; + } + // A host that does not wire the catalogue has no presentation record to read, + // so there is nothing to wait for and nothing to honour: start it, as the + // mount always did. + if (catalogue === undefined) return (await open(pluginId)) ? "presented" : "blocked"; + const row = catalogue.miniApps.find((candidate) => candidate.pluginId === pluginId); + if (row?.detached === true) return "waiting"; + if (row?.runtime.status === "stopped") { + // Terminal, and not this client's doing: the runtime is down and nobody here + // asked it to stop, which is the cross-document case — this view was waiting + // on another document's lease when that document stopped the app. #256's rule + // is that a stopped runtime is reported and never reconnected without a + // person, so the state is recorded (which is what makes the surface say why + // and offer its own retry) and the loop ends. + setPresentation(pluginId, { kind: "stopped", reason: "运行已停止。" }); + return "blocked"; + } + // A local `open` may be stale after another browser document opened the + // standalone route and replaced the server connection generation. Reopen + // against the latest service state so this frame never keeps an obsolete id. + return (await open(pluginId)) ? "presented" : "blocked"; + }; + + /** + * One page turn. + * + * A host without the member — an older client, a runtime composed without the + * agent application — answers a documented code instead of a promise nobody + * settles. The refusal is delivered through `onFailure` so both paths settle the + * page's call the same way. + */ + const watchAgentTurn: WebuiMiniAppWorkflows["watchAgentTurn"] = (request, onFrame, onFailure) => { + if (!request.hostSessionId) { + onFailure(Object.assign(new Error("not_permitted"), { code: "not_permitted" })); + return () => undefined; + } + const watch = port.watchMiniAppAgentTurn; + if (!watch) { + onFailure(Object.assign(new Error("runtime_unavailable"), { code: "runtime_unavailable" })); + return () => undefined; + } + return watch(request, onFrame, onFailure); + }; + + const agentCall: WebuiMiniAppWorkflows["agentCall"] = async (request) => { + // No host session means no chat is showing this page, and the runtime refuses + // such a caller anyway. Answering here keeps the page from waiting on a reply + // the service would never send. + if (!request.hostSessionId) return { ok: false, code: "not_permitted" }; + const call = port.miniAppAgentCall; + if (!call) return { ok: false, code: "runtime_unavailable" }; + return call(request); + }; + + /** + * Tells the runtime one panel is no longer on screen. + * + * Best effort by this workflow's convention: a close that cannot be delivered is + * swallowed, because the runtime's own record is also closed by a stop, and a + * rejected report must never become an unhandled rejection. + */ + const closePanel = (panel: WebuiMiniAppAgentPanel): Promise => + agentCall({ + pluginId: panel.pluginId, + hostSessionId: panel.hostSessionId, + connection: panel.connection, + requestId: `panel-closed-${panel.sessionId}`, + method: "panelClosed", + targetSessionId: panel.sessionId, + }) + .then(() => undefined) + .catch(() => undefined); + + /** Forgets a remembered presentation, when it is still the one remembered. */ + const dropResting = (panel: WebuiMiniAppAgentPanel): void => { + set((current) => + current.agentResting === panel ? { ...current, agentResting: undefined } : current, + ); + }; + + /** Stops counting one shown session as answerable, and says so. */ + const closeShown = async ( + shown: WebuiMiniAppAgentPanel, + options: { readonly keep: boolean } = { keep: true }, + ): Promise => { + set((current) => + current.agentShown !== shown + ? current + : { + ...current, + agentShown: undefined, + ...(options.keep ? { agentResting: shown } : {}), + }, + ); + await closePanel(shown); + }; + + const reportShown: WebuiMiniAppWorkflows["reportShown"] = async (input) => { + const epoch = shownEpochs.get(input.pluginId) ?? 0; + const response = await agentCall({ + pluginId: input.pluginId, + hostSessionId: input.hostSessionId, + connection: input.connection, + requestId: `panel-shown-${input.sessionId}`, + method: "panelOpened", + targetSessionId: input.sessionId, + }); + // Only a session the runtime accepted is recorded: recording it on a refused + // report would make the close report name a panel the runtime never knew about. + if (!response.ok) return; + const selection = deps.selection; + if ( + (shownEpochs.get(input.pluginId) ?? 0) !== epoch || + (selection !== undefined && selection.current() !== input.sessionId) + ) { + // The view may have unmounted or the person may have moved on while the + // service accepted this report. Withdraw it before a permission ask can be + // routed to a page that is no longer present. + await closePanel(input); + return; + } + set((current) => ({ ...current, agentShown: input, agentResting: undefined })); + }; + + /** + * Report a resting presentation again, because the person selected it. + * + * The one report that makes a Mini App session's asks answerable while the rail + * has it. Guarded against a burst of store notifications: the owner notifies on + * every snapshot change, and a second `panelOpened` per tick is noise. + */ + let reopening = false; + const reopen = async (resting: WebuiMiniAppAgentPanel): Promise => { + if (reopening) return; + reopening = true; + try { + let response: Awaited>; + try { + response = await agentCall({ + pluginId: resting.pluginId, + hostSessionId: resting.hostSessionId, + connection: resting.connection, + requestId: `panel-shown-${resting.sessionId}`, + method: "panelOpened", + targetSessionId: resting.sessionId, + }); + } catch { + // A transport failure is handled like a refusal: the claim could not be + // made, and keeping the record would re-ask on every store notification + // with no better chance. A page that still wants it can open it again. + dropResting(resting); + return; + } + if (!response.ok) { + // The runtime refused to be told this session is shown — a stopped + // runtime, a revoked generation. Keeping the record would re-ask on every + // store notification, and nothing this client can do would succeed, so the + // claim is dropped instead. + dropResting(resting); + return; + } + // The person may have left while the report was in flight. The runtime has + // now been told this session is on screen, so it has to be told otherwise + // before this client records a session nobody is looking at as shown. + const selection = deps.selection; + if (selection && selection.current() !== resting.sessionId) { + await closePanel(resting); + return; + } + set((current) => + current.agentResting !== resting + ? current + : { ...current, agentShown: resting, agentResting: undefined }, + ); + } finally { + reopening = false; + } + }; + + const noteSelectedSession: WebuiMiniAppWorkflows["noteSelectedSession"] = (sessionId) => { + const shown = state.agentShown; + if (shown) { + if (shown.sessionId === sessionId) return; + // Leaving it withdraws the fact the runtime acts on. + void closeShown(shown); + return; + } + const resting = state.agentResting; + // Coming back is a new claim: without it the session stays unanswerable while + // the rail shows it, which is the state AC6 says must be answerable. + if (resting && resting.sessionId === sessionId) void reopen(resting); + }; + + /** + * Resolve when the client's selection is (or becomes) `sessionId`. + * + * A subscription rather than a poll: the owner notifies, and the answer is the + * owner's state at that moment. The bound exists because a selection can be + * refused elsewhere (a session that no longer exists), and waiting forever would + * leave the page's `open` promise unsettled. + */ + const awaitSelection: WebuiMiniAppWorkflows["awaitSelection"] = (sessionId, timeoutMs = 2_000) => { + const selection = deps.selection; + if (!selection) return Promise.resolve(false); + if (selection.current() === sessionId) return Promise.resolve(true); + return new Promise((resolve) => { + let done = false; + let unsubscribe: () => void = () => undefined; + let timer: ReturnType | undefined; + const finish = (value: boolean): void => { + if (done) return; + done = true; + unsubscribe(); + if (timer !== undefined) clearTimeout(timer); + resolve(value); + }; + unsubscribe = selection.subscribe(() => { + if (selection.current() === sessionId) finish(true); + }); + timer = setTimeout(() => finish(false), timeoutMs); + // The selection may have landed between the first read and the subscription. + if (selection.current() === sessionId) finish(true); + }); + }; + + const releaseShown: WebuiMiniAppWorkflows["releaseShown"] = (pluginId) => { + shownEpochs.set(pluginId, (shownEpochs.get(pluginId) ?? 0) + 1); + const shown = state.agentShown; + if (shown && shown.pluginId === pluginId) { + // Nothing shows it any more and nothing is coming back, so the lease is + // closed and forgotten rather than kept for a later selection. + void closeShown(shown, { keep: false }); + return; + } + const resting = state.agentResting; + if (resting && resting.pluginId === pluginId) { + // A session switch unmounts its workspace tab after `noteSelectedSession` + // has closed the shown lease. Keep that panel so selecting the session + // again can truthfully report it shown; an unmount while it is still the + // selected session is a real close and must not be revived later. + const selectedSessionId = deps.selection?.current(); + if (selectedSessionId !== undefined && selectedSessionId !== resting.sessionId) { + return; + } + set((current) => + current.agentResting === resting ? { ...current, agentResting: undefined } : current, + ); + } + }; + + const mutatePlugin = async ( + action: "enablePlugin" | "disablePlugin" | "uninstallPlugin", + pluginId: string, + ): Promise => { + if (!port.pluginManagement) return; + try { + await pluginActions.invoke(action, { pluginName: pluginId }); + } catch (error) { + set((current) => ({ ...current, listing: { ...current.listing, error: refusalOf(error).message } })); + return; + } + await loadListing(); + }; + + // Follow the selection owner: every change either keeps the shown session or + // closes its lease, which is the fact the runtime's approval router reads. The + // subscription lives as long as the store and this owner do, which is the + // application's own lifetime, so it is never released separately. + deps.selection?.subscribe(() => { + noteSelectedSession(deps.selection?.current()); + }); + + return { + getSnapshot: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + available, + watchPresenter: (onFrame, onFailure) => + port.watchMiniAppPresenter?.(onFrame, onFailure) ?? (() => undefined), + loadListing, + open, + refreshInline, + stop, + syncPresentations, + presentInline, + standaloneUrl, + claimStandalone, + watchAgentTurn, + agentCall, + reportShown, + noteSelectedSession, + awaitSelection, + releaseShown, + setPluginEnabled: (pluginId, enabled) => + mutatePlugin(enabled ? "enablePlugin" : "disablePlugin", pluginId), + uninstall: (pluginId) => mutatePlugin("uninstallPlugin", pluginId), + }; +} diff --git a/packages/webui/src/client/features/miniapp/bindings/use-miniapp-workflows.tsx b/packages/webui/src/client/features/miniapp/bindings/use-miniapp-workflows.tsx new file mode 100644 index 000000000..95cacbe52 --- /dev/null +++ b/packages/webui/src/client/features/miniapp/bindings/use-miniapp-workflows.tsx @@ -0,0 +1,53 @@ +// The Mini App business's binding: one provider, two hooks. +// +// The owner is built once by the composition root (`application/create-application.ts`) +// and provided here, so a surface submits commands and reads the snapshot rather +// than constructing a second owner. With no provider mounted — SSR, a unit test +// that mounts the view on its own — the hooks read the initial snapshot and the +// component renders "no Mini App capability", which is the same thing a host +// without the cell does. + +import { createContext, useContext, useSyncExternalStore, type ReactNode } from "react"; + +import { + initialWebuiMiniAppWorkflowsState, + type WebuiMiniAppWorkflows, + type WebuiMiniAppWorkflowsState, +} from "../application/miniapp-workflows.js"; + +const WebuiMiniAppWorkflowsContext = createContext(undefined); + +export function WebuiMiniAppWorkflowsProvider({ + workflows, + children, +}: { + readonly workflows: WebuiMiniAppWorkflows; + readonly children: ReactNode; +}): JSX.Element { + return ( + + {children} + + ); +} + +/** The owner, for a component that submits commands. */ +export function useWebuiMiniAppWorkflows(): WebuiMiniAppWorkflows | undefined { + return useContext(WebuiMiniAppWorkflowsContext); +} + +const noopSubscribe = (): (() => void) => () => undefined; + +function initialSnapshot(): WebuiMiniAppWorkflowsState { + return initialWebuiMiniAppWorkflowsState; +} + +/** The snapshot, for a component that renders it. */ +export function useWebuiMiniAppWorkflowsState(): WebuiMiniAppWorkflowsState { + const workflows = useContext(WebuiMiniAppWorkflowsContext); + return useSyncExternalStore( + workflows ? workflows.subscribe : noopSubscribe, + workflows ? workflows.getSnapshot : initialSnapshot, + workflows ? workflows.getSnapshot : initialSnapshot, + ); +} diff --git a/packages/webui/src/client/features/miniapp/contracts/miniapp-port.ts b/packages/webui/src/client/features/miniapp/contracts/miniapp-port.ts new file mode 100644 index 000000000..c6de0ecbe --- /dev/null +++ b/packages/webui/src/client/features/miniapp/contracts/miniapp-port.ts @@ -0,0 +1,110 @@ +// Mini App capability port. +// +// The three operations the WebUI's Mini App business drives: the catalogue, the +// launch and the stop, plus the standalone presentation lease and the deep link +// that opens one. Split from the monolithic transport the way every other +// business's port is, and composed by `transport.ts`. +// +// Every method stays optional — `undefined` means "the operation is not wired", +// which is the state a host without the Mini App cell answers with. The business +// renders that as a report ("this host has no Mini App capability") rather than +// calling into an absent member. +// +// Two things are deliberately absent. The Mini App's own origin, its surface path +// and the launch credential never appear here: the server holds them and the +// client is handed the proxy's `viewUrl` (`shared/contracts/miniapp.ts`). And +// there is no "attach": returning to the inline presentation is closing the +// standalone tab, which releases the lease — the standalone page has no control +// that would need one (ADR 0020). + +import type { + WebuiMiniAppCatalogue, + WebuiMiniAppPresentationState, + WebuiMiniAppPresentationIntent, + WebuiMiniAppView, +} from "../../../../shared/contracts/miniapp.js"; +import type { + WebuiMiniAppAgentCallRequest, + WebuiMiniAppAgentCallResponse, + WebuiMiniAppAgentTurnFrame, + WebuiMiniAppAgentTurnRequest, +} from "../../../../shared/contracts/miniapp-agent.js"; + +export interface MiniAppPort { + /** Follow the host-owned agent presentation request and enact it in the shell. */ + readonly watchMiniAppPresenter?: ( + onFrame: (intent: WebuiMiniAppPresentationIntent) => void, + onFailure?: (error: Error) => void, + ) => () => void; + /** + * The installed Mini Apps, with the runtime state and the live presentation the + * row renders. + * + * `detached` and `presentationRevision` are the service's record, which is why + * this answers `WebuiMiniAppCatalogue` and not the runtime's own list: what a + * row says about presentation is not something the runtime knows. + */ + readonly listMiniApps?: () => Promise; + /** + * Starts one Mini App and answers with the loopback URL the panel frames. + * + * A refusal is the runtime's: `OWNED_ELSEWHERE` names the client that holds the + * app, `BUSY` carries the capacity refusal. The business reports the message + * it was given rather than inventing one, because the runtime is the only + * party that knows which of the two happened. + */ + readonly openMiniApp?: (request: { + readonly pluginId: string; + }) => Promise; + /** Stops one Mini App. The server closes that plugin's proxy with it. */ + readonly stopMiniApp?: (request: { + readonly pluginId: string; + }) => Promise<{ readonly success: true }>; + /** + * Declares this document as the standalone presentation of one Mini App, and + * holds the lease until the returned function is called. + * + * This is the whole of ADR 0020's lease from the client's side: the call opens a + * connection the service holds the record against, the first frame says what the + * lease is (its revision, and whether the deep link's session is still live), + * and the release is that subscription ending — which is what closing the tab + * does to it. + */ + readonly watchMiniAppPresentation?: ( + request: { readonly pluginId: string; readonly sessionId: string }, + onFrame: (state: WebuiMiniAppPresentationState) => void, + /** + * The lease could not be established — a refused session lookup, a missing + * capability. Reported rather than folded into "no lease", because a stand-in + * that cannot answer is not a closed tab. + */ + onFailure?: (error: Error) => void, + ) => () => void; + /** + * One followed turn: the page's `run` / `answer`. + * + * The body carries `observe`, because whether the page is listening decides + * whether the runtime may wait on it for a permission or questionnaire answer — + * and a stream existing is not the same fact. The detach ends this turn's socket + * and nothing else. + */ + readonly watchMiniAppAgentTurn?: ( + request: WebuiMiniAppAgentTurnRequest, + onFrame: (frame: WebuiMiniAppAgentTurnFrame) => void, + onFailure?: (error: Error) => void, + ) => () => void; + /** + * One short Mini App agent call, its refusal included in the body. + * + * A refusal is a body rather than a thrown error because the page has to receive + * its documented code; only a transport failure throws here. + */ + readonly miniAppAgentCall?: ( + request: WebuiMiniAppAgentCallRequest, + ) => Promise; + /** The standalone page's URL, token included. Built by the wire, not the UI. */ + readonly miniappStandaloneUrl?: (request: { + readonly pluginId: string; + readonly sessionId: string; + }) => string; +} diff --git a/packages/webui/src/client/features/miniapp/projection/miniapp-view.ts b/packages/webui/src/client/features/miniapp/projection/miniapp-view.ts new file mode 100644 index 000000000..213b11d4f --- /dev/null +++ b/packages/webui/src/client/features/miniapp/projection/miniapp-view.ts @@ -0,0 +1,170 @@ +// The Mini App catalogue, projected for the two surfaces that render it. +// +// Pure functions: a row list in, what a row says out. The picker and the plugin +// catalogue's apps area read the same projection, which is what makes them the +// same rows rather than two readings of the runtime that can disagree +// (ADR 0020, "Its rows and the plugin catalogue's apps area are the same rows"). +// +// The runtime names five states and the row offers a different action for each, +// so the decision lives here rather than being re-derived in two components. + +import type { + WebuiMiniAppCatalogueRow, + WebuiMiniAppRuntimeStatus, +} from "../../../../shared/contracts/miniapp.js"; + +/** + * How many Mini Apps run at once. + * + * The runtime enforces it (`at most three run at once`, ADR 0020) and refuses a + * fourth with `BUSY`. The number is repeated here only to say so in words before + * the refusal arrives; it is not this client's rule to enforce. + */ +export const WEBUI_MINIAPP_CAPACITY = 3; + +export interface WebuiMiniAppRow { + readonly pluginId: string; + /** The catalogue's display name, or the plugin id when it has none. */ + readonly name: string; + readonly description: string; + readonly iconUrl?: string; + readonly status: WebuiMiniAppRuntimeStatus; + /** The runtime's own failure code, when the row failed. */ + readonly errorCode?: string; + /** The state in words, for the row. */ + readonly statusLabel: string; + /** True while the runtime is up, which is when the row offers "stop". */ + readonly running: boolean; + /** + * False for a package that is installed but disabled. + * + * Such a row can be enabled but not started: the runtime refuses to launch a + * disabled package, so offering "open" on it would be an action that always + * fails. + */ + readonly enabled: boolean; + /** The runtime guard confirms another client owns this plugin's process. */ + readonly ownedElsewhere: boolean; + /** Owner's client label, when the live process lease exposes it. */ + readonly ownerClient?: string; + /** + * True while a standalone presentation of this Mini App is live (ADR 0020). + * + * The inline view reports it and offers to switch; it does not start a second + * presentation of the same app while this is true. + */ + readonly detached: boolean; + /** How many times a standalone presentation has been acquired. Moves on each. */ + readonly presentationRevision: number; +} + +/** The state in words. A closed union in, a label out — never "unknown". */ +export function webuiMiniAppStatusLabel(status: WebuiMiniAppRuntimeStatus): string { + switch (status) { + case "running": + return "运行中"; + case "starting": + return "启动中"; + case "stopping": + return "停止中"; + case "failed": + return "启动失败"; + case "stopped": + return "已停止"; + } +} + +/** One catalogue answer, as the surfaces render it. */ +export function webuiMiniAppRows( + miniApps: readonly WebuiMiniAppCatalogueRow[], +): readonly WebuiMiniAppRow[] { + return miniApps.map((miniApp) => { + // A disabled package has no runtime state to report: it is installed and not + // startable, which is a fact about the package rather than about a process. + const enabled = miniApp.enabled !== false; + return { + pluginId: miniApp.pluginId, + name: miniApp.displayName || miniApp.pluginId, + description: miniApp.description ?? "", + ...(miniApp.iconUrl !== undefined ? { iconUrl: miniApp.iconUrl } : {}), + status: miniApp.runtime.status, + ...(miniApp.runtime.errorCode !== undefined ? { errorCode: miniApp.runtime.errorCode } : {}), + statusLabel: !enabled + ? "已停用" + : miniApp.runtime.ownership === "other-client" + ? miniApp.runtime.ownerClient + ? `其他客户端运行中(${miniApp.runtime.ownerClient})` + : "其他客户端运行中" + : webuiMiniAppStatusLabel(miniApp.runtime.status), + running: enabled && miniApp.runtime.status === "running", + enabled, + ownedElsewhere: miniApp.runtime.ownership === "other-client", + ...(miniApp.runtime.ownerClient ? { ownerClient: miniApp.runtime.ownerClient } : {}), + detached: miniApp.detached, + presentationRevision: miniApp.presentationRevision, + }; + }); +} + +/** + * The browsing-context name a Mini App's standalone tab claims. + * + * `window.open` with a name either creates that context or focuses the one that + * already has it, so this is the whole mechanism behind "switch to the tab + * instead of opening a second document": the standalone surface sets its + * `window.name` to this on mount, and the inline view opens or focuses the same + * name. Deterministic from the plugin id because the two documents share nothing + * but the service and this convention. + */ +export function webuiMiniAppWindowName(pluginId: string): string { + return `webui-miniapp:${pluginId}`; +} + +/** The rows that are up, in catalogue order. */ +export function webuiMiniAppRunningRows( + rows: readonly WebuiMiniAppRow[], +): readonly WebuiMiniAppRow[] { + return rows.filter((row) => row.running); +} + +/** + * What a capacity refusal says: the limit, and which applications are using it. + * + * The person's next action is to stop one of them, so the refusal names them + * rather than reporting a count — "3 running" is not something anyone can act on. + */ +export function webuiMiniAppCapacityText(rows: readonly WebuiMiniAppRow[]): string { + const names = webuiMiniAppRunningRows(rows).map((row) => row.name); + const running = names.length > 0 ? names.join("、") : "其他应用"; + return `同时最多运行 ${WEBUI_MINIAPP_CAPACITY} 个 Mini App,正在运行:${running}。请先停止一个再打开。`; +} + +/** + * What a refusal says, in words the person can act on. + * + * `OWNED_ELSEWHERE` is the runtime's answer when another client holds the app — + * the message it carries names that client and its pid, and this client cannot + * discover either on its own: the credential that reaches the app never leaves + * the process that minted it, so "is it running elsewhere?" is answered by + * trying to start it. The message is kept rather than replaced, because the + * runtime is the only party that knows who the other client is. + */ +export function webuiMiniAppRefusalText(input: { + readonly code: string; + readonly message: string; + readonly rows: readonly WebuiMiniAppRow[]; +}): string { + if (input.code === "BUSY") return webuiMiniAppCapacityText(input.rows); + if (input.code === "OWNED_ELSEWHERE") return `已在其他客户端运行:${input.message}`; + return input.message; +} + +/** Why a view was cleared, in words. `undefined` means "still up". */ +export function webuiMiniAppStopReason(row: WebuiMiniAppRow | undefined): string | undefined { + if (!row) return "该应用已不在应用列表中。"; + if (row.status === "failed") { + return row.errorCode ? `运行失败(${row.errorCode})。` : "运行失败。"; + } + if (row.status === "stopped") return "运行已停止。"; + return undefined; +} diff --git a/packages/webui/src/client/features/miniapp/ui/MiniAppAppsList.public.tsx b/packages/webui/src/client/features/miniapp/ui/MiniAppAppsList.public.tsx new file mode 100644 index 000000000..b7c7639fd --- /dev/null +++ b/packages/webui/src/client/features/miniapp/ui/MiniAppAppsList.public.tsx @@ -0,0 +1,161 @@ +// The plugin catalogue's apps area, as the Mini App business publishes it. +// +// ADR 0020 gives this surface the list and the lifecycle and gives the Mini App +// business the state: the catalogue mounts this component and holds nothing of +// its own. That is why the component takes no rows, no loading flag and no error +// — it reads them from its own owner, and a row's runtime state is the same read +// the launcher's picker makes. +// +// "Install" is the one action a row cannot carry: a Mini App *is* a plugin, and +// installing one is installing its package from the marketplace. The area keeps +// the affordance and hands it to the catalogue, which owns switching to that +// view; what this component does not do is invent an install that installs +// something already installed. + +import { useEffect, type ReactElement } from "react"; + +import { + useWebuiMiniAppWorkflows, + useWebuiMiniAppWorkflowsState, +} from "../bindings/use-miniapp-workflows.js"; +import { webuiMiniAppRefusalText, webuiMiniAppWindowName } from "../projection/miniapp-view.js"; + +export interface MiniAppAppsListProps { + /** Offered when the catalogue can switch to the plugin marketplace. */ + readonly onInstallRequest?: () => void; + /** The session the standalone Mini App page should present. */ + readonly selectedSessionId?: string; +} + +export function MiniAppAppsList({ onInstallRequest, selectedSessionId }: MiniAppAppsListProps): ReactElement { + const workflows = useWebuiMiniAppWorkflows(); + const state = useWebuiMiniAppWorkflowsState(); + const { rows, loading, error } = state.listing; + + useEffect(() => { + void workflows?.loadListing(); + const refresh = () => { + if (document.visibilityState === "visible") void workflows?.loadListing(); + }; + window.addEventListener("focus", refresh); + document.addEventListener("visibilitychange", refresh); + return () => { + window.removeEventListener("focus", refresh); + document.removeEventListener("visibilitychange", refresh); + }; + }, [workflows]); + + if (state.unavailable) return

{state.unavailable}

; + if (loading && rows.length === 0) { + return ( +

+ 加载中… +

+ ); + } + + return ( +
+ {onInstallRequest ? ( +
+ +
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + {rows.length === 0 ?

暂无已安装的 Mini App

: null} +
+ {rows.map((row) => { + const refusal = state.refusals[row.pluginId]; + const opened = state.presentations[row.pluginId]; + const standaloneHref = + opened?.kind === "open" && selectedSessionId + ? workflows?.standaloneUrl({ pluginId: row.pluginId, sessionId: selectedSessionId }) + : undefined; + return ( +
+ +
+

{row.name}

+

{row.description || row.pluginId}

+ {refusal ? ( +

+ {webuiMiniAppRefusalText({ + code: refusal.code, + message: refusal.message, + rows, + })} +

+ ) : null} +
+ + {row.statusLabel} + {row.errorCode ? `(${row.errorCode})` : ""} + +
+ {row.enabled ? (<> + + {opened?.kind === "open" && standaloneHref !== undefined ? ( + + ) : null} + + + + ) : ( + // A disabled package has nothing to start or stop; the one + // action it has is the one that makes it startable again. + + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/packages/webui/src/client/features/miniapp/ui/MiniAppPicker.public.tsx b/packages/webui/src/client/features/miniapp/ui/MiniAppPicker.public.tsx new file mode 100644 index 000000000..d6315b1f5 --- /dev/null +++ b/packages/webui/src/client/features/miniapp/ui/MiniAppPicker.public.tsx @@ -0,0 +1,145 @@ +// The Mini App picker the workspace panel's launcher opens. +// +// Same listing as the plugin catalogue's apps area — one owner, one read — so a +// row here and a row there cannot disagree about what is running. The picker's own +// job is small: show the rows with their state, start the one that was chosen, and +// name the running ones when the runtime refuses a fourth. +// +// It opens the view by asking its owner to start the Mini App and then telling the +// workspace which tab to open. That direction is ADR 0020's: the business issues +// the workspace panel's own command, it never reaches into the panel's state. + +import { useEffect, type ReactElement } from "react"; + +import { + useWebuiMiniAppWorkflows, + useWebuiMiniAppWorkflowsState, +} from "../bindings/use-miniapp-workflows.js"; +import { + webuiMiniAppCapacityText, + webuiMiniAppRefusalText, + webuiMiniAppRunningRows, +} from "../projection/miniapp-view.js"; + +export interface MiniAppPickerProps { + /** The session the chosen Mini App's view belongs to. */ + readonly sessionId?: string; + /** Called once a Mini App is started, with the plugin the tab is for. */ + readonly onOpened: (pluginId: string) => void; + readonly onClose?: () => void; +} + +export function MiniAppPicker({ sessionId, onOpened, onClose }: MiniAppPickerProps): ReactElement { + const workflows = useWebuiMiniAppWorkflows(); + const state = useWebuiMiniAppWorkflowsState(); + const { loading } = state.listing; + // Only a startable package belongs here: the picker exists to open one, and a + // disabled Mini App cannot be opened — the catalogue's apps area is where it is + // enabled, and this surface would otherwise offer an action that always fails. + const rows = state.listing.rows.filter((row) => row.enabled); + const running = webuiMiniAppRunningRows(rows); + const refused = Object.values(state.refusals); + const capacityRefused = refused.some((refusal) => refusal.code === "BUSY"); + + useEffect(() => { + void workflows?.loadListing(); + const refresh = () => { + if (document.visibilityState === "visible") void workflows?.loadListing(); + }; + window.addEventListener("focus", refresh); + document.addEventListener("visibilitychange", refresh); + return () => { + window.removeEventListener("focus", refresh); + document.removeEventListener("visibilitychange", refresh); + }; + }, [workflows]); + + return ( +
+
+ 选择一个 Mini App + {onClose ? ( + + ) : null} +
+ {state.unavailable ?

{state.unavailable}

: null} + {loading && rows.length === 0 ? ( +

+ 加载中… +

+ ) : null} + {!state.unavailable && !loading && rows.length === 0 ? ( +

暂无已安装的 Mini App

+ ) : null} + {/* A refused fourth start says which applications are using the capacity, + and each of them carries the stop button that frees one. */} + {capacityRefused ? ( +

+ {webuiMiniAppCapacityText(rows)} +

+ ) : null} +
+ {rows.map((row) => { + const refusal = state.refusals[row.pluginId]; + return ( +
+
+

{row.name}

+

{row.description || row.pluginId}

+ {refusal ? ( +

+ {webuiMiniAppRefusalText({ + code: refusal.code, + message: refusal.message, + rows, + })} +

+ ) : null} +
+ + {row.statusLabel} + {row.errorCode ? `(${row.errorCode})` : ""} + +
+ + +
+
+ ); + })} +
+ {sessionId ? null : ( +

当前没有会话,Mini App 会打开在第一个标签页里。

+ )} + {running.length > 0 ? ( +

+ 正在运行:{running.map((row) => row.name).join("、")} +

+ ) : null} +
+ ); +} diff --git a/packages/webui/src/client/features/miniapp/ui/MiniAppStandalone.public.tsx b/packages/webui/src/client/features/miniapp/ui/MiniAppStandalone.public.tsx new file mode 100644 index 000000000..5770ddb12 --- /dev/null +++ b/packages/webui/src/client/features/miniapp/ui/MiniAppStandalone.public.tsx @@ -0,0 +1,95 @@ +// The standalone Mini App page: one Mini App, and nothing else. +// +// ADR 0020's second presentation. A browser tab opened through +// `/miniapp?plugin=&session=` runs this same client, and the shell mounts this +// component instead of the workspace: what a person sees is the Mini App view +// alone, with no rail, no conversation and no panel. +// +// Three properties are decisions: +// +// * **It holds the lease.** Mounting declares the presentation and unmounting +// releases it, and the service ties the record to the connection this opens — +// so closing the tab is what ends the presentation, which is why there is no +// control that would close it (closing the tab is the control). +// * **An ended session still shows the app.** The Mini App is plugin-scoped, so +// a page outlives the session its link named; the lease's first frame reports +// that, and this renders the notice beside the page rather than instead of it. +// * **No way back inline.** Returning to the panel is closing this tab, so this +// surface offers no such control — the inline view is where a person goes to +// switch *to* this presentation, never from it. + +import { useEffect, type ReactElement } from "react"; + +import { + useWebuiMiniAppWorkflows, + useWebuiMiniAppWorkflowsState, +} from "../bindings/use-miniapp-workflows.js"; +import { MiniAppView } from "./MiniAppView.public.js"; + +export interface MiniAppStandaloneProps { + readonly pluginId: string; + readonly sessionId: string; +} + +export function MiniAppStandalone({ + pluginId, + sessionId, +}: MiniAppStandaloneProps): ReactElement { + const workflows = useWebuiMiniAppWorkflows(); + const state = useWebuiMiniAppWorkflowsState(); + + /** + * Take the lease for this document, and give it back when the page goes away. + * + * Re-subscribing when the ids change is the reload case: a new deep link in the + * same context replaces the old presentation rather than holding both. + */ + useEffect(() => { + if (!workflows) return; + return workflows.claimStandalone({ pluginId, sessionId }); + }, [pluginId, sessionId, workflows]); + + const standalone = state.standalone; + const sessionEnded = + standalone !== undefined && + standalone.pluginId === pluginId && + standalone.session === "ended"; + const sessionUnresolved = + standalone !== undefined && + standalone.pluginId === pluginId && + standalone.session === "unresolved"; + + return ( +
+ {sessionEnded ? ( +

+ 该会话已结束,Mini App 仍在运行。 +

+ ) : null} + {sessionUnresolved ? ( +

+ 无法确认会话状态,Mini App 仍在运行。 +

+ ) : null} + {state.standaloneFailure !== undefined ? ( +

+ 无法确认会话状态:{state.standaloneFailure} +

+ ) : null} + +
+ ); +} diff --git a/packages/webui/src/client/features/miniapp/ui/MiniAppView.public.tsx b/packages/webui/src/client/features/miniapp/ui/MiniAppView.public.tsx new file mode 100644 index 000000000..8f406e552 --- /dev/null +++ b/packages/webui/src/client/features/miniapp/ui/MiniAppView.public.tsx @@ -0,0 +1,420 @@ +// The Mini App view the workspace panel frames, and the standalone page reuses. +// +// Published by the Mini App business because the state it renders is this +// business's: which view URL a Mini App is presented at, whether it is starting, +// why a view was cleared, and whether the same Mini App is already on screen in a +// browser tab of its own. The panel mounts it for its `miniapp` tab kind and stays +// the layout owner; it learns nothing about Mini Apps beyond the plugin id its tab +// carries. +// +// Four properties are worth stating, because all four were decisions: +// +// * **The iframe is not sandboxed.** Isolation is origin separation (ADR 0020): +// the document comes from the proxy's own loopback origin, so it runs in its +// own process and cannot read this client's credential. A sandbox attribute +// would instead deny the page the storage and runtime a Mini App is a +// Mini App because of. +// * **A stopped runtime is a rendered state, not an empty frame.** The view +// remembers why it was cleared and offers one button to reopen it. Nothing +// reopens on its own: a tab may be unattended, and a retry loop would restart +// an application the person did not ask for. +// * **One presentation is live at a time.** While a standalone tab holds the +// lease, this view reports that and offers to switch to it — it does not frame +// a second presentation and does not start anything. `detached` comes from the +// service's record, not from this component's own guess. +// * **The standalone page has no way back.** `surface="standalone"` renders the +// same view without the move controls, because returning is closing the tab +// and that releases the lease (ADR 0020). It also claims the browsing-context +// name the inline control focuses. + +import { + useCallback, + useEffect, + useRef, + type ReactElement, +} from "react"; + +import { + WEBUI_MINIAPP_BRIDGE_APPEND_REPLY, + type WebuiMiniAppComposerResult, +} from "../../../../shared/contracts/miniapp.js"; +import { useWebuiComposerStore } from "../../../bindings/composer-store.js"; +import { + useWebuiMiniAppWorkflows, + useWebuiMiniAppWorkflowsState, +} from "../bindings/use-miniapp-workflows.js"; +import { WEBUI_COMPOSER_HOME_KEY } from "../../composer/contracts/composer-state.js"; +import { + webuiMiniAppBridgeAppend, + webuiMiniAppBridgeRequest, + webuiMiniAppBridgeSenderAllowed, +} from "../application/miniapp-bridge.js"; +import { createWebuiMiniAppAgentBridge } from "../application/miniapp-agent-bridge.js"; +import { sessionHash } from "../../../projection/common/router.js"; +import { + webuiMiniAppRefusalText, + webuiMiniAppWindowName, +} from "../projection/miniapp-view.js"; + +/** + * How often an open view asks the runtime whether it is still up. + * + * One read of a small catalogue. A Mini App can stop without this client asking, + * and the alternative to asking is a frame that silently serves a dead page — the + * case ADR 0020 calls out ("a stopped runtime clears the view and reports why"). + * The same read is what tells this view that a standalone tab took the + * presentation, or gave it back. + */ +const PRESENTATION_SYNC_MS = 2_000; + +/** Which of ADR 0020's two presentations is mounting this view. */ +export type WebuiMiniAppSurface = "inline" | "standalone"; + +export interface MiniAppViewProps { + readonly pluginId: string; + /** The session whose composer a confirmed append writes to. */ + readonly sessionId?: string; + /** The tab's own title, when the panel has one. */ + readonly title?: string; + /** + * The presentation this view is, `inline` by default. + * + * The only difference is the move controls: an inline view offers to open a + * standalone one and to switch to it, while the standalone view offers neither. + */ + readonly surface?: WebuiMiniAppSurface; +} + +export function MiniAppView({ + pluginId, + sessionId, + title, + surface = "inline", +}: MiniAppViewProps): ReactElement { + const workflows = useWebuiMiniAppWorkflows(); + const state = useWebuiMiniAppWorkflowsState(); + const composer = useWebuiComposerStore(); + const presentation = state.presentations[pluginId]; + const refusal = state.refusals[pluginId]; + const row = state.listing.rows.find((candidate) => candidate.pluginId === pluginId); + const name = row?.name ?? pluginId; + const standalone = surface === "standalone"; + /** + * Whether another document is presenting this Mini App right now. + * + * Never true for the standalone surface: it is the document holding the lease, + * so reporting itself as "open elsewhere" would be the one wrong answer. + */ + const detached = !standalone && row?.detached === true; + const frameRef = useRef(null); + /** + * The bridge this view currently answers through, and how many documents it has + * framed. + * + * A page can navigate itself, and that destroys the pending map inside the + * document it was running in. The count is what tells the first load (this + * view's own document arriving) from a later one (the page replacing itself). + */ + const agentHostRef = useRef | null>(null); + const frameLoadsRef = useRef(0); + + /** + * Present the Mini App here, or wait for the standalone tab that holds it. + * + * The first ask starts it — that is what opening a tab means — and every later + * ask is one catalogue read, because the only event that says the tab closed is + * the record itself. `presentInline` refuses to restart a refused or stopped + * Mini App, so this loop cannot turn into a retry the person did not ask for. + * + * The standalone surface never waits: it *is* the presentation, so the lease the + * catalogue reports is its own. + */ + useEffect(() => { + if (!workflows || presentation !== undefined) return; + if (standalone) { + void workflows.open(pluginId); + return; + } + let stopped = false; + let timer: ReturnType | undefined; + const ask = async (): Promise => { + const outcome = await workflows.presentInline(pluginId).catch(() => "waiting" as const); + if (stopped) return; + if (outcome !== "waiting") { + if (timer !== undefined) clearInterval(timer); + return; + } + timer ??= setInterval(() => void ask(), PRESENTATION_SYNC_MS); + }; + void ask(); + return () => { + stopped = true; + if (timer !== undefined) clearInterval(timer); + }; + }, [pluginId, presentation, standalone, workflows]); + + /** While a view is up, the runtime is asked whether it still is. */ + useEffect(() => { + if (!workflows || presentation?.kind !== "open") return; + const timer = setInterval(() => void workflows.syncPresentations(), PRESENTATION_SYNC_MS); + return () => clearInterval(timer); + }, [presentation?.kind, workflows]); + + /** + * A standalone document can replace the service connection while this inline + * view remains mounted. When its lease ends, reacquire the current generation + * before sending any more page bridge calls through the old one. + */ + useEffect(() => { + if (standalone || detached || presentation?.kind !== "open" || !workflows) return; + if (row && row.presentationRevision > presentation.presentationRevision) { + void workflows.refreshInline(pluginId); + } + }, [detached, pluginId, presentation, row?.presentationRevision, standalone, workflows]); + + /** + * Claim the tab's browsing-context name, so the inline view can focus it. + * + * A tab opened by the inline control already has the name it was opened with; a + * tab reached by a pasted deep link does not, and without this the inline view's + * switch would open one more document instead of focusing this one. + */ + useEffect(() => { + if (!standalone) return; + window.name = webuiMiniAppWindowName(pluginId); + }, [pluginId, standalone]); + + /** Open the standalone page, or focus the tab that already holds it. */ + const standaloneHref = + !standalone && sessionId !== undefined + ? workflows?.standaloneUrl({ pluginId, sessionId }) + : undefined; + + const moveToStandalone = useCallback((): void => { + if (!standaloneHref) return; + // A named context is reused when it exists, which is what makes the switch a + // switch: same URL, same name, and the browser focuses the document that is + // already there rather than creating a second one. + window.open(standaloneHref, webuiMiniAppWindowName(pluginId)); + }, [pluginId, standaloneHref]); + + const answer = useCallback( + (target: Window, origin: string, reply: { requestId: string; result: WebuiMiniAppComposerResult }) => { + target.postMessage( + { type: WEBUI_MINIAPP_BRIDGE_APPEND_REPLY, requestId: reply.requestId, result: reply.result }, + origin, + ); + }, + [], + ); + + /** + * The page bridge's host half. + * + * A message is read only when it came from the frame this view created, on the + * origin this view framed, and the answer goes back to that origin only. Every + * other sender is ignored rather than answered: a page that is not this Mini App + * has nothing this client would say to it. + */ + useEffect(() => { + if (presentation?.kind !== "open" || detached) return; + const viewUrl = presentation.viewUrl; + const onMessage = (event: MessageEvent): void => { + const request = webuiMiniAppBridgeRequest(event.data); + if (!request) return; + const frame = frameRef.current?.contentWindow; + if ( + !webuiMiniAppBridgeSenderAllowed({ + origin: event.origin, + viewUrl, + source: event.source, + frame: frame ?? null, + }) || + !frame + ) { + return; + } + const origin = event.origin; + void webuiMiniAppBridgeAppend({ + request, + ...(sessionId !== undefined ? { sessionId } : {}), + pluginName: name, + // The person's confirmation, not the page's claim: an untrusted document + // may ask, and only a person may decide. + confirm: (question) => window.confirm(question), + readDraft: () => composer.readDraft(sessionId ?? WEBUI_COMPOSER_HOME_KEY), + writeDraft: (text) => composer.setDraft(sessionId ?? WEBUI_COMPOSER_HOME_KEY, text), + }) + .then((result) => answer(frame, origin, { requestId: request.requestId, result })) + .catch(() => answer(frame, origin, { requestId: request.requestId, result: { success: false, code: "result_unknown" } })); + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [answer, composer, detached, name, presentation, sessionId]); + + /** + * The page's agent bridge. + * + * One host per framed document, created with the generation the service issued + * for this start. A replacement frame gets a new host, and the old one's teardown + * ends the turns it had open — that is the client's half of generation fencing, + * while the service refuses any call whose generation it never issued. + * + * Selection runs through the `#session=` hash, the same anchor a rail link uses, + * because ADR 0020 maps a page's `open` onto the rail rather than onto a second + * conversation surface. Reporting it shown waits until the rail has actually + * selected it: the runtime treats a shown session as answerable, so claiming it + * early would make its permission asks wait on a panel that is not on screen. + */ + useEffect(() => { + if (!workflows || presentation?.kind !== "open" || detached) return; + const viewUrl = presentation.viewUrl; + const connection = presentation.connection; + const hostSessionId = sessionId ?? ""; + const agentHost = createWebuiMiniAppAgentBridge({ + pluginId, + hostSessionId, + connection, + viewUrl, + post: (message) => { + const target = frameRef.current?.contentWindow; + if (!target) return; + try { + target.postMessage(message, new URL(viewUrl).origin); + } catch { + // The frame this answer belonged to is gone; nothing is delivered into a + // replacement document. + } + }, + commands: { + call: async (request) => { + const response = await workflows.agentCall(request); + if (response.ok && response.method === "open") { + if (typeof window !== "undefined") { + window.location.hash = sessionHash(response.sessionId); + } + // Shown means the rail selected it, not that this document asked. The + // report waits for the selection to land; a selection that never + // arrives reports nothing, so the runtime is never told a panel exists + // that the person cannot see. + if (await workflows.awaitSelection(response.sessionId)) { + await workflows.reportShown({ + pluginId, + sessionId: response.sessionId, + hostSessionId, + connection, + }); + } + } + return response; + }, + watchTurn: (request, onFrame, onFailure) => + workflows.watchAgentTurn(request, onFrame, onFailure), + }, + }); + agentHostRef.current = agentHost; + frameLoadsRef.current = 0; + const onMessage = (event: MessageEvent): void => { + agentHost.handleMessage( + { data: event.data, origin: event.origin, source: event.source }, + frameRef.current?.contentWindow ?? null, + ); + }; + window.addEventListener("message", onMessage); + return () => { + window.removeEventListener("message", onMessage); + agentHostRef.current = null; + agentHost.close(); + // Nothing shows it any more, so the runtime stops counting it as answerable. + workflows.releaseShown(pluginId); + }; + }, [detached, pluginId, presentation, sessionId, workflows]); + + /** + * Whether this view can offer the move at all. + * + * A session is required because the deep link names one, and the URL comes from + * the wire because it carries the service token. A host that wires neither is a + * host where the standalone presentation does not exist, and the view then + * simply does not offer it rather than offering a control that does nothing. + */ + const canMove = !standalone && !detached && standaloneHref !== undefined; + + return ( +
+ {presentation?.kind === "opening" && !detached ? ( +

+ 正在启动 {name}… +

+ ) : null} + {detached ? ( +
+

已在独立的浏览器标签页中打开。

+ +
+ ) : null} + {presentation?.kind === "open" && !detached ? ( +