diff --git a/.changeset/fix-web-launch-identity.md b/.changeset/fix-web-launch-identity.md new file mode 100644 index 000000000..f14126d8e --- /dev/null +++ b/.changeset/fix-web-launch-identity.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Normalize ` web` and `/web` portable-plus-host launch identity to avoid false AB8023 conflicts (#633) diff --git a/packages/agent-bundle/src/dev/web-host-launch-selection.ts b/packages/agent-bundle/src/dev/web-host-launch-selection.ts index c7e47a81c..3bf8001ee 100644 --- a/packages/agent-bundle/src/dev/web-host-launch-selection.ts +++ b/packages/agent-bundle/src/dev/web-host-launch-selection.ts @@ -1,10 +1,10 @@ import { readFile } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { isAbsolute, 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 { assertInside, isInsideOrEqual, joinArtifact } from '../core/paths.ts'; import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; import { resolveMcpPathTokens } from '../services/mcp-path-tokens.ts'; import { @@ -64,6 +64,22 @@ interface LaunchCandidate { readonly target: string; } +const normalizedStdioArgument = ( + value: string, + artifactRoot: string, + cwd: string, +): string => { + if ( + !isAbsolute(value) && + !value.startsWith('./') && + !value.startsWith('../') && + !value.includes('/') && + !value.includes('\\') + ) return value; + const resolved = resolve(cwd, value); + return isInsideOrEqual(artifactRoot, resolved) ? resolved : value; +}; + /** * The normalized-launch runtime view of one projection: env values pass * through the target's stdio-argument rule after token resolution, exactly @@ -122,7 +138,7 @@ const launchIdentityOf = async ( ? artifactRoot : assertInside(artifactRoot, resolve(artifactRoot, resolved.cwd)); return digest({ - args: resolved.args, + args: resolved.args.map((argument) => normalizedStdioArgument(argument, artifactRoot, cwd)), command: resolved.command, cwd, env: resolved.env ?? {}, diff --git a/packages/agent-bundle/tests/web-host-launch-selection.test.ts b/packages/agent-bundle/tests/web-host-launch-selection.test.ts index 49330b4d6..d0b3d7cdf 100644 --- a/packages/agent-bundle/tests/web-host-launch-selection.test.ts +++ b/packages/agent-bundle/tests/web-host-launch-selection.test.ts @@ -46,6 +46,15 @@ const portableServer = (overrides: Readonly> = {}): Read ...overrides, }); +const emittedPortableServer = (overrides: Readonly> = {}): Readonly> => ({ + args: ['mcp/mcp-status.mjs'], + command: 'node', + cwd: '${PLUGIN_ROOT}', + env: { AGENT_BUNDLE_PLUGIN_ROOT: '${PLUGIN_ROOT}' }, + type: 'stdio', + ...overrides, +}); + const codexServer = (overrides: Readonly> = {}): Readonly> => ({ args: ['./mcp/mcp-status.mjs'], command: 'node', @@ -97,6 +106,25 @@ describe('selectWebLaunch', () => { expect(selection.sharedTargets).toEqual(['claude', 'portable']); }); + it('normalizes the emitted portable cwd-relative entry to the host artifact entry', async () => { + const root = await artifactRoot(); + await writeManifest(root, '.mcp.json', { status: claudeServer() }); + await writeManifest(root, 'mcp.json', { status: emittedPortableServer() }); + const selection = await select(root, { declaredTargets: ['claude', 'portable'] }); + expect(selection.target).toBe('claude'); + expect(selection.sharedTargets).toEqual(['claude', 'portable']); + }); + + it('keeps an emitted portable entry distinct when it resolves to another artifact path', async () => { + const root = await artifactRoot(); + await writeManifest(root, '.mcp.json', { status: claudeServer() }); + await writeManifest(root, 'mcp.json', { + status: emittedPortableServer({ args: ['mcp/mcp-other.mjs'] }), + }); + const error = await failure(root, { declaredTargets: ['claude', 'portable'] }); + expect(error.code).toBe('launch-ambiguous'); + }); + it('selects identically when the hosts are declared in reversed order', async () => { const root = await artifactRoot(); await writeManifest(root, '.mcp.json', { status: claudeServer() });