diff --git a/.changeset/620-web-launch-followup.md b/.changeset/620-web-launch-followup.md new file mode 100644 index 000000000..54b734aeb --- /dev/null +++ b/.changeset/620-web-launch-followup.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Resolve the dev `/web//` launch from the projections the artifact manifest declares instead of hardcoding portable (#620 review follow-up): validate an explicit `?target=` (invalid is an error, never a fallback), open unprompted when declared projections share one normalized launch descriptor, answer 409 (`AB8023`) naming the choices when they differ materially, and require no portable projection or `mcp.json` for a Claude- or Codex-only build. Cache web sessions by epoch, server, and resolved launch identity, retiring them only when a rebuild publishes a new epoch — a session pages still lease stays valid and closes at its last release, and a failed rebuild retires nothing. Run a non-read-only opening tool once per session, tool, App, and input (concurrent first loads share one call; `readOnlyHint: true` runs on every load) and rebind refreshes to the retained result. Move ` web` per-server state out of the installed artifact to `~/.agent-bundle/web-data/-/` so a read-only install launches. (#628) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 56eadb58f..e4219ebe9 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1911,7 +1911,7 @@ foreground server accepts. | `AB8020` | 400 / 404 | `MCP App route path is not valid.` — an App route whose binding id or operation segment is missing or does not decode, or an unknown operation under `/api/mcp/apps//`. `agent-bundle serve-app` answers unknown paths with `Not found.` (404) under the same code. | Use the App routes the Workbench MCP page issues. | | `AB8021` | 400 | `MCP App request has an invalid shape.` — the request body does not match the operation's expected fields. | Send the fields the operation defines. | | `AB8022` | 404 / 410 / 503 | `MCP App routes are not available.` — 404 without the preview service, 503 after shutdown; `MCP App preview is not available.` (404) — the binding id is unknown; `Runtime MCP App preview was revoked.` (410) — the runtime binding has been revoked. `agent-bundle serve-app` reports `MCP App host is not ready.` (503) before its host finishes starting. | Re-open the App preview; after 410 the page must create a new binding. | -| `AB8023` | 413 / 502 | `MCP App operation could not be completed.` (502) — an unmapped service failure; `Runtime MCP App operation exceeded its 30 second deadline.` (502); `Runtime MCP App operation response could not be encoded.` (502) or `… exceeds its transport bound.` (413) — the result of a runtime App operation could not cross the bounded host-to-App channel. | Read the dev-server log; shrink or split the App operation result if the bound was hit. | +| `AB8023` | 404 / 409 / 413 / 502 | `MCP App operation could not be completed.` (502) — an unmapped service failure; `Runtime MCP App operation exceeded its 30 second deadline.` (502); `Runtime MCP App operation response could not be encoded.` (502) or `… exceeds its transport bound.` (413) — the result of a runtime App operation could not cross the bounded host-to-App channel. On `/web//`: `MCP App could not be opened.` (502) — the launch, opening call, or page render failed; `Target "…" is not a declared projection that launches MCP server …` (404) — an invalid `?target=`, never a fallback; `No declared projection of this artifact launches MCP server …` (404); `The declared projections launch MCP server … differently; pick one explicitly with ?target=<…>.` (409). | Read the dev-server log; shrink or split the App operation result if the bound was hit; on `/web`, pass a `?target=` the message names. | ### Hook playground (`/api/hooks/**`) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 715da7dae..685d7519e 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1572,6 +1572,28 @@ gains a `web` section and the bin exists even without authored CLI commands. for Apps not listed in `web.apps`). There is no `web/` directory in the artifact. +`--profile` (and the dev page's preview profile) is browser presentation +only; it never selects a host artifact. The dev `/web` route resolves the +server's launch from the projections the artifact manifest declares: an +explicit `?target=` is validated against the declared +projections that launch the server (invalid is an error, never a fallback); +without one, every candidate's normalized launch descriptor (command, +arguments, cwd, declared env, runtime binding) is compared, materially +identical launches proceed unprompted whatever the host order, and +materially different ones answer 409 naming the choices. No portable +projection or `mcp.json` is required — a Claude- or Codex-only build opens +`/web//` from its own projection. Web sessions are cached by +epoch, server, and resolved launch identity; a successful rebuild retires +unused sessions of older epochs (pages still leasing one keep it until +their last lease releases), and a failed rebuild retires nothing. Opening an App page is not an unbounded +mutation: an opening tool annotated `readOnlyHint: true` runs on every page +load, while any other opening tool runs once per session, tool, App, and +input, and a refresh rebinds that retained result. ` web` keeps the +installed artifact immutable: framework-owned per-server web state +(`${PLUGIN_DATA}` in declared env) lives under the user's home +(`~/.agent-bundle/web-data/-/`), never inside the +plugin root, so a read-only install still launches. + ## `agent-bundle/app` — the App-side bridge client `agent-bundle/app` (`src/app/index.ts`, #594) is the half of the MCP Apps diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index 22121291a..023bf7fd3 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -28,7 +28,7 @@ import { RouteInvocationRoutes, type RouteInvocationRouteService } from './route import { RouteManifestRoutes, type RouteManifestRouteService } from './routes/route-manifest-routes.ts'; import { SkillDocumentError, type SkillDocumentService } from './skill-document-service.ts'; import type { Invalidation, ProjectEventMessage, ProjectStatus } from './types.ts'; -import { WebHostRoutes, type WebHostEpochSource } from './web-host-routes.ts'; +import { WebHostRoutes, type WebHostEpochSource, type WebHostLaunchOptions } from './web-host-routes.ts'; import { isWorkbenchShellPath } from './workbench-shell-paths.ts'; import { diagnostic, @@ -202,6 +202,8 @@ export interface ForegroundServerOptions { readonly sessionToken?: string; /** Test-only foreground stream observation; production callers never supply this. */ readonly testing?: ForegroundServerTesting; + /** How the development web host selects the launch of a web-exposed server across declared projections. */ + readonly webHostLaunch?: WebHostLaunchOptions; /** * Contributor HMR only: browser origins of a separately started Workbench * Rsbuild dev server that proxies /api here. Loopback http(s) origins only; @@ -426,6 +428,7 @@ export class ForegroundServer { readonly #sockets = new Set(); readonly #streamSubscriptions = new Set(); readonly #testing: ForegroundServerTesting | undefined; + readonly #webHostEpochSubscription: ProjectEventSubscription | undefined; readonly #webHostRoutes: WebHostRoutes; readonly #workbenchDevOrigins: ReadonlySet; #closePromise: Promise | undefined; @@ -474,11 +477,25 @@ export class ForegroundServer { this.#webHostRoutes = new WebHostRoutes({ authorize: (request) => this.#assertWebHostNavigation(request), ...(options.epochs === undefined ? {} : { epochs: options.epochs }), + ...(options.webHostLaunch === undefined ? {} : { launch: options.webHostLaunch }), ...(options.mcpSessions === undefined ? {} : { mcpSessions: options.mcpSessions }), ...(options.mcpAppPreviews === undefined ? {} : { previews: options.mcpAppPreviews }), sandboxOrigin: options.mcpAppSandboxOrigin ?? (() => undefined), sessionToken: this.sessionToken, }); + // Web-host session retirement follows successful epoch publications only: + // a failed rebuild publishes no artifact.available and retires nothing. + // Subscribed only when the web host is functional, so a foreground server + // without it keeps the hub's SSE-only subscription accounting. + this.#webHostEpochSubscription = + options.epochs === undefined || options.mcpSessions === undefined || options.webHostLaunch === undefined + ? undefined + : options.eventHub.subscribe( + { afterSequence: options.eventHub.latestSequence }, + (event) => { + if (event.type === 'artifact.available') this.#webHostRoutes.adoptActiveEpoch(event.epochId); + }, + ); this.#mcpAppRoutes = new McpAppRoutes({ authorize: (request) => this.#assertMutationSession(request), openingCall: (sessionId, toolName, opening) => this.#webHostRoutes.openingCall(sessionId, toolName, opening), @@ -672,6 +689,7 @@ export class ForegroundServer { } async #release(): Promise { + this.#webHostEpochSubscription?.unsubscribe(); this.#webHostRoutes.close(); this.#mcpAppRoutes.close(); this.#hostMcpRoutes?.close(); diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts index afdd4df14..24f617a30 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts @@ -104,6 +104,8 @@ interface ActiveSession { readonly session: McpSession; appLeaseCount: number; closed: boolean; + /** Deferred close armed by {@link McpSessionService.closeSessionWhenUnleased}; fires at the release of the last lease. */ + retire: (() => void) | undefined; } type McpAppLeaseIdentity = McpAppBridgeSession['identity'] & Readonly<{ @@ -219,6 +221,11 @@ const createMcpAppSessionLease = (entry: ActiveSession): McpAppSessionLease => { if (released) return; released = true; entry.appLeaseCount = Math.max(0, entry.appLeaseCount - 1); + if (entry.appLeaseCount === 0 && !entry.closed) { + const retire = entry.retire; + entry.retire = undefined; + retire?.(); + } }, session: bridgeSession, watchSessionClosed: (listener: McpAppSessionCloseListener) => { @@ -391,6 +398,7 @@ export class McpSessionService { appLeaseCount: 0, closeWatchers: new Set(), closed: false, + retire: undefined, session, }); return session; @@ -425,6 +433,30 @@ export class McpSessionService { return createMcpAppSessionLease(entry); } + /** Live App leases over one session; 0 for an unknown or closed session. */ + appLeaseCount(sessionId: string): number { + const entry = this.#sessions.get(sessionId); + return entry === undefined || entry.closed ? 0 : entry.appLeaseCount; + } + + /** + * Closes the session as soon as nothing leases it: immediately when the + * lease count is already zero, otherwise at the release of its last lease. + * Returns whether the close began now. + */ + closeSessionWhenUnleased(id: McpSessionId): boolean { + const entry = this.#sessions.get(id); + if (entry === undefined || entry.closed) return false; + if (entry.appLeaseCount === 0) { + void this.closeSession(id).catch(() => undefined); + return true; + } + entry.retire = () => { + void this.closeSession(id).catch(() => undefined); + }; + return false; + } + async closeSession(id: McpSessionId): Promise { const entry = this.#invalidateSession(id, new Error('MCP session control closed.')); if (entry === undefined) return false; diff --git a/packages/agent-bundle/src/dev/web-host-launch-selection.ts b/packages/agent-bundle/src/dev/web-host-launch-selection.ts new file mode 100644 index 000000000..c7e47a81c --- /dev/null +++ b/packages/agent-bundle/src/dev/web-host-launch-selection.ts @@ -0,0 +1,198 @@ +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import type { TargetRegistry } from '../adapters/registry.ts'; +import { digest } from '../core/digest.ts'; +import { CodedError } from '../core/errors.ts'; +import { assertInside, joinArtifact } from '../core/paths.ts'; +import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; +import { resolveMcpPathTokens } from '../services/mcp-path-tokens.ts'; +import { + readTargetMcpServer, + type TargetMcpRuntimeContract, +} from '../services/mcp-runtime.ts'; + +/** + * The effective launch of one web-exposed MCP server (#620 follow-up): the + * browser presentation profile never selects a host artifact, so the launch + * comes from the declared projections the composite root actually ships. + * Exactly one normalized launch may be in effect: an explicit `target` is + * validated against the declared projections that launch the server; without + * one, every candidate projection's launch descriptor is normalized and + * compared, and only materially identical launches proceed unprompted. + * Selection is resolved before any process spawns — candidates are never + * launched to discover which works. + */ + +export type WebLaunchSelectionErrorCode = + | 'launch-ambiguous' + | 'launch-missing' + | 'target-not-launchable'; + +export class WebLaunchSelectionError extends CodedError { + /** The declared projections that launch the server, for the caller's message. */ + readonly candidates: readonly string[]; + + constructor(code: WebLaunchSelectionErrorCode, message: string, candidates: readonly string[]) { + super('WebLaunchSelectionError', code, message); + this.candidates = Object.freeze([...candidates]); + } +} + +export interface SelectedWebLaunch { + /** Content identity of the normalized launch descriptor the selection resolves to. */ + readonly launchId: string; + /** Every candidate projection whose normalized launch equals the selection. */ + readonly sharedTargets: readonly string[]; + /** The deterministic representative projection the session opens with. */ + readonly target: string; +} + +export interface SelectWebLaunchOptions { + readonly artifactRoot: string; + /** The projections the artifact manifest declares for this composite root. */ + readonly declaredTargets: readonly string[]; + readonly registry: TargetRegistry; + /** Explicit projection choice; validated, never a fallback. */ + readonly requestedTarget?: string; + readonly serverName: string; + readonly workspaceRoot: string; +} + +interface LaunchCandidate { + readonly launchId: string; + readonly target: string; +} + +/** + * The normalized-launch runtime view of one projection: env values pass + * through the target's stdio-argument rule after token resolution, exactly + * as `resolveMcpStdioLaunch` normalizes them for `mcp run` — a target that + * serializes the plugin-root anchor as a `./` path (Codex) compares equal to + * one that interpolates a token for the same root. + */ +const identityRuntime = (runtime: TargetMcpRuntimeContract): TargetMcpRuntimeContract => ({ + manifestPath: runtime.manifestPath, + readModernServers: (document) => runtime.readModernServers(document), + resolveStdioArgument: (value, roots) => runtime.resolveStdioArgument(value, roots), + resolveValue: (field, roots, value) => { + if (field !== 'env') return runtime.resolveValue(field, roots, value); + const resolution = runtime.resolveValue(field, roots, value); + return { ...resolution, value: runtime.resolveStdioArgument(resolution.value, roots) }; + }, +}); + +/** + * The content identity of one projection's launch for the named server, or + * undefined when the projection does not launch it. The plugin-data root is + * a shared placeholder — identity compares descriptors, it allocates no + * state — so two projections binding the same durable-state layout digest + * equally whatever data root a later session mounts. + */ +const launchIdentityOf = async ( + options: SelectWebLaunchOptions, + target: string, +): Promise => { + const { registry } = options; + if (!registry.has(target) || !registry.supports(target, 'mcp')) return undefined; + const runtime = registry.mcpRuntime(target); + if (runtime === undefined) return undefined; + const artifactRoot = resolve(options.artifactRoot); + let document: unknown; + try { + document = parseJsonWithoutDuplicateKeys(await readFile(joinArtifact(artifactRoot, runtime.manifestPath), 'utf8')); + } catch { + return undefined; + } + const result = readTargetMcpServer(runtime, document, options.serverName); + if (result.status !== 'found') return undefined; + try { + const resolved = resolveMcpPathTokens({ + roots: { + pluginData: join(artifactRoot, '.web-launch-identity'), + pluginRoot: artifactRoot, + workspaceRoot: resolve(options.workspaceRoot), + }, + runtime: identityRuntime(runtime), + server: result.server, + target, + }); + if (resolved.kind === 'stdio') { + const cwd = resolved.cwd === undefined + ? artifactRoot + : assertInside(artifactRoot, resolve(artifactRoot, resolved.cwd)); + return digest({ + args: resolved.args, + command: resolved.command, + cwd, + env: resolved.env ?? {}, + kind: 'stdio', + }); + } + return digest({ headers: resolved.headers ?? {}, kind: 'streamable-http', url: resolved.url }); + } catch { + // A projection whose descriptor cannot resolve is not a launch candidate. + return undefined; + } +}; + +const listOf = (targets: readonly string[]): string => targets.join(', '); + +/** + * Resolves the one effective launch of a web-exposed server across the + * artifact's declared projections. Candidate order never matters: targets are + * sorted before grouping, so a selection over reversed host declarations is + * identical. Ambiguity is kept whenever normalized descriptors cannot prove + * equivalence; nothing synthesizes a portable launch. + */ +export const selectWebLaunch = async (options: SelectWebLaunchOptions): Promise => { + const targets = [...new Set(options.declaredTargets)].sort((left, right) => left.localeCompare(right)); + const candidates: LaunchCandidate[] = []; + for (const target of targets) { + const launchId = await launchIdentityOf(options, target); + if (launchId !== undefined) candidates.push(Object.freeze({ launchId, target })); + } + const candidateNames = Object.freeze(candidates.map((candidate) => candidate.target)); + const requested = options.requestedTarget; + if (requested !== undefined) { + const candidate = candidates.find((entry) => entry.target === requested); + if (candidate === undefined) { + throw new WebLaunchSelectionError( + 'target-not-launchable', + `Target ${JSON.stringify(requested)} is not a declared projection that launches MCP server ${JSON.stringify(options.serverName)}` + + `${candidateNames.length === 0 ? '.' : `; declared projections that do: ${listOf(candidateNames)}.`}`, + candidateNames, + ); + } + return Object.freeze({ + launchId: candidate.launchId, + sharedTargets: Object.freeze(candidates + .filter((entry) => entry.launchId === candidate.launchId) + .map((entry) => entry.target)), + target: candidate.target, + }); + } + if (candidates.length === 0) { + throw new WebLaunchSelectionError( + 'launch-missing', + `No declared projection of this artifact launches MCP server ${JSON.stringify(options.serverName)}; ` + + 'the server has no launch binding to open the App with.', + candidateNames, + ); + } + const launchIds = new Set(candidates.map((candidate) => candidate.launchId)); + if (launchIds.size > 1) { + throw new WebLaunchSelectionError( + 'launch-ambiguous', + `The declared projections launch MCP server ${JSON.stringify(options.serverName)} differently; ` + + `pick one explicitly with ?target=<${listOf(candidateNames)}>.`, + candidateNames, + ); + } + const representative = candidates[0]!; + return Object.freeze({ + launchId: representative.launchId, + sharedTargets: candidateNames, + target: representative.target, + }); +}; \ No newline at end of file diff --git a/packages/agent-bundle/src/dev/web-host-routes.ts b/packages/agent-bundle/src/dev/web-host-routes.ts index 79e418d6a..7248a192c 100644 --- a/packages/agent-bundle/src/dev/web-host-routes.ts +++ b/packages/agent-bundle/src/dev/web-host-routes.ts @@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { join } from 'node:path'; +import type { TargetRegistry } from '../adapters/registry.ts'; +import { digest } from '../core/digest.ts'; import { isRecord } from '../core/strict-json.ts'; import type { McpAppJsonValue, @@ -29,19 +31,25 @@ import { responseDiagnostic, } from './http.ts'; import { - readWebManifest, + readWebManifestDocument, type WebManifestApp, } from '../web-host/manifest.ts'; import { renderWebHostPage, webHostContentSecurityPolicy } from '../web-host/page.ts'; import { readWebHostPageScript } from '../web-host/page-script.ts'; import { - openApp, + resolveAppOpening, type AppSelectionSource, + type ResolvedAppOpening, } from '../web-host/select-app.ts'; +import { + selectWebLaunch, + WebLaunchSelectionError, + type SelectedWebLaunch, +} from './web-host-launch-selection.ts'; -const devWebHostTarget = 'portable'; const manifestFileName = 'agent-bundle.manifest.json'; const maxRetainedOpeningCalls = 64; +const maxRetainedOpeningResults = 64; interface WebHostEpochReference { close(): Promise; @@ -58,9 +66,16 @@ interface RegisteredSession { readonly session: McpSession; } +/** How the launch of a web-exposed server is selected across the artifact's declared projections. */ +export interface WebHostLaunchOptions { + readonly projectRoot: string; + readonly registry: TargetRegistry; +} + export interface WebHostRoutesOptions { readonly authorize: (request: IncomingMessage) => void; readonly epochs?: WebHostEpochSource; + readonly launch?: WebHostLaunchOptions; readonly mcpSessions?: McpSessionService; readonly previews?: McpAppRoutePreviewService; readonly sandboxOrigin: () => string | undefined; @@ -70,6 +85,8 @@ export interface WebHostRoutesOptions { interface WebHostRoute { readonly app: string; readonly server: string; + /** Explicit projection choice from `?target=`; validated, never a fallback. */ + readonly target?: string; } const routeSegment = (value: string): string => @@ -79,12 +96,31 @@ const routeSegment = (value: string): string => rejectBlank: true, }); -const route = (requestTarget: string | undefined): WebHostRoute | false | undefined => { - const pathname = rawPathname(requestTarget); +const requestedTarget = (requestUrl: string | undefined): string | undefined => { + let target: string | null; + try { + target = new URL(requestUrl ?? '', 'http://localhost').searchParams.get('target'); + } catch { + throw requestError(diagnostic('AB8020', 'Web host route path is not valid.', 400)); + } + if (target === null) return undefined; + if (target.trim().length === 0) { + throw requestError(diagnostic('AB8020', 'The target query parameter must name a declared projection.', 400)); + } + return target; +}; + +const route = (requestUrl: string | undefined): WebHostRoute | false | undefined => { + const pathname = rawPathname(requestUrl); if (pathname !== '/web' && !pathname.startsWith('/web/')) return undefined; const parts = pathname.split('/'); if (parts.length !== 4 || parts[0] !== '' || parts[1] !== 'web') return false; - return Object.freeze({ app: routeSegment(parts[3]!), server: routeSegment(parts[2]!) }); + const requested = requestedTarget(requestUrl); + return Object.freeze({ + app: routeSegment(parts[3]!), + server: routeSegment(parts[2]!), + ...(requested === undefined ? {} : { target: requested }), + }); }; const jsonInput = ( @@ -133,17 +169,20 @@ const writePage = ( export class WebHostRoutes { readonly #authorize: (request: IncomingMessage) => void; readonly #epochs: WebHostEpochSource | undefined; + readonly #launch: WebHostLaunchOptions | undefined; readonly #mcpSessions: McpSessionService | undefined; readonly #previews: McpAppRoutePreviewService | undefined; readonly #sandboxOrigin: () => string | undefined; readonly #sessionToken: string; readonly #openingCalls = new Map(); + readonly #openingResults = new Map>(); readonly #sessions = new Map>(); #closed = false; constructor(options: WebHostRoutesOptions) { this.#authorize = options.authorize; this.#epochs = options.epochs; + this.#launch = options.launch; this.#mcpSessions = options.mcpSessions; this.#previews = options.previews; this.#sandboxOrigin = options.sandboxOrigin; @@ -154,6 +193,7 @@ export class WebHostRoutes { if (this.#closed) return; this.#closed = true; this.#openingCalls.clear(); + this.#openingResults.clear(); const sessions = [...this.#sessions.values()]; this.#sessions.clear(); for (const session of sessions) { @@ -161,6 +201,31 @@ export class WebHostRoutes { } } + /** + * Retires sessions of every epoch but the newly published one. New page + * loads acquire sessions on the new epoch; an old session nobody leases + * beyond this registry closes now, releasing its process and epoch + * reference, while one that pages still lease stays valid for them — no + * longer handed out, and closed at the release of its last page lease. A + * failed rebuild publishes no epoch, so it never reaches this method and + * the last working session is kept. + */ + adoptActiveEpoch(activeEpochId: string): void { + if (this.#closed) return; + const service = this.#mcpSessions; + for (const [key, opening] of [...this.#sessions.entries()]) { + if (key.startsWith(`${activeEpochId}\0`)) continue; + this.#sessions.delete(key); + void opening.then(async (registered) => { + if (service === undefined) return; + await registered.dispose(); + if (service.closeSessionWhenUnleased(registered.session.id)) { + this.#dropSessionState(registered.session.id); + } + }).catch(() => undefined); + } + } + /** * Every `/web` page shares its server's session with every other page of * that server, so a page binds only the call stamped into its own seed: @@ -185,15 +250,16 @@ export class WebHostRoutes { } if (this.#closed) throw requestError(diagnostic('AB8022', 'Web host routes are not available.', 503)); const epochs = this.#epochs; + const launchOptions = this.#launch; const mcpSessions = this.#mcpSessions; const sandboxOrigin = this.#sandboxOrigin(); - if (epochs === undefined || mcpSessions === undefined || this.#previews === undefined || sandboxOrigin === undefined) { + if (epochs === undefined || launchOptions === undefined || mcpSessions === undefined || this.#previews === undefined || sandboxOrigin === undefined) { throw requestError(diagnostic('AB8022', 'Web host routes are not available.', 404)); } try { - const exposed = await this.#exposedApp(epochs, parsed); - if (exposed.app === undefined) { + const exposed = await this.#exposedApp(epochs, launchOptions, parsed); + if (exposed.app === undefined || exposed.launch === undefined) { const suffix = exposed.names.length === 0 ? ' No Apps are exposed.' : ` Exposed Apps: ${exposed.names.join(', ')}.`; @@ -207,52 +273,94 @@ export class WebHostRoutes { ); return true; } - const { app, epochId } = exposed; + const { app, epochId, launch } = exposed; if (method === 'HEAD') { writePage(response, method, sandboxOrigin, ''); return true; } - const registered = await this.#session(mcpSessions, epochId, app.server); - const selection = await openApp(selectionSource(registered.session), { + const registered = await this.#session(mcpSessions, epochId, app.server, launch); + const source = selectionSource(registered.session); + const resolved = await resolveAppOpening(source, { input: jsonInput(app.input), resourceUri: app.resourceUri, server: app.server, ...(app.tool === undefined ? {} : { tool: app.tool }), }); + const call = await this.#openingCallFor(source, registered.session.id, resolved); const opening = randomUUID(); this.#retainOpeningCall( - this.#openingCallKey(registered.session.id, selection.tool.name, opening), - Object.freeze({ input: selection.input, result: selection.result }), + this.#openingCallKey(registered.session.id, resolved.tool.name, opening), + call, ); const body = renderWebHostPage({ script: await readWebHostPageScript(), seed: { autoApprove: app.allow, - input: selection.input, + input: call.input, opening, previewProfile: 'portable', - result: selection.result, + result: call.result, sessionId: registered.session.id, title: app.app, token: this.#sessionToken, tokenHeader: 'x-agent-bundle-session', - toolName: selection.tool.name, + toolName: resolved.tool.name, }, }); writePage(response, method, sandboxOrigin, body); } catch (error) { + if (error instanceof WebLaunchSelectionError) { + responseDiagnostic(response, diagnostic('AB8023', error.message, error.code === 'launch-ambiguous' ? 409 : 404)); + return true; + } if (isRequestDiagnostic(error)) throw error; throw requestError(diagnostic('AB8023', 'MCP App could not be opened.', 502)); } return true; } + /** + * The call that opens the page. A tool annotated `readOnlyHint: true` runs + * once per page load — a refresh re-reads live state. Any other opening + * tool may mutate, so a page open is not an unbounded mutation: its first + * call per session, tool, App, and input is retained while still in + * flight (concurrent first loads share it) and every later load of the + * same page rebinds its result instead of re-running the tool; a failed + * call is dropped so the next load retries, and a new session (a new + * epoch after rebuild) runs the tool once again. + */ + async #openingCallFor( + source: AppSelectionSource, + sessionId: string, + resolved: ResolvedAppOpening, + ): Promise { + const call = async (): Promise => Object.freeze({ + input: resolved.input, + result: await source.callTool(resolved.tool.name, resolved.input), + }); + const readOnly = isRecord(resolved.tool['annotations']) && resolved.tool['annotations']['readOnlyHint'] === true; + if (readOnly) return call(); + const key = `${sessionId}\0${resolved.tool.name}\0${resolved.resourceUri}\0${digest(resolved.input)}`; + const retained = this.#openingResults.get(key); + if (retained !== undefined) return retained; + const pending = call(); + this.#retainOpeningResult(key, pending); + try { + return await pending; + } catch (error) { + if (this.#openingResults.get(key) === pending) this.#openingResults.delete(key); + throw error; + } + } + async #exposedApp( epochs: WebHostEpochSource, + launchOptions: WebHostLaunchOptions, requested: WebHostRoute, ): Promise> { let reference: WebHostEpochReference; @@ -262,26 +370,42 @@ export class WebHostRoutes { throw requestError(diagnostic('AB8022', 'Web host routes are not available without an active artifact epoch.', 404)); } try { - const manifest = await readWebManifest(join(reference.root, manifestFileName)); - const apps: readonly WebManifestApp[] = manifest?.apps ?? []; + const document = await readWebManifestDocument(join(reference.root, manifestFileName)); + const apps: readonly WebManifestApp[] = document.web?.apps ?? []; const requestedName = `${requested.server}/${requested.app}`; const app = apps.find((candidate) => candidate.app === requestedName); const names = Object.freeze(apps.map((candidate) => candidate.app).sort((left, right) => left.localeCompare(right))); - return Object.freeze({ - ...(app === undefined ? {} : { app }), - epochId: reference.epoch.id, - names, + if (app === undefined) { + return Object.freeze({ epochId: reference.epoch.id, names }); + } + const launch = await selectWebLaunch({ + artifactRoot: reference.root, + declaredTargets: document.targets, + registry: launchOptions.registry, + ...(requested.target === undefined ? {} : { requestedTarget: requested.target }), + serverName: app.server, + workspaceRoot: launchOptions.projectRoot, }); + return Object.freeze({ app, epochId: reference.epoch.id, launch, names }); } finally { await reference.close(); } } - async #session(service: McpSessionService, epochId: string, serverName: string): Promise { - const key = `${epochId}\0${serverName}`; + async #session( + service: McpSessionService, + epochId: string, + serverName: string, + launch: SelectedWebLaunch, + ): Promise { + // The resolved launch identity keys the session beside the epoch and + // server: two projections sharing one normalized launch share the + // session, while explicit targets with materially different launches + // never collide on epoch + server alone. + const key = `${epochId}\0${serverName}\0${launch.launchId}`; const existing = this.#sessions.get(key); if (existing !== undefined) return existing; - const opening = this.#openSession(service, key, epochId, serverName); + const opening = this.#openSession(service, key, epochId, serverName, launch.target); this.#sessions.set(key, opening); try { return await opening; @@ -296,8 +420,9 @@ export class WebHostRoutes { key: string, epochId: string, serverName: string, + target: string, ): Promise { - const session = await service.open({ epochId, serverName, target: devWebHostTarget }); + const session = await service.open({ epochId, serverName, target }); const lease: McpAppSessionLease = await service.acquireAppLease(session.id); let disposed = false; let unsubscribe = (): void => undefined; @@ -324,13 +449,21 @@ export class WebHostRoutes { } #forgetSession(key: string, sessionId: string): void { + this.#dropSessionState(sessionId); const current = this.#sessions.get(key); if (current === undefined) return; this.#sessions.delete(key); + void current.then((registered) => registered.dispose()).catch(() => undefined); + } + + #dropSessionState(sessionId: string): void { + const prefix = `${sessionId}\0`; for (const openingKey of this.#openingCalls.keys()) { - if (openingKey.startsWith(`${sessionId}\0`)) this.#openingCalls.delete(openingKey); + if (openingKey.startsWith(prefix)) this.#openingCalls.delete(openingKey); + } + for (const resultKey of this.#openingResults.keys()) { + if (resultKey.startsWith(prefix)) this.#openingResults.delete(resultKey); } - void current.then((registered) => registered.dispose()).catch(() => undefined); } #retainOpeningCall(key: string, call: McpAppOpeningCall): void { @@ -341,6 +474,14 @@ export class WebHostRoutes { } } + #retainOpeningResult(key: string, call: Promise): void { + this.#openingResults.set(key, call); + for (const oldest of this.#openingResults.keys()) { + if (this.#openingResults.size <= maxRetainedOpeningResults) break; + this.#openingResults.delete(oldest); + } + } + #openingCallKey(sessionId: string, toolName: string, opening: string): string { return `${sessionId}\0${toolName}\0${opening}`; } diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 3170df9e3..d872b557b 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -996,6 +996,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun routeInvocations, ...(runtime === undefined ? {} : { runtime }), skillDocuments, + webHostLaunch: { projectRoot: root, registry }, ...(options.workbenchDevOrigins === undefined || options.workbenchDevOrigins.length === 0 ? {} : { workbenchDevOrigins: options.workbenchDevOrigins }), diff --git a/packages/agent-bundle/src/web-host/launch.ts b/packages/agent-bundle/src/web-host/launch.ts index da634073b..e447c368f 100644 --- a/packages/agent-bundle/src/web-host/launch.ts +++ b/packages/agent-bundle/src/web-host/launch.ts @@ -1,6 +1,8 @@ import { mkdir } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; +import { sha256Hex } from '../core/digest.ts'; import { CodedError } from '../core/errors.ts'; import { mcpServerStateDirectory } from '../core/mcp-state-directory.ts'; import { exists, joinArtifact, safeArtifactPath } from '../core/paths.ts'; @@ -12,6 +14,8 @@ import type { StdioLaunch } from './session.ts'; export interface ResolveWebLaunchOptions { readonly app: WebManifestApp; readonly env: NodeJS.ProcessEnv; + /** The user home the durable web state root anchors on; defaults to the OS home directory. */ + readonly home?: string; readonly pluginRoot: string; } @@ -23,15 +27,35 @@ export class WebLaunchError extends CodedError { } } -export const webPluginDataDirectory = (pluginRoot: string, server: string): string => - join(pluginRoot, '.agent-bundle', 'web', mcpServerStateDirectory(server)); +const safePluginSegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; + +/** + * One state segment per installed plugin root: the resolved root's digest + * keys the state, so two installs of the same plugin never share it, and the + * basename stays in front only when it is already a safe path segment. + */ +const webPluginStateSegment = (pluginRoot: string): string => { + const digest = sha256Hex(pluginRoot).slice(0, 16); + const name = basename(pluginRoot); + return safePluginSegment.test(name) ? `${name}-${digest}` : `plugin-${digest}`; +}; + +/** + * Durable per-server web state, outside the installed artifact: the artifact + * stays immutable (it may be installed read-only), so framework-owned + * writable state anchors under the user's home instead of the plugin root. + */ +export const webPluginDataDirectory = (pluginRoot: string, server: string, home = homedir()): string => + join(home, '.agent-bundle', 'web-data', webPluginStateSegment(resolve(pluginRoot)), mcpServerStateDirectory(server)); const inheritedEnvironment = (env: NodeJS.ProcessEnv): Record => Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string')); /** * Declared env overrides inherited env, matching installed hosts. Plugin data - * is artifact-local because the artifact is the durable installation. + * lives outside the artifact (under the user's home), because the installed + * artifact is immutable — a read-only install must still launch when the + * server declares plugin-data state. */ export const resolveWebLaunch = async (options: ResolveWebLaunchOptions): Promise => { const pluginRoot = resolve(options.pluginRoot); @@ -50,7 +74,7 @@ export const resolveWebLaunch = async (options: ResolveWebLaunchOptions): Promis `MCP server entry ${entry} of ${app.app} does not exist; rebuild the plugin so the artifact matches its manifest.`, ); } - const pluginData = webPluginDataDirectory(pluginRoot, app.server); + const pluginData = webPluginDataDirectory(pluginRoot, app.server, options.home); const workspaceRoot = process.cwd(); const expand = (value: string): string => value .replaceAll(pathTokens.pluginRoot, pluginRoot) diff --git a/packages/agent-bundle/src/web-host/manifest.ts b/packages/agent-bundle/src/web-host/manifest.ts index 39e2fe3bb..e8ba32687 100644 --- a/packages/agent-bundle/src/web-host/manifest.ts +++ b/packages/agent-bundle/src/web-host/manifest.ts @@ -119,12 +119,34 @@ export const parseWebManifest = (value: unknown): WebManifest => { return { apps, open: manifest.open }; }; -export const readWebManifest = async (manifestPath: string): Promise => { +/** The web-relevant read of one artifact manifest: the exposed Apps and the declared projections. */ +export interface WebManifestDocument { + /** The projection names the artifact manifest declares for this composite root. */ + readonly targets: readonly string[]; + readonly web?: WebManifest; +} + +const targetNames = (value: unknown): readonly string[] => { + if (!Array.isArray(value)) return Object.freeze([]); + return Object.freeze(value.flatMap((target: unknown) => { + if (!isPlainRecord(target)) return []; + const name = target['name']; + return typeof name === 'string' && name.length > 0 ? [name] : []; + })); +}; + +export const readWebManifestDocument = async (manifestPath: string): Promise => { try { const document = parseJsonWithoutDuplicateKeys(await readFile(manifestPath, 'utf8')); const manifest = record(document, 'manifest'); - return manifest['web'] === undefined ? undefined : parseWebManifest(manifest['web']); + return { + targets: targetNames(manifest['targets']), + ...(manifest['web'] === undefined ? {} : { web: parseWebManifest(manifest['web']) }), + }; } catch (error) { throw new Error(`Unable to read web section from ${manifestPath}: ${errorMessage(error)}`, { cause: error }); } }; + +export const readWebManifest = async (manifestPath: string): Promise => + (await readWebManifestDocument(manifestPath)).web; diff --git a/packages/agent-bundle/src/web-host/select-app.ts b/packages/agent-bundle/src/web-host/select-app.ts index 5583f98ba..408185cf6 100644 --- a/packages/agent-bundle/src/web-host/select-app.ts +++ b/packages/agent-bundle/src/web-host/select-app.ts @@ -70,13 +70,21 @@ const matchingResourceUris = (resourceUris: readonly string[], request: OpenAppR return resourceUris.filter((uri) => appNameOf(uri) === request.name); }; +/** An App opening resolved against the live server, before its opening tool has been called. */ +export interface ResolvedAppOpening { + readonly input: Readonly>; + readonly resourceUri: string; + readonly server: string; + readonly tool: McpAppToolDefinition; +} + /** - * Resolves the App and its opening tool against the live server, then calls - * the tool once. A known `resourceUri` skips name matching but is still + * Resolves the App and its opening tool against the live server without + * calling the tool. A known `resourceUri` skips name matching but is still * verified against what the server serves; the tool must advertise the App * as `_meta.ui.resourceUri`, and without `tool` exactly one may. */ -export const openApp = async (source: AppSelectionSource, request: OpenAppRequest): Promise => { +export const resolveAppOpening = async (source: AppSelectionSource, request: OpenAppRequest): Promise => { if (request.resourceUri === undefined && request.name === undefined) { throw new Error('MCP App must be named as / or a ui:// resource URI.'); } @@ -112,6 +120,12 @@ export const openApp = async (source: AppSelectionSource, request: OpenAppReques : `Several tools open MCP App ${resourceUri} (${appTools.map((tool) => tool.name).join(', ')}); choose one with --tool.`); } const input = requireJsonObject(request.input ?? {}, 'MCP App tool input'); - const result = await source.callTool(selectedTool.name, input); - return Object.freeze({ input, resourceUri, result, server: request.server, tool: selectedTool }); + return Object.freeze({ input, resourceUri, server: request.server, tool: selectedTool }); +}; + +/** {@link resolveAppOpening}, then exactly one call of the resolved opening tool. */ +export const openApp = async (source: AppSelectionSource, request: OpenAppRequest): Promise => { + const resolved = await resolveAppOpening(source, request); + const result = await source.callTool(resolved.tool.name, resolved.input); + return Object.freeze({ ...resolved, result }); }; diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index 5c734d55d..6455c55dc 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -1699,6 +1699,47 @@ it('leases immutable canonical MCP App data without closing the control-owned se } }, 30_000); +it('closes an unleased session immediately on closeSessionWhenUnleased and a leased one at its last release', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-app-retire-')); + try { + const epochStore = await publishFixtureEpoch(root, 'epoch-retire'); + let clientCloses = 0; + const service = new McpSessionService({ + createClient: () => ({ + ...mcpCatalogStub(), + callTool: async () => ({ content: [] }), + close: async () => { + clientCloses += 1; + }, + connect: async () => undefined, + }), + createStdioTransport: () => stdioTransportStub() as never, + epochStore, + projectRoot: root, + }); + + const unleased = await service.open({ epochId: 'epoch-retire', serverName: 'fixture', target: 'portable' }); + expect(service.closeSessionWhenUnleased(unleased.id)).toBe(true); + expect(service.get(unleased.id)).toBeUndefined(); + + const leased = await service.open({ epochId: 'epoch-retire', serverName: 'fixture', target: 'portable' }); + const registryLease = await service.acquireAppLease(leased.id); + const pageLease = await service.acquireAppLease(leased.id); + await registryLease.release(); + expect(service.closeSessionWhenUnleased(leased.id)).toBe(false); + expect(service.get(leased.id)).toBe(leased); + expect(service.appLeaseCount(leased.id)).toBe(1); + await pageLease.release(); + expect(service.get(leased.id)).toBeUndefined(); + await new Promise((done) => setTimeout(done, 0)); + expect(clientCloses).toBe(2); + expect(service.closeSessionWhenUnleased(leased.id)).toBe(false); + await service.close(); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 30_000); + it('synchronously invalidates App leases when the control session closes during binding creation', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-app-close-race-')); try { diff --git a/packages/agent-bundle/tests/web-host-launch-selection.test.ts b/packages/agent-bundle/tests/web-host-launch-selection.test.ts new file mode 100644 index 000000000..49330b4d6 --- /dev/null +++ b/packages/agent-bundle/tests/web-host-launch-selection.test.ts @@ -0,0 +1,180 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { + selectWebLaunch, + WebLaunchSelectionError, + type SelectWebLaunchOptions, +} from '../src/dev/web-host-launch-selection.ts'; + +const registry = createDefaultRegistry(); +const roots: string[] = []; + +const artifactRoot = async (): Promise => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-web-select-'))); + roots.push(root); + return root; +}; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const writeManifest = async (root: string, relativePath: string, servers: Readonly>): Promise => { + const path = join(root, relativePath); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify({ mcpServers: servers })); +}; + +const claudeServer = (overrides: Readonly> = {}): Readonly> => ({ + args: ['${CLAUDE_PLUGIN_ROOT}/mcp/mcp-status.mjs'], + command: 'node', + env: { AGENT_BUNDLE_PLUGIN_ROOT: '${CLAUDE_PLUGIN_ROOT}' }, + type: 'stdio', + ...overrides, +}); + +const portableServer = (overrides: Readonly> = {}): Readonly> => ({ + args: ['${PLUGIN_ROOT}/mcp/mcp-status.mjs'], + command: 'node', + env: { AGENT_BUNDLE_PLUGIN_ROOT: '${PLUGIN_ROOT}' }, + type: 'stdio', + ...overrides, +}); + +const codexServer = (overrides: Readonly> = {}): Readonly> => ({ + args: ['./mcp/mcp-status.mjs'], + command: 'node', + cwd: './', + env: { AGENT_BUNDLE_PLUGIN_ROOT: './' }, + type: 'stdio', + ...overrides, +}); + +const select = (root: string, overrides: Partial = {}) => selectWebLaunch({ + artifactRoot: root, + declaredTargets: ['claude'], + registry, + serverName: 'status', + workspaceRoot: root, + ...overrides, +}); + +const failure = async (root: string, overrides: Partial): Promise => { + const outcome = await select(root, overrides).then(() => undefined, (error: unknown) => error); + expect(outcome).toBeInstanceOf(WebLaunchSelectionError); + if (!(outcome instanceof WebLaunchSelectionError)) throw outcome; + return outcome; +}; + +describe('selectWebLaunch', () => { + it('resolves a Claude-only artifact without any portable projection or mcp.json', async () => { + const root = await artifactRoot(); + await writeManifest(root, '.mcp.json', { status: claudeServer() }); + const selection = await select(root); + expect(selection.target).toBe('claude'); + expect(selection.sharedTargets).toEqual(['claude']); + }); + + it('resolves a Codex-only artifact without any portable projection or mcp.json', async () => { + const root = await artifactRoot(); + await writeManifest(root, '.codex-plugin/mcp.json', { status: codexServer() }); + const selection = await select(root, { declaredTargets: ['codex'] }); + expect(selection.target).toBe('codex'); + expect(selection.sharedTargets).toEqual(['codex']); + }); + + it('needs no explicit target when two projections share one normalized launch', async () => { + const root = await artifactRoot(); + await writeManifest(root, '.mcp.json', { status: claudeServer() }); + await writeManifest(root, 'mcp.json', { status: portableServer() }); + const selection = await select(root, { declaredTargets: ['claude', 'portable'] }); + expect(selection.target).toBe('claude'); + expect(selection.sharedTargets).toEqual(['claude', 'portable']); + }); + + it('selects identically when the hosts are declared in reversed order', async () => { + const root = await artifactRoot(); + await writeManifest(root, '.mcp.json', { status: claudeServer() }); + await writeManifest(root, 'mcp.json', { status: portableServer() }); + const forward = await select(root, { declaredTargets: ['claude', 'portable'] }); + const reversed = await select(root, { declaredTargets: ['portable', 'claude'] }); + expect(reversed).toEqual(forward); + }); + + it("normalizes Codex's ./-relative anchors against the same roots as token interpolation", async () => { + const root = await artifactRoot(); + await writeManifest(root, '.mcp.json', { status: claudeServer() }); + await writeManifest(root, '.codex-plugin/mcp.json', { status: codexServer() }); + const selection = await select(root, { declaredTargets: ['claude', 'codex'] }); + expect(selection.sharedTargets).toEqual(['claude', 'codex']); + }); + + it('requires an explicit target when projections differ in one execution-relevant field', async () => { + const root = await artifactRoot(); + await writeManifest(root, '.mcp.json', { status: claudeServer() }); + await writeManifest(root, 'mcp.json', { + status: portableServer({ env: { AGENT_BUNDLE_PLUGIN_ROOT: '${PLUGIN_ROOT}', STATUS_MODE: 'portable-only' } }), + }); + const error = await failure(root, { declaredTargets: ['claude', 'portable'] }); + expect(error.code).toBe('launch-ambiguous'); + expect(error.candidates).toEqual(['claude', 'portable']); + expect(error.message).toContain('?target='); + expect(error.message).toContain('claude'); + expect(error.message).toContain('portable'); + }); + + it('honors an explicit target among materially different launches', async () => { + const root = await artifactRoot(); + await writeManifest(root, '.mcp.json', { status: claudeServer() }); + await writeManifest(root, 'mcp.json', { + status: portableServer({ env: { AGENT_BUNDLE_PLUGIN_ROOT: '${PLUGIN_ROOT}', STATUS_MODE: 'portable-only' } }), + }); + const selection = await select(root, { declaredTargets: ['claude', 'portable'], requestedTarget: 'portable' }); + expect(selection.target).toBe('portable'); + expect(selection.sharedTargets).toEqual(['portable']); + }); + + it('refuses an explicit target that no declared projection launches, never falling back', async () => { + const root = await artifactRoot(); + await writeManifest(root, '.mcp.json', { status: claudeServer() }); + const error = await failure(root, { declaredTargets: ['claude'], requestedTarget: 'portable' }); + expect(error.code).toBe('target-not-launchable'); + expect(error.message).toContain('"portable"'); + expect(error.message).toContain('claude'); + }); + + it('refuses an explicit target whose projection does not declare the server', async () => { + const root = await artifactRoot(); + await writeManifest(root, '.mcp.json', { status: claudeServer() }); + await writeManifest(root, 'mcp.json', { other: portableServer() }); + const error = await failure(root, { declaredTargets: ['claude', 'portable'], requestedTarget: 'portable' }); + expect(error.code).toBe('target-not-launchable'); + }); + + it('reports a missing launch binding instead of synthesizing a portable one', async () => { + const root = await artifactRoot(); + await writeManifest(root, '.mcp.json', { other: claudeServer() }); + const error = await failure(root, { declaredTargets: ['claude'] }); + expect(error.code).toBe('launch-missing'); + expect(error.message).toContain('"status"'); + }); + + it('gives distinct launch identities to materially different launches and one to shared launches', async () => { + const root = await artifactRoot(); + await writeManifest(root, '.mcp.json', { status: claudeServer() }); + await writeManifest(root, 'mcp.json', { + status: portableServer({ args: ['${PLUGIN_ROOT}/mcp/mcp-status.mjs', '--verbose'] }), + }); + const claude = await select(root, { declaredTargets: ['claude', 'portable'], requestedTarget: 'claude' }); + const portable = await select(root, { declaredTargets: ['claude', 'portable'], requestedTarget: 'portable' }); + expect(claude.launchId).not.toBe(portable.launchId); + await writeManifest(root, 'mcp.json', { status: portableServer() }); + const aligned = await select(root, { declaredTargets: ['claude', 'portable'], requestedTarget: 'portable' }); + expect(aligned.launchId).toBe(claude.launchId); + }); +}); diff --git a/packages/agent-bundle/tests/web-host-routes-unit.test.ts b/packages/agent-bundle/tests/web-host-routes-unit.test.ts new file mode 100644 index 000000000..5daa7aa4f --- /dev/null +++ b/packages/agent-bundle/tests/web-host-routes-unit.test.ts @@ -0,0 +1,427 @@ +import { mkdir, mkdtemp, realpath, 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 { afterEach, describe, expect, it } from '@rstest/core'; + +import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { isRequestDiagnostic } from '../src/dev/http.ts'; +import type { McpAppRoutePreviewService } from '../src/dev/mcp-apps/mcp-app-routes.ts'; +import type { McpSession } from '../src/dev/mcp-session/mcp-session.ts'; +import type { McpSessionService } from '../src/dev/mcp-session/mcp-session-service.ts'; +import { WebHostRoutes, type WebHostEpochSource } from '../src/dev/web-host-routes.ts'; + +const registry = createDefaultRegistry(); +const roots: string[] = []; +const servers: Server[] = []; + +const artifactRoot = async (): Promise => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-web-routes-'))); + roots.push(root); + return root; +}; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((done) => server.close(done)))); + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const resourceUri = 'ui://status/status.html'; + +const claudeServer = (): Readonly> => ({ + args: ['${CLAUDE_PLUGIN_ROOT}/mcp/mcp-status.mjs'], + command: 'node', + env: { AGENT_BUNDLE_PLUGIN_ROOT: '${CLAUDE_PLUGIN_ROOT}' }, + type: 'stdio', +}); + +const portableServer = (overrides: Readonly> = {}): Readonly> => ({ + args: ['${PLUGIN_ROOT}/mcp/mcp-status.mjs'], + command: 'node', + env: { AGENT_BUNDLE_PLUGIN_ROOT: '${PLUGIN_ROOT}' }, + type: 'stdio', + ...overrides, +}); + +const codexServer = (): Readonly> => ({ + args: ['./mcp/mcp-status.mjs'], + command: 'node', + cwd: './', + env: { AGENT_BUNDLE_PLUGIN_ROOT: './' }, + type: 'stdio', +}); + +interface FixtureOptions { + readonly projections: Readonly>>>; + readonly targets: readonly string[]; +} + +const writeFixture = async (root: string, options: FixtureOptions): Promise => { + await writeFile(join(root, 'agent-bundle.manifest.json'), JSON.stringify({ + targets: options.targets.map((name) => ({ name })), + web: { + apps: [{ + allow: [], + app: 'status/status', + args: [], + entry: 'mcp/mcp-status.mjs', + env: {}, + name: 'status', + resourceUri, + server: 'status', + }], + open: 'never', + }, + })); + for (const [relativePath, serverEntry] of Object.entries(options.projections)) { + const path = join(root, relativePath); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify({ mcpServers: { status: serverEntry } })); + } +}; + +interface OpenedSession { + readonly epochId: string; + readonly id: string; + readonly serverName: string; + readonly target: string; +} + +/** In-memory McpSessionService double: no processes, real lease counting. */ +class FakeSessionService { + readonly closed: string[] = []; + readonly opened: OpenedSession[] = []; + toolCalls = 0; + readOnlyOpeningTool = false; + failNextToolCall = false; + readonly toolCallReleases: (() => void)[] = []; + gateToolCalls = false; + readonly #leases = new Map(); + readonly #retire = new Set(); + + async open(options: { readonly epochId: string; readonly serverName: string; readonly target: string }): Promise { + const id = `session-${String(this.opened.length + 1)}`; + this.opened.push({ epochId: options.epochId, id, serverName: options.serverName, target: options.target }); + this.#leases.set(id, 0); + const session = { + callTool: async () => { + this.toolCalls += 1; + if (this.gateToolCalls) await new Promise((release) => this.toolCallReleases.push(release)); + if (this.failNextToolCall) { + this.failNextToolCall = false; + throw new Error('opening tool failed'); + } + return { content: [], structuredContent: { status: 'healthy' } }; + }, + id, + listResources: async () => [{ mimeType: 'text/html;profile=mcp-app', name: 'status', uri: resourceUri }], + listTools: async () => [{ + _meta: { ui: { resourceUri } }, + ...(this.readOnlyOpeningTool ? { annotations: { readOnlyHint: true } } : {}), + inputSchema: { type: 'object' }, + name: 'show-status', + }], + }; + return session as unknown as McpSession; + } + + async acquireAppLease(sessionId: string) { + if (!this.#leases.has(sessionId)) throw new Error(`Unknown MCP App session ${JSON.stringify(sessionId)}.`); + this.#leases.set(sessionId, (this.#leases.get(sessionId) ?? 0) + 1); + let released = false; + return { + release: async () => { + if (released) return; + released = true; + const remaining = Math.max(0, (this.#leases.get(sessionId) ?? 1) - 1); + this.#leases.set(sessionId, remaining); + if (remaining === 0 && this.#retire.delete(sessionId)) void this.closeSession(sessionId); + }, + session: {}, + watchSessionClosed: () => ({ closed: false, unsubscribe: () => undefined }), + }; + } + + appLeaseCount(sessionId: string): number { + return this.#leases.get(sessionId) ?? 0; + } + + closeSessionWhenUnleased(sessionId: string): boolean { + if (!this.#leases.has(sessionId)) return false; + if ((this.#leases.get(sessionId) ?? 0) === 0) { + void this.closeSession(sessionId); + return true; + } + this.#retire.add(sessionId); + return false; + } + + async closeSession(sessionId: string): Promise { + this.closed.push(sessionId); + this.#leases.delete(sessionId); + this.#retire.delete(sessionId); + return true; + } + + /** Simulates a page holding its own lease on the session, as a bind does. */ + async leaseAsPage(sessionId: string) { + return this.acquireAppLease(sessionId); + } +} + +interface Harness { + readonly routes: WebHostRoutes; + readonly service: FakeSessionService; + readonly url: string; + setEpoch(epochId: string): void; +} + +const startHarness = async (root: string, initialEpochId = 'epoch-1'): Promise => { + let epochId = initialEpochId; + const epochs: WebHostEpochSource = { + acquireActiveEpochReference: async () => ({ + close: async () => undefined, + epoch: { id: epochId }, + root, + }), + }; + const service = new FakeSessionService(); + const routes = new WebHostRoutes({ + authorize: () => undefined, + epochs, + launch: { projectRoot: root, registry }, + mcpSessions: service as unknown as McpSessionService, + previews: {} as unknown as McpAppRoutePreviewService, + sandboxOrigin: () => 'http://127.0.0.1:1', + sessionToken: 'test-token', + }); + const server = createServer((request, response) => { + void routes.handle(request, response).then((handled) => { + if (!handled) { + response.statusCode = 404; + response.end(); + } + }).catch((error: unknown) => { + response.statusCode = isRequestDiagnostic(error) ? error.status : 500; + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ message: error instanceof Error ? error.message : String(error) })); + }); + }); + servers.push(server); + const url = await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen({ host: '127.0.0.1', port: 0 }, () => { + const address = server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('no address')); + return; + } + resolve(`http://127.0.0.1:${String(address.port)}`); + }); + }); + return { + routes, + service, + setEpoch: (next: string) => { epochId = next; }, + url, + }; +}; + +const settle = async (): Promise => { + // Retirement disposes retired sessions off the request path. + for (let turn = 0; turn < 8; turn += 1) await Promise.resolve(); + await new Promise((done) => setTimeout(done, 0)); +}; + +describe('WebHostRoutes launch selection', () => { + it('opens the web route from a Claude-only artifact without a portable projection', async () => { + const root = await artifactRoot(); + await writeFixture(root, { projections: { '.mcp.json': claudeServer() }, targets: ['claude'] }); + const harness = await startHarness(root); + const response = await fetch(`${harness.url}/web/status/status`); + expect(response.status).toBe(200); + expect(await response.text()).toContain('"previewProfile":"portable"'); + expect(harness.service.opened).toHaveLength(1); + expect(harness.service.opened[0]?.target).toBe('claude'); + }); + + it('opens the web route from a Codex-only artifact without a portable projection', async () => { + const root = await artifactRoot(); + await writeFixture(root, { projections: { '.codex-plugin/mcp.json': codexServer() }, targets: ['codex'] }); + const harness = await startHarness(root); + const response = await fetch(`${harness.url}/web/status/status`); + expect(response.status).toBe(200); + expect(harness.service.opened[0]?.target).toBe('codex'); + }); + + it('opens without an explicit target when the declared projections share one launch, whatever their order', async () => { + const root = await artifactRoot(); + await writeFixture(root, { + projections: { '.mcp.json': claudeServer(), 'mcp.json': portableServer() }, + targets: ['portable', 'claude'], + }); + const harness = await startHarness(root); + expect((await fetch(`${harness.url}/web/status/status`)).status).toBe(200); + expect((await fetch(`${harness.url}/web/status/status`)).status).toBe(200); + expect(harness.service.opened).toHaveLength(1); + expect(harness.service.opened[0]?.target).toBe('claude'); + }); + + it('requires an explicit target for materially different launches and validates it, never falling back', async () => { + const root = await artifactRoot(); + await writeFixture(root, { + projections: { + '.mcp.json': claudeServer(), + 'mcp.json': portableServer({ env: { AGENT_BUNDLE_PLUGIN_ROOT: '${PLUGIN_ROOT}', STATUS_MODE: 'portable-only' } }), + }, + targets: ['claude', 'portable'], + }); + const harness = await startHarness(root); + const ambiguous = await fetch(`${harness.url}/web/status/status`); + expect(ambiguous.status).toBe(409); + expect(await ambiguous.text()).toContain('?target='); + expect(harness.service.opened).toHaveLength(0); + + const invalid = await fetch(`${harness.url}/web/status/status?target=nope`); + expect(invalid.status).toBe(404); + expect(await invalid.text()).toContain('nope'); + expect(harness.service.opened).toHaveLength(0); + + const explicit = await fetch(`${harness.url}/web/status/status?target=portable`); + expect(explicit.status).toBe(200); + expect(harness.service.opened).toHaveLength(1); + expect(harness.service.opened[0]?.target).toBe('portable'); + }); + + it('keys the session cache on the resolved launch identity, not epoch and server alone', async () => { + const root = await artifactRoot(); + await writeFixture(root, { + projections: { + '.mcp.json': claudeServer(), + 'mcp.json': portableServer({ env: { AGENT_BUNDLE_PLUGIN_ROOT: '${PLUGIN_ROOT}', STATUS_MODE: 'portable-only' } }), + }, + targets: ['claude', 'portable'], + }); + const harness = await startHarness(root); + expect((await fetch(`${harness.url}/web/status/status?target=claude`)).status).toBe(200); + expect((await fetch(`${harness.url}/web/status/status?target=portable`)).status).toBe(200); + expect(harness.service.opened).toHaveLength(2); + expect(harness.service.opened.map((opened) => opened.target)).toEqual(['claude', 'portable']); + expect((await fetch(`${harness.url}/web/status/status?target=claude`)).status).toBe(200); + expect((await fetch(`${harness.url}/web/status/status?target=portable`)).status).toBe(200); + expect(harness.service.opened).toHaveLength(2); + }); +}); + +describe('WebHostRoutes session retirement', () => { + it('retires an unused old-epoch session on successful epoch publication and acquires the new epoch', async () => { + const root = await artifactRoot(); + await writeFixture(root, { projections: { '.mcp.json': claudeServer() }, targets: ['claude'] }); + const harness = await startHarness(root, 'epoch-1'); + expect((await fetch(`${harness.url}/web/status/status`)).status).toBe(200); + expect(harness.service.opened).toHaveLength(1); + + harness.setEpoch('epoch-2'); + harness.routes.adoptActiveEpoch('epoch-2'); + await settle(); + expect(harness.service.closed).toEqual(['session-1']); + + expect((await fetch(`${harness.url}/web/status/status`)).status).toBe(200); + expect(harness.service.opened).toHaveLength(2); + expect(harness.service.opened[1]?.epochId).toBe('epoch-2'); + }); + + it('keeps an old-epoch session that pages still lease, while no longer handing it out', async () => { + const root = await artifactRoot(); + await writeFixture(root, { projections: { '.mcp.json': claudeServer() }, targets: ['claude'] }); + const harness = await startHarness(root, 'epoch-1'); + expect((await fetch(`${harness.url}/web/status/status`)).status).toBe(200); + const sessionId = harness.service.opened[0]!.id; + // Two tabs bind the shared session; one closing (releasing) must not end it. + const firstTab = await harness.service.leaseAsPage(sessionId); + const secondTab = await harness.service.leaseAsPage(sessionId); + await firstTab.release(); + + harness.setEpoch('epoch-2'); + harness.routes.adoptActiveEpoch('epoch-2'); + await settle(); + expect(harness.service.closed).toEqual([]); + expect(harness.service.appLeaseCount(sessionId)).toBeGreaterThan(0); + await secondTab.release(); + // The retired session closes at the release of its last page lease. + await settle(); + expect(harness.service.closed).toEqual([sessionId]); + + expect((await fetch(`${harness.url}/web/status/status`)).status).toBe(200); + expect(harness.service.opened).toHaveLength(2); + }); + + it('keeps the last working session when a rebuild fails and publishes no epoch', async () => { + const root = await artifactRoot(); + await writeFixture(root, { projections: { '.mcp.json': claudeServer() }, targets: ['claude'] }); + const harness = await startHarness(root, 'epoch-1'); + expect((await fetch(`${harness.url}/web/status/status`)).status).toBe(200); + // A failed rebuild publishes no artifact.available, so nothing retires. + await settle(); + expect((await fetch(`${harness.url}/web/status/status`)).status).toBe(200); + expect(harness.service.opened).toHaveLength(1); + expect(harness.service.closed).toEqual([]); + }); +}); + +describe('WebHostRoutes opening-tool policy', () => { + it('runs a mutating (unannotated) opening tool once per session and rebinds its result on refresh', async () => { + const root = await artifactRoot(); + await writeFixture(root, { projections: { '.mcp.json': claudeServer() }, targets: ['claude'] }); + const harness = await startHarness(root); + const first = await (await fetch(`${harness.url}/web/status/status`)).text(); + const second = await (await fetch(`${harness.url}/web/status/status`)).text(); + expect(harness.service.toolCalls).toBe(1); + const openingOf = (html: string): string => { + const match = html.match(/"opening":"([^"]+)"/u); + if (match?.[1] === undefined) throw new Error('Web host seed does not contain opening.'); + return match[1]; + }; + expect(openingOf(second)).not.toBe(openingOf(first)); + }); + + it('re-runs an opening tool annotated readOnlyHint on every page load', async () => { + const root = await artifactRoot(); + await writeFixture(root, { projections: { '.mcp.json': claudeServer() }, targets: ['claude'] }); + const harness = await startHarness(root); + harness.service.readOnlyOpeningTool = true; + expect((await fetch(`${harness.url}/web/status/status`)).status).toBe(200); + expect((await fetch(`${harness.url}/web/status/status`)).status).toBe(200); + expect(harness.service.toolCalls).toBe(2); + }); + + it('shares one in-flight mutating opening call across concurrent first loads', async () => { + const root = await artifactRoot(); + await writeFixture(root, { projections: { '.mcp.json': claudeServer() }, targets: ['claude'] }); + const harness = await startHarness(root); + harness.service.gateToolCalls = true; + const loads = Promise.all([ + fetch(`${harness.url}/web/status/status`), + fetch(`${harness.url}/web/status/status`), + ]); + for (let turn = 0; turn < 200 && harness.service.toolCallReleases.length === 0; turn += 1) { + await new Promise((done) => setTimeout(done, 5)); + } + await settle(); + harness.service.toolCallReleases.splice(0).forEach((release) => release()); + const responses = await loads; + expect(responses.map((response) => response.status)).toEqual([200, 200]); + expect(harness.service.toolCalls).toBe(1); + }); + + it('drops a failed mutating opening call so the next load retries', async () => { + const root = await artifactRoot(); + await writeFixture(root, { projections: { '.mcp.json': claudeServer() }, targets: ['claude'] }); + const harness = await startHarness(root); + harness.service.failNextToolCall = true; + expect((await fetch(`${harness.url}/web/status/status`)).status).toBe(502); + expect((await fetch(`${harness.url}/web/status/status`)).status).toBe(200); + expect(harness.service.toolCalls).toBe(2); + }); +}); diff --git a/packages/agent-bundle/tests/web-launch.test.ts b/packages/agent-bundle/tests/web-launch.test.ts index 370d688d7..1b43874bf 100644 --- a/packages/agent-bundle/tests/web-launch.test.ts +++ b/packages/agent-bundle/tests/web-launch.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -19,8 +19,17 @@ const artifactRoot = async (): Promise => { return root; }; +const homeRoot = async (): Promise => { + const home = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-web-home-'))); + roots.push(home); + return home; +}; + afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); + await Promise.all(roots.splice(0).map(async (root) => { + await chmod(root, 0o755).catch(() => undefined); + await rm(root, { force: true, recursive: true }); + })); }); const app = (overrides: Partial = {}): WebManifestApp => ({ @@ -95,8 +104,9 @@ describe('resolveWebLaunch', () => { }); describe('path tokens in declared env', () => { - it('expands plugin-root, plugin-data, and workspace-root, and creates the per-server data directory', async () => { + it('expands plugin-root, plugin-data, and workspace-root, and creates the per-server data directory outside the artifact', async () => { const root = await artifactRoot(); + const home = await homeRoot(); const launch = await resolveWebLaunch({ app: app({ env: { @@ -107,29 +117,64 @@ describe('resolveWebLaunch', () => { }, }), env: {}, + home, pluginRoot: root, }); - const data = webPluginDataDirectory(root, 'status'); - expect(data).toBe(join(root, '.agent-bundle', 'web', 'status')); + const data = webPluginDataDirectory(root, 'status', home); + expect(data.startsWith(join(home, '.agent-bundle', 'web-data') + '/')).toBe(true); + expect(data.startsWith(root)).toBe(false); + expect(data.endsWith('/status')).toBe(true); expect(launch.env['CACHE']).toBe(`${data}/cache`); expect(launch.env['HOME_DIR']).toBe(root); expect(launch.env['MIXED']).toBe(`${root}:${process.cwd()}`); expect(launch.env['PLAIN']).toBe('kept as is'); expect((await stat(data)).isDirectory()).toBe(true); + expect(await exists(join(root, '.agent-bundle'))).toBe(false); + }); + + it('keys the state on the resolved plugin root, so two installs never share it', async () => { + const home = await homeRoot(); + const first = await artifactRoot(); + const second = await artifactRoot(); + expect(webPluginDataDirectory(first, 'status', home)).not.toBe(webPluginDataDirectory(second, 'status', home)); + expect(webPluginDataDirectory(first, 'status', home)).toBe(webPluginDataDirectory(`${first}/mcp/..`, 'status', home)); }); it('creates no data directory when no declared value names plugin-data', async () => { const root = await artifactRoot(); - await resolveWebLaunch({ app: app({ env: { HOME_DIR: pathTokens.pluginRoot } }), env: {}, pluginRoot: root }); + const home = await homeRoot(); + await resolveWebLaunch({ app: app({ env: { HOME_DIR: pathTokens.pluginRoot } }), env: {}, home, pluginRoot: root }); expect(await exists(join(root, '.agent-bundle'))).toBe(false); + expect(await exists(join(home, '.agent-bundle'))).toBe(false); }); it('keeps a hostile server name inside the data root', async () => { const root = await artifactRoot(); - const data = webPluginDataDirectory(root, '../shared'); - expect(data.startsWith(join(root, '.agent-bundle', 'web') + '/')).toBe(true); + const home = await homeRoot(); + const data = webPluginDataDirectory(root, '../shared', home); + expect(data.startsWith(join(home, '.agent-bundle', 'web-data') + '/')).toBe(true); expect(data).not.toContain('..'); }); + + it('launches from a read-only artifact when the server declares plugin-data state', async () => { + const root = await artifactRoot(); + const home = await homeRoot(); + await chmod(root, 0o555); + try { + const launch = await resolveWebLaunch({ + app: app({ env: { CACHE: `${pathTokens.pluginData}/cache` } }), + env: {}, + home, + pluginRoot: root, + }); + const data = webPluginDataDirectory(root, 'status', home); + expect(launch.env['CACHE']).toBe(`${data}/cache`); + expect((await stat(data)).isDirectory()).toBe(true); + expect(await readdir(root)).toEqual(['mcp']); + } finally { + await chmod(root, 0o755); + } + }); }); describe('environment precedence', () => { diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 958fb39f3..63bfc115c 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -1031,8 +1031,10 @@ node /bin/.mjs web [/] [--port N] [--open|--no-open] The default App is the only exposed one; several without a selector is a usage error (exit `2`) that lists them. Missing `agent-bundle.manifest.json` beside `bin/` exits `1`. The command launches the plugin's own MCP server (`node /mcp/.mjs`, -`AGENT_BUNDLE_PLUGIN_ROOT=`, declared static env with path tokens expanded), calls the -opening tool once, serves the App at `http://127.0.0.1:/` on a loopback origin with a +`AGENT_BUNDLE_PLUGIN_ROOT=`, declared static env with path tokens expanded — the +plugin-data token resolves outside the artifact, under +`~/.agent-bundle/web-data/-/`, so a read-only install still launches), +calls the opening tool once, serves the App at `http://127.0.0.1:/` on a loopback origin with a second loopback sandbox origin for the App document (the same host stack and consent behavior as `agent-bundle serve-app` and the Workbench), prints `MCP App / at (tool ; Ctrl-C stops the server)` (or one JSON line with @@ -1043,7 +1045,18 @@ Ctrl-C / SIGTERM. `web` is listed in the bin's `--help`. An authored `src/cli/we `agent-bundle dev` serves the same page, relay, routes, sandbox proxy, and consent behavior at `GET /web//` on the foreground server, bound to the current epoch's server session. -Apps not listed in `web.apps` are 404. +Apps not listed in `web.apps` are 404. The server's launch comes from the projections the +artifact manifest declares, never from the browser presentation profile: an explicit +`?target=` is validated against the declared projections that launch the server +(invalid is an error, never a fallback), projections sharing one normalized launch descriptor +(command, arguments, cwd, declared env, runtime binding) open unprompted, and materially +different launches answer 409 naming the choices — a Claude- or Codex-only build opens its App +without a portable projection or `mcp.json`. Web sessions are cached by epoch, server, and +resolved launch identity; a successful rebuild retires unused sessions of older epochs (pages +still leasing one keep it until their last lease releases), and a failed rebuild retires +nothing. An opening tool annotated +`readOnlyHint: true` runs on every page load; any other opening tool runs once per session, +tool, App, and input, and a refresh rebinds that retained result. `agent-bundle serve-app` is unchanged: it is the checkout-time form that builds or points at an artifact and needs the framework installed. Every option is in the diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 63837ba8e..c67592a30 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -102,7 +102,11 @@ serves one App standalone in a plain browser tab through `agent-bundle serve-app project configures [`web`](../../reference/configuration.mdx#web), `agent-bundle dev` also serves `GET /web//` on the foreground server — the same page, relay, routes, sandbox proxy, and consent behavior, bound to the current epoch's server session. Apps not listed in `web.apps` -are 404. See [Exposing an App in the browser](../authoring/mcp.mdx#exposing-an-app-in-the-browser). +are 404. The launch resolves from the artifact's declared projections (`?target=` is validated, +and materially different launches answer 409 naming the choices), web sessions are keyed by +epoch, server, and launch identity and retire only when a rebuild publishes a new epoch, and a +non-read-only opening tool runs once per session, tool, App, and input. See +[Exposing an App in the browser](../authoring/mcp.mdx#exposing-an-app-in-the-browser). A Skill leaf renders the emitted Skill document by default. Source/generated differences, frontmatter, resources, and eval coverage live in the inspector. Raw HTML, JSX/MDX, and Mermaid diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 839e2ca6e..685e6f483 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -135,8 +135,10 @@ plugin root — `agent-bundle.manifest.json` must sit beside `bin/` (exit `1` ot | `--json` | off | Print one JSON line `{ app, server, tool, url, port, resourceUri, sandboxOrigin }` instead of the human ready line, then keep running. | The command launches the plugin's own MCP server (`node /mcp/.mjs`, -`AGENT_BUNDLE_PLUGIN_ROOT=`, declared static env with path tokens expanded), calls the -opening tool once, and serves the App at `http://127.0.0.1:/` on a loopback origin with a +`AGENT_BUNDLE_PLUGIN_ROOT=`, declared static env with path tokens expanded — the +plugin-data token resolves outside the artifact, under +`~/.agent-bundle/web-data/-/`, so a read-only install still launches), +calls the opening tool once, and serves the App at `http://127.0.0.1:/` on a loopback origin with a second loopback sandbox origin for the App document — the same host stack and consent behavior as `agent-bundle serve-app` and the Workbench. Human mode prints `MCP App / at (tool ; Ctrl-C stops the server)` and runs until Ctrl-C / diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 000de4cec..28dfcefbe 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -896,7 +896,8 @@ node /bin/.mjs web [/] [--port N] [--open|--no-open] 默认 App 是唯一被暴露的那个;暴露了多个却未选择时是用法错误(退出码 `2`)并列出它们。`bin/` 旁边 缺少 `agent-bundle.manifest.json` 则以 `1` 退出。该命令启动插件自己的 MCP 服务器 (`node /mcp/.mjs`,`AGENT_BUNDLE_PLUGIN_ROOT=`,已声明的静态 env 并展开路径 -令牌),先调用一次开场工具,然后在 `http://127.0.0.1:/` 上用 loopback origin 提供 App,并为 +令牌——plugin-data 令牌解析到产物之外的 `~/.agent-bundle/web-data/-/`, +因此只读安装也能启动),先调用一次开场工具,然后在 `http://127.0.0.1:/` 上用 loopback origin 提供 App,并为 App 文档再开一个 loopback 沙箱 origin(与 `agent-bundle serve-app` 以及 Workbench 同一套宿主栈与 同意行为),打印 `MCP App / at (tool ; Ctrl-C stops the server)`(或带 `--json` 时打印一行 `{ app, server, tool, url, port, resourceUri, sandboxOrigin }`),并一直运行 @@ -904,7 +905,15 @@ App 文档再开一个 loopback 沙箱 origin(与 `agent-bundle serve-app` 以 会被 `AB4341` 拒绝。全部标志见[命令行参考](../../reference/cli.mdx#plugin-web)。 `agent-bundle dev` 在前台服务器的 `GET /web//` 上提供同一页面、中继、路由、沙箱代理 -与同意行为,绑定到当前 epoch 的服务器会话。未列入 `web.apps` 的 App 返回 404。 +与同意行为,绑定到当前 epoch 的服务器会话。未列入 `web.apps` 的 App 返回 404。服务器的启动方式来自 +产物清单声明的 projection,而绝不来自浏览器展示 profile:显式的 `?target=` 会对照声明 +了该服务器启动方式的 projection 校验(无效即报错,绝不回退);共享同一个规范化启动描述符(command、 +args、cwd、声明的 env、运行时绑定)的 projection 无需询问即可打开;实质不同的启动方式则以 409 应答 +并列出可选项——只有 Claude 或 Codex projection 的构建不需要 portable projection 或 `mcp.json` 就能 +打开它的 App。Web 会话按 epoch、服务器与解析后的启动身份缓存;一次成功的重建会退役旧 epoch 中未被 +使用的会话(仍被页面租用的保持有效,直到最后一个租约释放时关闭),失败的重建则不退役任何会话。标注了 `readOnlyHint: true` 的 +开场工具在每次页面加载时运行;其他开场工具在每个会话、工具、App 与输入组合下只运行一次,刷新会重新 +绑定保留的结果。 `agent-bundle serve-app` 保持不变:它是 checkout 时的形态,会构建或指向一份产物,并需要已安装的 框架。全部选项见[命令行参考](../../reference/cli.mdx#serve-app)。 diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index bbfabd194..c1a062848 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -86,7 +86,10 @@ App 叶子把沙箱化的 MCP App 预览放在工作区中央,并绑定到它 已绑定的 App。同一套宿主栈通过 `agent-bundle serve-app` 在普通浏览器标签页里独立托管一个 App;当项目配置了 [`web`](../../reference/configuration.mdx#web),`agent-bundle dev` 也会在前台服务器上提供 `GET /web//`——同样的页面、中继、路由、沙箱代理与同意行为,绑定到当前 epoch 的服务器会话。 -未列在 `web.apps` 中的 App 返回 404。见[在浏览器中暴露 App](../authoring/mcp.mdx#在浏览器中暴露-app)。 +未列在 `web.apps` 中的 App 返回 404。启动方式从产物声明的 projection 解析(`?target=` 会被校验, +实质不同的启动方式以 409 应答并列出可选项);Web 会话按 epoch、服务器与启动身份缓存,只在重建发布 +新 epoch 时退役;非只读的开场工具在每个会话、工具、App 与输入组合下只运行一次。见 +[在浏览器中暴露 App](../authoring/mcp.mdx#在浏览器中暴露-app)。 Skill 叶子默认渲染输出的 Skill 文档。源码/生成差异、frontmatter、资源与 eval 覆盖位于检查器中。Skill Markdown 中的原始 HTML、JSX/MDX 与 Mermaid 保持惰性。 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index b2a639e87..235423aca 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -130,7 +130,9 @@ node /bin/.mjs web [/] [--port N] [--open|--no-open] | `--json` | 关闭 | 打印一行 JSON `{ app, server, tool, url, port, resourceUri, sandboxOrigin }` 代替人类可读就绪行,然后继续运行。 | 该命令启动插件自己的 MCP 服务器(`node /mcp/.mjs`,`AGENT_BUNDLE_PLUGIN_ROOT=`, -已声明的静态 env 并展开路径令牌),先调用一次开场工具,然后在 `http://127.0.0.1:/` 上用 +已声明的静态 env 并展开路径令牌——plugin-data 令牌解析到产物之外的 +`~/.agent-bundle/web-data/-/`,因此只读安装也能启动),先调用一次开场工具, +然后在 `http://127.0.0.1:/` 上用 loopback origin 提供 App,并为 App 文档再开一个 loopback 沙箱 origin——与 `agent-bundle serve-app` 以及 Workbench 同一套宿主栈与同意行为。人类模式打印 `MCP App / at (tool ; Ctrl-C stops the server)`,并一直运行到 Ctrl-C /