From 683d854034efc0a24173013416ec04435d2d9356 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:07:20 +0000 Subject: [PATCH 1/7] fix(564): move framework-owned web state outside the installed artifact web anchored per-server plugin data at /.agent-bundle/web, mutating the installed artifact. Durable web state now lives under the user's home (~/.agent-bundle/web-data/-/), keyed by the resolved plugin root so two installs never share it, and a read-only install still launches when the server declares plugin-data state. The configured args still pass through app.args.map(expand) unchanged. Co-authored-by: Zack Jackson --- packages/agent-bundle/src/web-host/launch.ts | 34 +++++++++-- .../agent-bundle/tests/web-launch.test.ts | 61 ++++++++++++++++--- 2 files changed, 82 insertions(+), 13 deletions(-) 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/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', () => { From a38aaa8f208c4c4668b352d4d6902ca7f25390f1 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:07:34 +0000 Subject: [PATCH 2/7] fix(564): one effective web launch from declared projections; identity-keyed sessions, epoch retirement, opening-tool policy The dev /web route hardcoded target 'portable', so browser presentation forced the portable projection: a Claude- or Codex-only build could not open /web// without mcp.json. The launch now resolves from the projections the artifact manifest declares, over the same canonical resolution mcp run and MCP sessions use (readTargetMcpServer + resolveMcpPathTokens, env values re-anchored through the target's stdio argument rule): 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, args, cwd, declared env, runtime binding) open unprompted whatever the host order; materially different launches answer 409 naming the choices; no candidate reports the missing binding instead of synthesizing portable. Selection settles before any spawn. Web sessions are cached by epoch, server, and resolved launch identity, and retire on successful epoch publication only (artifact.available): new acquisitions use the new epoch, an old session leased only by this registry closes and releases its process and epoch reference, sessions pages still lease stay valid, and a failed rebuild retires nothing. Opening an App page is no longer an unbounded mutation: an opening tool annotated readOnlyHint: true runs on every load, any other opening tool runs once per session, tool, App, and input, and refreshes rebind the retained result. The page's previewProfile stays presentation-only. Co-authored-by: Zack Jackson --- .../agent-bundle/src/dev/foreground-server.ts | 20 +- .../dev/mcp-session/mcp-session-service.ts | 6 + .../src/dev/web-host-launch-selection.ts | 198 ++++++++++ .../agent-bundle/src/dev/web-host-routes.ts | 186 +++++++-- .../agent-bundle/src/dev/workbench-server.ts | 1 + .../agent-bundle/src/web-host/manifest.ts | 26 +- .../agent-bundle/src/web-host/select-app.ts | 24 +- .../tests/web-host-launch-selection.test.ts | 180 +++++++++ .../tests/web-host-routes-unit.test.ts | 373 ++++++++++++++++++ 9 files changed, 977 insertions(+), 37 deletions(-) create mode 100644 packages/agent-bundle/src/dev/web-host-launch-selection.ts create mode 100644 packages/agent-bundle/tests/web-host-launch-selection.test.ts create mode 100644 packages/agent-bundle/tests/web-host-routes-unit.test.ts 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..9697f3476 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 @@ -425,6 +425,12 @@ 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; + } + 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..5bab3230a 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,29 @@ 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 and releases its process and epoch + * reference, while one that pages still lease stays valid for them — it is + * only no longer handed out. 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 || service.appLeaseCount(registered.session.id) > 1) return; + this.#dropSessionState(registered.session.id); + await registered.dispose(); + await service.closeSession(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 +248,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 +271,83 @@ 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 + * result per session, tool, App, and input is retained and every later + * load of the same page rebinds that result instead of re-running the + * tool; a new session (a new epoch after rebuild) runs it once again. + */ + async #openingCallFor( + source: AppSelectionSource, + sessionId: string, + resolved: ResolvedAppOpening, + ): Promise { + const readOnly = isRecord(resolved.tool['annotations']) && resolved.tool['annotations']['readOnlyHint'] === true; + const key = `${sessionId}\0${resolved.tool.name}\0${resolved.resourceUri}\0${digest(resolved.input)}`; + const retained = readOnly ? undefined : this.#openingResults.get(key); + if (retained !== undefined) return retained; + const result = await source.callTool(resolved.tool.name, resolved.input); + const call: McpAppOpeningCall = Object.freeze({ input: resolved.input, result }); + if (!readOnly) this.#retainOpeningResult(key, call); + return call; + } + async #exposedApp( epochs: WebHostEpochSource, + launchOptions: WebHostLaunchOptions, requested: WebHostRoute, ): Promise> { let reference: WebHostEpochReference; @@ -262,26 +357,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 +407,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 +436,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 +461,14 @@ export class WebHostRoutes { } } + #retainOpeningResult(key: string, call: McpAppOpeningCall): 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/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/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..968d08b9b --- /dev/null +++ b/packages/agent-bundle/tests/web-host-routes-unit.test.ts @@ -0,0 +1,373 @@ +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; + readonly #leases = new Map(); + + 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; + 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; + this.#leases.set(sessionId, Math.max(0, (this.#leases.get(sessionId) ?? 1) - 1)); + }, + session: {}, + watchSessionClosed: () => ({ closed: false, unsubscribe: () => undefined }), + }; + } + + appLeaseCount(sessionId: string): number { + return this.#leases.get(sessionId) ?? 0; + } + + async closeSession(sessionId: string): Promise { + this.closed.push(sessionId); + this.#leases.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(); + + 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); + }); +}); From 067ac744e2854fdcfe92d5fb8c79a529b48bfed1 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:07:40 +0000 Subject: [PATCH 3/7] docs(564): document web launch selection, opening-tool policy, web state location, and session retirement Co-authored-by: Zack Jackson --- .changeset/564-web-surface.md | 2 ++ docs/entry-conventions.md | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/.changeset/564-web-surface.md b/.changeset/564-web-surface.md index 404c5a4e3..661b4f4f9 100644 --- a/.changeset/564-web-surface.md +++ b/.changeset/564-web-surface.md @@ -3,3 +3,5 @@ --- Add the `web` config key to expose declared MCP Apps in a browser: `web.apps` selects `/` entries already under `mcp.servers..apps`, `web.open` is `browser` or `never` (default `never`), and invalid exposure reports `AB4341`. When `web` is configured, the composite artifact's `agent-bundle.manifest.json` gains a `web` section (each App carries its server's `entry`, `args`, and `env`; Apps scoped to unselected targets are omitted) and `bin/.mjs` carries the framework-owned ` web` command even without `src/cli/**`; `agent-bundle dev` serves the same host at `/web//`. Host adapters publish a `web` capability row that gates the web-only bin like `cli` gates a routed CLI; a target without it is an `AB4341` warning, and an authored command or alias spelled `web` is `AB4341`. Remove `agent-bundle/serve-app-command` (`spawnServeApp`, `serveAppArgv`, `locateFrameworkCli`, `ServeAppCommandError`); the supported path from an installed artifact is ` web`. `web` never displaces an authored executable: a hand-written `src/cli.ts`, a `bin` entry claiming the plugin name, or `bin: false` keeps its bin and `AB4341` reports the web surface with nowhere to live. Fix `agent-bundle dev` rebuilding the epoch it just produced whenever the project has a `dist/` package build: the watcher now ignores the build's `.dist.stage-*` staging directory. (#620) + +Follow-up (#620 review): browser presentation never selects a host artifact. The dev `/web` route resolves the server's launch from the artifact's declared projections — explicit `?target=` is validated (invalid is an error, never a fallback), projections sharing one normalized launch open unprompted whatever the host order, materially different launches answer 409 naming the choices, and no portable projection or `mcp.json` is required for a Claude- or Codex-only build. Web sessions are cached by epoch, server, and resolved launch identity; a successful rebuild retires unused old-epoch web sessions while leased ones stay valid, and a failed rebuild retires nothing. Opening an App page re-runs the opening tool only when it is annotated `readOnlyHint: true`; any other opening tool runs once per session, tool, App, and input, and refreshes rebind the retained result. ` web` keeps the installed artifact immutable: per-server web state moves out of the plugin root to `~/.agent-bundle/web-data/-/`, so a read-only install launches. diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 715da7dae..bf3a9a817 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), 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 From 50c1ee7ecb6177174fa9b9ea54e59a1b6b3da970 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:12:38 +0000 Subject: [PATCH 4/7] chore(564): own changeset for the #620 follow-up; leave the merged PR's changeset untouched Co-authored-by: Zack Jackson --- .changeset/564-web-surface.md | 2 -- .changeset/620-web-launch-followup.md | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 .changeset/620-web-launch-followup.md diff --git a/.changeset/564-web-surface.md b/.changeset/564-web-surface.md index 661b4f4f9..404c5a4e3 100644 --- a/.changeset/564-web-surface.md +++ b/.changeset/564-web-surface.md @@ -3,5 +3,3 @@ --- Add the `web` config key to expose declared MCP Apps in a browser: `web.apps` selects `/` entries already under `mcp.servers..apps`, `web.open` is `browser` or `never` (default `never`), and invalid exposure reports `AB4341`. When `web` is configured, the composite artifact's `agent-bundle.manifest.json` gains a `web` section (each App carries its server's `entry`, `args`, and `env`; Apps scoped to unselected targets are omitted) and `bin/.mjs` carries the framework-owned ` web` command even without `src/cli/**`; `agent-bundle dev` serves the same host at `/web//`. Host adapters publish a `web` capability row that gates the web-only bin like `cli` gates a routed CLI; a target without it is an `AB4341` warning, and an authored command or alias spelled `web` is `AB4341`. Remove `agent-bundle/serve-app-command` (`spawnServeApp`, `serveAppArgv`, `locateFrameworkCli`, `ServeAppCommandError`); the supported path from an installed artifact is ` web`. `web` never displaces an authored executable: a hand-written `src/cli.ts`, a `bin` entry claiming the plugin name, or `bin: false` keeps its bin and `AB4341` reports the web surface with nowhere to live. Fix `agent-bundle dev` rebuilding the epoch it just produced whenever the project has a `dist/` package build: the watcher now ignores the build's `.dist.stage-*` staging directory. (#620) - -Follow-up (#620 review): browser presentation never selects a host artifact. The dev `/web` route resolves the server's launch from the artifact's declared projections — explicit `?target=` is validated (invalid is an error, never a fallback), projections sharing one normalized launch open unprompted whatever the host order, materially different launches answer 409 naming the choices, and no portable projection or `mcp.json` is required for a Claude- or Codex-only build. Web sessions are cached by epoch, server, and resolved launch identity; a successful rebuild retires unused old-epoch web sessions while leased ones stay valid, and a failed rebuild retires nothing. Opening an App page re-runs the opening tool only when it is annotated `readOnlyHint: true`; any other opening tool runs once per session, tool, App, and input, and refreshes rebind the retained result. ` web` keeps the installed artifact immutable: per-server web state moves out of the plugin root to `~/.agent-bundle/web-data/-/`, so a read-only install launches. diff --git a/.changeset/620-web-launch-followup.md b/.changeset/620-web-launch-followup.md new file mode 100644 index 000000000..953a8f23c --- /dev/null +++ b/.changeset/620-web-launch-followup.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Follow-up to the `web` surface (#620 review): browser presentation never selects a host artifact. The dev `/web` route resolves the server's launch from the artifact's declared projections — explicit `?target=` is validated (invalid is an error, never a fallback), projections sharing one normalized launch open unprompted whatever the host order, materially different launches answer 409 naming the choices, and no portable projection or `mcp.json` is required for a Claude- or Codex-only build. Web sessions are cached by epoch, server, and resolved launch identity; a successful rebuild retires unused old-epoch web sessions while leased ones stay valid, and a failed rebuild retires nothing. Opening an App page re-runs the opening tool only when it is annotated `readOnlyHint: true`; any other opening tool runs once per session, tool, App, and input, and refreshes rebind the retained result. ` web` keeps the installed artifact immutable: per-server web state moves out of the plugin root to `~/.agent-bundle/web-data/-/`, so a read-only install launches. (#620) From 93c0ed130b788981eeac3ef5bdfe898c31ee8be2 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:24:16 +0000 Subject: [PATCH 5/7] docs(website): dev /web launch selection, opening-tool policy, session retirement, web state location (en+zh) Co-authored-by: Zack Jackson --- website/docs/en/guide/authoring/mcp.mdx | 18 +++++++++++++++--- .../docs/en/guide/development/workbench.mdx | 6 +++++- website/docs/en/reference/cli.mdx | 6 ++++-- website/docs/zh/guide/authoring/mcp.mdx | 13 +++++++++++-- .../docs/zh/guide/development/workbench.mdx | 5 ++++- website/docs/zh/reference/cli.mdx | 4 +++- 6 files changed, 42 insertions(+), 10 deletions(-) diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 958fb39f3..a9e94040d 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,17 @@ 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), 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..15d19ac22 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 / From 5324e344c75ae07ead3627ee4346cc0055d5e1af Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:25:20 +0000 Subject: [PATCH 6/7] chore: changeset PR number (#628) Co-authored-by: Zack Jackson --- .changeset/620-web-launch-followup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/620-web-launch-followup.md b/.changeset/620-web-launch-followup.md index 953a8f23c..a00de409a 100644 --- a/.changeset/620-web-launch-followup.md +++ b/.changeset/620-web-launch-followup.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Follow-up to the `web` surface (#620 review): browser presentation never selects a host artifact. The dev `/web` route resolves the server's launch from the artifact's declared projections — explicit `?target=` is validated (invalid is an error, never a fallback), projections sharing one normalized launch open unprompted whatever the host order, materially different launches answer 409 naming the choices, and no portable projection or `mcp.json` is required for a Claude- or Codex-only build. Web sessions are cached by epoch, server, and resolved launch identity; a successful rebuild retires unused old-epoch web sessions while leased ones stay valid, and a failed rebuild retires nothing. Opening an App page re-runs the opening tool only when it is annotated `readOnlyHint: true`; any other opening tool runs once per session, tool, App, and input, and refreshes rebind the retained result. ` web` keeps the installed artifact immutable: per-server web state moves out of the plugin root to `~/.agent-bundle/web-data/-/`, so a read-only install launches. (#620) +Follow-up to the `web` surface (#620 review): browser presentation never selects a host artifact. The dev `/web` route resolves the server's launch from the artifact's declared projections — explicit `?target=` is validated (invalid is an error, never a fallback), projections sharing one normalized launch open unprompted whatever the host order, materially different launches answer 409 naming the choices, and no portable projection or `mcp.json` is required for a Claude- or Codex-only build. Web sessions are cached by epoch, server, and resolved launch identity; a successful rebuild retires unused old-epoch web sessions while leased ones stay valid, and a failed rebuild retires nothing. Opening an App page re-runs the opening tool only when it is annotated `readOnlyHint: true`; any other opening tool runs once per session, tool, App, and input, and refreshes rebind the retained result. ` web` keeps the installed artifact immutable: per-server web state moves out of the plugin root to `~/.agent-bundle/web-data/-/`, so a read-only install launches. (#628) From 749f48eb60610e86e63c434fabad10e385a04cec Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:40:40 +0000 Subject: [PATCH 7/7] fix(564): close a retired leased web session at its last lease release; share one in-flight opening call across concurrent loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review findings on #628: a session retired by epoch publication while pages still leased it was deleted from the registry and never closed — closeSessionWhenUnleased on McpSessionService now closes it at the release of its last lease (immediately when nothing leases it). The opening-result cache retains the pending call rather than its settled value, so concurrent first loads of a mutating opening tool share one tools/call, and a failed call is dropped so the next load retries. AB8023 documents the /web launch selection statuses (404 invalid target or no candidate, 409 ambiguous). Co-authored-by: Zack Jackson --- .changeset/620-web-launch-followup.md | 2 +- docs/diagnostics.md | 2 +- docs/entry-conventions.md | 4 +- .../dev/mcp-session/mcp-session-service.ts | 26 +++++++++ .../agent-bundle/src/dev/web-host-routes.ts | 47 ++++++++++------ .../tests/mcp-session-service.test.ts | 41 ++++++++++++++ .../tests/web-host-routes-unit.test.ts | 56 ++++++++++++++++++- website/docs/en/guide/authoring/mcp.mdx | 3 +- website/docs/zh/guide/authoring/mcp.mdx | 2 +- 9 files changed, 159 insertions(+), 24 deletions(-) diff --git a/.changeset/620-web-launch-followup.md b/.changeset/620-web-launch-followup.md index a00de409a..54b734aeb 100644 --- a/.changeset/620-web-launch-followup.md +++ b/.changeset/620-web-launch-followup.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Follow-up to the `web` surface (#620 review): browser presentation never selects a host artifact. The dev `/web` route resolves the server's launch from the artifact's declared projections — explicit `?target=` is validated (invalid is an error, never a fallback), projections sharing one normalized launch open unprompted whatever the host order, materially different launches answer 409 naming the choices, and no portable projection or `mcp.json` is required for a Claude- or Codex-only build. Web sessions are cached by epoch, server, and resolved launch identity; a successful rebuild retires unused old-epoch web sessions while leased ones stay valid, and a failed rebuild retires nothing. Opening an App page re-runs the opening tool only when it is annotated `readOnlyHint: true`; any other opening tool runs once per session, tool, App, and input, and refreshes rebind the retained result. ` web` keeps the installed artifact immutable: per-server web state moves out of the plugin root to `~/.agent-bundle/web-data/-/`, so a read-only install launches. (#628) +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 bf3a9a817..685d7519e 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1584,8 +1584,8 @@ 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), and a -failed rebuild retires nothing. Opening an App page is not an unbounded +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 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 9697f3476..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; @@ -431,6 +439,24 @@ export class McpSessionService { 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-routes.ts b/packages/agent-bundle/src/dev/web-host-routes.ts index 5bab3230a..7248a192c 100644 --- a/packages/agent-bundle/src/dev/web-host-routes.ts +++ b/packages/agent-bundle/src/dev/web-host-routes.ts @@ -175,7 +175,7 @@ export class WebHostRoutes { readonly #sandboxOrigin: () => string | undefined; readonly #sessionToken: string; readonly #openingCalls = new Map(); - readonly #openingResults = new Map(); + readonly #openingResults = new Map>(); readonly #sessions = new Map>(); #closed = false; @@ -204,10 +204,11 @@ 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 and releases its process and epoch - * reference, while one that pages still lease stays valid for them — it is - * only no longer handed out. A failed rebuild publishes no epoch, so it - * never reaches this method and the last working session is kept. + * 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; @@ -216,10 +217,11 @@ export class WebHostRoutes { if (key.startsWith(`${activeEpochId}\0`)) continue; this.#sessions.delete(key); void opening.then(async (registered) => { - if (service === undefined || service.appLeaseCount(registered.session.id) > 1) return; - this.#dropSessionState(registered.session.id); + if (service === undefined) return; await registered.dispose(); - await service.closeSession(registered.session.id); + if (service.closeSessionWhenUnleased(registered.session.id)) { + this.#dropSessionState(registered.session.id); + } }).catch(() => undefined); } } @@ -321,23 +323,34 @@ export class WebHostRoutes { * 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 - * result per session, tool, App, and input is retained and every later - * load of the same page rebinds that result instead of re-running the - * tool; a new session (a new epoch after rebuild) runs it once again. + * 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 = readOnly ? undefined : this.#openingResults.get(key); + const retained = this.#openingResults.get(key); if (retained !== undefined) return retained; - const result = await source.callTool(resolved.tool.name, resolved.input); - const call: McpAppOpeningCall = Object.freeze({ input: resolved.input, result }); - if (!readOnly) this.#retainOpeningResult(key, call); - return call; + 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( @@ -461,7 +474,7 @@ export class WebHostRoutes { } } - #retainOpeningResult(key: string, call: McpAppOpeningCall): void { + #retainOpeningResult(key: string, call: Promise): void { this.#openingResults.set(key, call); for (const oldest of this.#openingResults.keys()) { if (this.#openingResults.size <= maxRetainedOpeningResults) break; 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-routes-unit.test.ts b/packages/agent-bundle/tests/web-host-routes-unit.test.ts index 968d08b9b..5daa7aa4f 100644 --- a/packages/agent-bundle/tests/web-host-routes-unit.test.ts +++ b/packages/agent-bundle/tests/web-host-routes-unit.test.ts @@ -94,7 +94,11 @@ class FakeSessionService { 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)}`; @@ -103,6 +107,11 @@ class FakeSessionService { 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, @@ -125,7 +134,9 @@ class FakeSessionService { release: async () => { if (released) return; released = true; - this.#leases.set(sessionId, Math.max(0, (this.#leases.get(sessionId) ?? 1) - 1)); + 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 }), @@ -136,9 +147,20 @@ class FakeSessionService { 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; } @@ -327,6 +349,9 @@ describe('WebHostRoutes session retirement', () => { 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); @@ -370,4 +395,33 @@ describe('WebHostRoutes opening-tool policy', () => { 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/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index a9e94040d..63bfc115c 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -1053,7 +1053,8 @@ artifact manifest declares, never from the browser presentation profile: an expl 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), and a failed rebuild retires nothing. An opening tool annotated +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. diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 15d19ac22..28dfcefbe 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -911,7 +911,7 @@ App 文档再开一个 loopback 沙箱 origin(与 `agent-bundle serve-app` 以 args、cwd、声明的 env、运行时绑定)的 projection 无需询问即可打开;实质不同的启动方式则以 409 应答 并列出可选项——只有 Claude 或 Codex projection 的构建不需要 portable projection 或 `mcp.json` 就能 打开它的 App。Web 会话按 epoch、服务器与解析后的启动身份缓存;一次成功的重建会退役旧 epoch 中未被 -使用的会话(仍被页面租用的保持有效),失败的重建则不退役任何会话。标注了 `readOnlyHint: true` 的 +使用的会话(仍被页面租用的保持有效,直到最后一个租约释放时关闭),失败的重建则不退役任何会话。标注了 `readOnlyHint: true` 的 开场工具在每次页面加载时运行;其他开场工具在每个会话、工具、App 与输入组合下只运行一次,刷新会重新 绑定保留的结果。