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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,19 +535,26 @@ _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**:
The local process that serves one Mini App's page on a loopback port. The client hosts its output
and does not own its state.
_Avoid_: runtime (that names the harness side), server, container

**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**:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,8 @@ const PREPARATION_REASON_BY_HOST_CODE: Readonly<Record<string, MiniAppFailureRea
MINIAPP_RUNTIME_INVALID: 'MINIAPP_RUNTIME_INVALID',
MINIAPP_RUNTIME_ENTRY_NOT_ARTIFACT: 'MINIAPP_RUNTIME_ENTRY_INVALID',
MINIAPP_SURFACE_INVALID: 'MINIAPP_SURFACE_INVALID',
MINIAPP_AGENT_ACCESS_INVALID: 'MINIAPP_MANIFEST_INVALID',
MINIAPP_AGENT_CONCURRENCY_INVALID: 'MINIAPP_MANIFEST_INVALID',
MINIAPP_MCP_ENDPOINTS_INVALID: 'MINIAPP_MCP_ENDPOINTS_INVALID',
MINIAPP_HOST_CONNECTOR_ACCESS_INVALID: 'MINIAPP_CONNECTOR_ACCESS_INVALID',
WORKSPACE_INSTALL_FAILED: 'WORKSPACE_INSTALL_FAILED',
Expand Down
3 changes: 3 additions & 0 deletions packages/local-runtime-v2/src/service/miniapp/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/** Largest `agentConcurrency` a Mini App manifest may declare. */
export const MINIAPP_AGENT_MAX_CONCURRENCY = 32;

export type MiniAppHostConnectorPolicy =
| { readonly kind: 'deny-all' }
| { readonly kind: 'allowlist'; readonly providers: readonly string[] };
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import path from 'node:path';

import { MINIAPP_AGENT_MAX_CONCURRENCY } from '../../../../miniapp/contracts.js';

import {
isRecord,
resolvePluginDirectory,
Expand All @@ -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;
Expand All @@ -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 }),
};
}

Expand Down Expand Up @@ -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') };
}

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>): Promise<void> {
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<string, unknown>) {
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': '<!doctype 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',
});
});
});
Loading
Loading