diff --git a/.changeset/quiet-caches-linger.md b/.changeset/quiet-caches-linger.md new file mode 100644 index 000000000..c50bd0b0a --- /dev/null +++ b/.changeset/quiet-caches-linger.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Record Claude package, cache, substitution, and persistent-data lifecycle contracts, and reject path tokens from undocumented MCP and LSP fields. diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index 4a55e52e2..da122fdd0 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -345,6 +345,82 @@ ] } }, + "packageLifecycle": { + "nodeDependencyInstall": { + "state": "unavailable", + "reason": "Agent Bundle emits compile-time plugin artifacts but does not currently place package.json plus a supported lockfile at the plugin root or run Claude's host-owned dependency install in a copied cache entry.", + "manifest": "package.json", + "commands": { + "bun.lock": "bun install --frozen-lockfile --ignore-scripts", + "bun.lockb": "bun install --frozen-lockfile --ignore-scripts", + "npm-shrinkwrap.json": "npm ci --ignore-scripts", + "package-lock.json": "npm ci --ignore-scripts" + }, + "lockfilePrecedence": ["bun.lock", "bun.lockb", "npm-shrinkwrap.json", "package-lock.json"], + "timeoutSeconds": 60, + "evidence": [ + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents that automatic dependency installation is eligible only when the copied plugin root contains package.json plus bun.lock, bun.lockb, npm-shrinkwrap.json, or package-lock.json, and maps those lockfiles to frozen Bun install or npm ci commands with --ignore-scripts.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents deterministic lockfile precedence as bun.lock, bun.lockb, npm-shrinkwrap.json, then package-lock.json; Claude invokes the first match from PATH without falling back when its package manager is missing.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents exact lockfile resolution, disabled preinstall/install/postinstall scripts, a 60-second timeout, non-blocking skipped or failed installs, and the possibility of a partial node_modules tree after timeout.", + "retrieved 2026-09-02: packages/agent-bundle/src/adapters/types.ts documents that prebuilt payload files are emitted beneath their required named destination, so the compiler has no current surface that copies package.json or a lockfile directly to the plugin root.", + "retrieved 2026-09-02: local Claude Code 2.1.257 `claude plugin validate --strict` accepts a plugin root carrying package.json plus package-lock.json, does not name either file, and creates no node_modules directory, proving validation does not run the documented install-time contract." + ] + }, + "yarnPnpmInstallAlternative": { + "state": "unavailable", + "reason": "Agent Bundle does not run automatic Yarn or pnpm installs; Claude skips those lockfiles at host install time, while authors must explicitly install such dependencies from a hook into persistent plugin data.", + "skippedLockfiles": ["yarn.lock", "pnpm-lock.yaml"], + "persistentDataToken": "${CLAUDE_PLUGIN_DATA}", + "evidence": [ + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents that Claude skips yarn.lock and pnpm-lock.yaml because Yarn and pnpm resolution-time configuration hooks can bypass --ignore-scripts, records the skip as a debug warning, and still loads the plugin.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference directs Yarn-, pnpm-, Python-, and lifecycle-script-dependent installs to a hook that installs into ${CLAUDE_PLUGIN_DATA}, which persists across plugin updates." + ] + }, + "pluginCacheLifecycle": { + "state": "unavailable", + "reason": "Agent Bundle emits compile-time artifacts; cache copying or linking, resolved-version directory naming, orphan marking and sweeping, symlink materialization, and host component path-escape rejection happen during Claude installation and runtime.", + "cacheRoot": "~/.claude/plugins/cache", + "orphanGraceDays": 14, + "evidence": [ + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents that marketplace plugins are copied into ~/.claude/plugins/cache, except command sources in link mode, and that each resolved version gets its own directory while release-tag versions receive a commit-SHA suffix.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents that update or uninstall marks the old version orphaned for a roughly 14-day background grace period, that sweeping runs only while at least one plugin is installed, and that Glob and Grep skip orphaned versions.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents that development symlink version entries are neither orphaned nor removed, cache parent folders remain while any directory or symlink exists, and Claude never writes version-tracking files inside a linked checkout.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents symlink containment: links within a plugin are preserved relatively, links elsewhere in the same marketplace are dereferenced for copied marketplace installs, links outside the marketplace are skipped, and non-marketplace local or command-copy installs preserve only links within the plugin.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents that a component path resolving outside the plugin root is rejected with `path escapes plugin directory`, the plugin loads without that component, and files above the plugin root are not copied into the cache.", + "retrieved 2026-09-02: local Claude Code 2.1.257 `claude plugin validate --strict` accepts a skills entry symlinked to a directory outside the plugin root and does not name it, proving symlink containment remains an install-copy check rather than a validation check." + ] + }, + "pluginPathSubstitution": { + "state": "degraded", + "reason": "Agent Bundle lowers and validates its canonical path tokens in structured Claude MCP, LSP, settings, generated-hook, and monitor fields, but authored Skill content and target-native hook documents remain opaque compile-time payloads and actual placeholder substitution is owned by Claude at runtime.", + "tokens": ["${CLAUDE_PLUGIN_ROOT}", "${CLAUDE_PLUGIN_DATA}", "${CLAUDE_PROJECT_DIR}"], + "fields": { + "skillAndAgentContent": ["anywhere"], + "hookCommands": ["command", "args"], + "monitorCommands": ["command"], + "mcpStdio": ["command", "args", "env"], + "mcpRemote": ["url", "headers", "headersHelper"], + "lsp": ["command", "args", "env", "workspaceFolder"] + }, + "evidence": [ + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents ${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA}, and ${CLAUDE_PROJECT_DIR}, exports all three to hook, MCP, and LSP subprocesses, and substitutes them anywhere in Skill and agent content and in hook and monitor commands.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents the closed structured field table: MCP stdio command, args, and env; MCP HTTP, SSE, and WS url, headers, and headersHelper; and LSP command, args, env, and workspaceFolder.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/hooks documents that command hooks substitute path placeholders in exec-form command and args as plain strings, while shell-form commands require quoting and export the same three environment variables.", + "retrieved 2026-09-02: packages/agent-bundle/src/adapters/claude.ts lowers canonical path tokens in the documented structured fields and rejects them from settings.json and undocumented MCP or LSP fields, while Skill Markdown and target-native hook documents are copied without semantic rewriting." + ] + }, + "pluginDataLifecycle": { + "state": "unavailable", + "reason": "Agent Bundle can emit ${CLAUDE_PLUGIN_DATA} references but cannot observe the last installed scope, prompt for deletion, delete persistent data, or apply Claude's --keep-data uninstall option.", + "deleteOnLastScopeUninstall": true, + "cliDeleteDefault": true, + "keepDataFlag": "--keep-data", + "evidence": [ + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents ${CLAUDE_PLUGIN_DATA} as ~/.claude/plugins/data/{sanitized-id}/, created on first reference and shared across plugin versions.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents automatic data deletion only when the plugin is uninstalled from its last installed scope, an interactive size display and deletion prompt, CLI deletion by default, and --keep-data preservation." + ] + } + }, "experimentalThemes": { "defaultDirectory": "themes", "experimental": true, diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 400faa51b..a567de22a 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -49,7 +49,6 @@ import { stringify as stringifyYaml } from 'yaml'; import { commandWriteEntries, createAdapterValidator, - hasPathToken, schemaDescriptorsFrom, sortedEntries, sourceInputs, @@ -424,7 +423,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.18.0', + adapterRevision: '1.19.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -436,6 +435,7 @@ const agentCapabilities = Object.freeze(Object.fromEntries( unavailableCapability(row.reason), ]), )); +const packageLifecycle = capabilityTable.plugin.packageLifecycle; export const claudeArtifactValidation = deepFreeze({ documents: [ @@ -511,7 +511,7 @@ const planMcpServer = ( const declaredEnv = server.env === undefined ? undefined : Object.fromEntries(Object.entries(server.env).map(([key, value]) => { - if (hasPathToken(key)) { + if (findClaudePathSubstitutionToken(key) !== undefined) { diagnostics.push(errorDiagnostic( 'claude.mcp.token.env.key', `Claude MCP environment key "${key}" cannot use a path token.`, @@ -519,13 +519,19 @@ const planMcpServer = ( } return [key, expandClaudeToken(value)]; })); + const canonicalPluginRootCwd = server.cwd === pathTokens.pluginRoot; + if (!canonicalPluginRootCwd && server.cwd !== undefined) { + const token = findClaudePathSubstitutionToken(server.cwd); + if (token !== undefined) { + diagnostics.push(unsupportedClaudeSubstitutionDiagnostic('MCP stdio', 'cwd', token)); + } + } if (diagnostics.length > 0) return { diagnostics }; const args = server.args?.map(expandClaudeToken); // Claude Code currently ignores stdio cwd at runtime (see - // anthropics/claude-code#17565), so the absolute entry path stays as the - // script-resolution hedge and the env anchor carries the working - // plugin-root guarantee; cwd is still emitted below as documented, - // schema-valid future-proofing. + // anthropics/claude-code#17565), and its placeholder table excludes cwd. + // Keep the absolute entry path plus env anchor as the working-directory + // hedge; a canonical plugin-root cwd is omitted from the emitted server. if (server.source !== undefined && server.cwd === pathTokens.pluginRoot && args?.[0] !== undefined) { args[0] = `${hookContract.commandRoot}/${args[0]}`; } @@ -534,7 +540,7 @@ const planMcpServer = ( value: { ...(args === undefined ? {} : { args }), command: expandClaudeToken(server.command), - ...(server.cwd === undefined ? {} : { cwd: expandClaudeToken(server.cwd) }), + ...(server.cwd === undefined || canonicalPluginRootCwd ? {} : { cwd: server.cwd }), env: withPluginRootEnvAnchor(declaredEnv, expandClaudeToken(pathTokens.pluginRoot)), type: 'stdio', }, @@ -548,7 +554,7 @@ const planMcpServer = ( const headers = server.headers === undefined ? undefined : Object.fromEntries(Object.entries(server.headers).map(([key, value]) => { - if (hasPathToken(key)) { + if (findClaudePathSubstitutionToken(key) !== undefined) { diagnostics.push(errorDiagnostic( 'claude.mcp.token.headers.key', `Claude MCP header key "${key}" cannot use a path token.`, @@ -593,6 +599,45 @@ const lspServerFields: ReadonlySet = new Set([ const isDataRecord = (value: unknown): value is Readonly> => typeof value === 'object' && value !== null && !Array.isArray(value); +const claudePathSubstitutionTokens = Object.freeze([ + ...Object.values(pathTokens), + '${CLAUDE_PLUGIN_ROOT}', + '${CLAUDE_PLUGIN_DATA}', + '${CLAUDE_PROJECT_DIR}', +]); + +const findClaudePathSubstitutionToken = ( + value: unknown, + seen: WeakSet = new WeakSet(), +): string | undefined => { + if (typeof value === 'string') { + return claudePathSubstitutionTokens.find((token) => value.includes(token)); + } + if (typeof value !== 'object' || value === null || seen.has(value)) return undefined; + seen.add(value); + if (Array.isArray(value)) { + for (const entry of value) { + const token = findClaudePathSubstitutionToken(entry, seen); + if (token !== undefined) return token; + } + return undefined; + } + for (const [key, entry] of Object.entries(value)) { + const token = findClaudePathSubstitutionToken(key, seen) ?? findClaudePathSubstitutionToken(entry, seen); + if (token !== undefined) return token; + } + return undefined; +}; + +const unsupportedClaudeSubstitutionDiagnostic = ( + component: string, + field: string, + token: string, +): Diagnostic => errorDiagnostic( + 'claude.substitution.token.unsupported', + `Claude ${component} field "${field}" cannot use ${JSON.stringify(token)}; the pinned placeholder table does not substitute path tokens in that field.`, +); + const isPlainDataRecord = (value: unknown): value is Readonly> => isDataRecord(value) && [null, Object.prototype].includes(Object.getPrototypeOf(value)); const dependencyFields: ReadonlySet = new Set(['marketplace', 'name', 'version']); @@ -1800,6 +1845,13 @@ const planClaudeMarketplace = (model: NormalizedPlugin): ClaudeMarketplacePlan = const expandLspToken = (value: unknown): unknown => typeof value === 'string' ? expandClaudeToken(value) : value; +const lspPathSubstitutionFields: ReadonlySet = new Set([ + 'args', + 'command', + 'env', + 'workspaceFolder', +]); + const planLspServer = ( name: string, declared: unknown, @@ -1813,11 +1865,18 @@ const planLspServer = ( return { diagnostics }; } for (const field of Object.keys(declared).sort()) { - if (lspServerFields.has(field)) continue; - diagnostics.push(errorDiagnostic( - 'claude.lsp.field.unknown', - `Claude LSP server "${name}" declares unknown field "${field}".`, - )); + if (!lspServerFields.has(field)) { + diagnostics.push(errorDiagnostic( + 'claude.lsp.field.unknown', + `Claude LSP server "${name}" declares unknown field "${field}".`, + )); + continue; + } + if (lspPathSubstitutionFields.has(field)) continue; + const token = findClaudePathSubstitutionToken(declared[field]); + if (token !== undefined) { + diagnostics.push(unsupportedClaudeSubstitutionDiagnostic(`LSP server "${name}"`, field, token)); + } } const command = declared['command']; if (typeof command !== 'string' || command.length === 0) { @@ -1836,7 +1895,7 @@ const planLspServer = ( const env = declared['env']; if (isDataRecord(env)) { for (const key of Object.keys(env).sort()) { - if (!hasPathToken(key)) continue; + if (findClaudePathSubstitutionToken(key) === undefined) continue; diagnostics.push(errorDiagnostic( 'claude.lsp.token.env.key', `Claude LSP environment key "${key}" cannot use a path token.`, @@ -2641,7 +2700,7 @@ const planSubagentStatusLine = ( )); return { diagnostics }; } - if (hasPathToken(command)) { + if (findClaudePathSubstitutionToken(command) !== undefined) { diagnostics.push(settingsTokenDiagnostic('subagentStatusLine.command')); return { diagnostics }; } @@ -2696,7 +2755,7 @@ export const planClaudeSettings = (model: NormalizedPlugin): ClaudeSettingsPlan 'claude.settings.agent.invalid', 'Claude settings agent must be a nonempty plugin agent name; it activates that agent as the main thread.', )); - } else if (hasPathToken(agent)) { + } else if (findClaudePathSubstitutionToken(agent) !== undefined) { diagnostics.push(settingsTokenDiagnostic('agent')); } else { document['agent'] = agent; @@ -3174,6 +3233,15 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Claude plugin contract does not document manifest dependencies.', ), + nodeDependencyInstall: unavailableCapability(packageLifecycle.nodeDependencyInstall.reason), + yarnPnpmInstallAlternative: unavailableCapability(packageLifecycle.yarnPnpmInstallAlternative.reason), + pluginCacheLifecycle: unavailableCapability(packageLifecycle.pluginCacheLifecycle.reason), + pluginPathSubstitution: Object.freeze({ + evidence, + reason: packageLifecycle.pluginPathSubstitution.reason, + state: 'degraded', + }), + pluginDataLifecycle: unavailableCapability(packageLifecycle.pluginDataLifecycle.reason), managedAllowManagedHooksOnly: unavailableCapability( distributionPolicy.managedAllowManagedHooksOnly.reason, ), diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 974b71b3f..11740548d 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -186,7 +186,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.16.0', + adapterRevision: '1.17.0', observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`, // Metadata schemas must exactly match the validation contract: each host's // documents, with one shared Claude-format hook schema (the pinned Codex @@ -618,6 +618,36 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ 'The pinned Codex and Cursor plugin contracts publish no dependency declaration or resolution surface; manifest dependencies reach Claude Code only.', ), ), + nodeDependencyInstall: intersectCapabilityStates( + claudeAdapter.capabilities.nodeDependencyInstall!, + unavailableCapability( + 'The unified bundle emits compile-time host artifacts and has no shared host-owned Node dependency installation transaction.', + ), + ), + yarnPnpmInstallAlternative: intersectCapabilityStates( + claudeAdapter.capabilities.yarnPnpmInstallAlternative!, + unavailableCapability( + 'The pinned Codex and Cursor contracts publish no shared Claude-style Yarn or pnpm persistent-data installation fallback.', + ), + ), + pluginCacheLifecycle: intersectCapabilityStates( + claudeAdapter.capabilities.pluginCacheLifecycle!, + unavailableCapability( + 'The unified bundle does not own one cross-host plugin cache, version resolution, orphan sweep, or symlink materialization lifecycle.', + ), + ), + pluginPathSubstitution: intersectCapabilityStates( + claudeAdapter.capabilities.pluginPathSubstitution!, + unavailableCapability( + 'The pinned Codex and Cursor contracts do not share Claude path placeholders or their component-specific substitution field table.', + ), + ), + pluginDataLifecycle: intersectCapabilityStates( + claudeAdapter.capabilities.pluginDataLifecycle!, + unavailableCapability( + 'The unified bundle cannot delete or preserve Claude persistent plugin data as one cross-host uninstall transaction.', + ), + ), managedAllowManagedHooksOnly: intersectCapabilityStates( claudeAdapter.capabilities.managedAllowManagedHooksOnly!, unavailableCapability( diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 097bcb5d9..8a6bf33a1 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -429,6 +429,76 @@ it('records dated unavailable Claude distribution and policy capability rows', ( ]); }); +const claudePackageLifecycleCapabilities = [ + 'nodeDependencyInstall', + 'yarnPnpmInstallAlternative', + 'pluginCacheLifecycle', + 'pluginPathSubstitution', + 'pluginDataLifecycle', +] as const; + +it('records dated Claude package, cache, and data lifecycle capability rows', () => { + const registry = createDefaultRegistry(); + const packageLifecycle = ( + claudeCapabilityTable.plugin as unknown as { + readonly packageLifecycle?: Readonly>; + } + ).packageLifecycle; + + expect(packageLifecycle).toBeDefined(); + if (packageLifecycle === undefined) return; + expect(Object.keys(packageLifecycle).sort()).toEqual([...claudePackageLifecycleCapabilities].sort()); + for (const capability of claudePackageLifecycleCapabilities) { + const row = packageLifecycle[capability]; + expect(row.state).toBe(capability === 'pluginPathSubstitution' ? 'degraded' : 'unavailable'); + expect(row.reason.length).toBeGreaterThan(0); + expect(row.evidence.length).toBeGreaterThan(0); + expect(row.evidence.every((line) => line.includes('retrieved 2026-09-02'))).toBe(true); + expect(registry.get('claude').capabilities[capability]).toMatchObject({ + reason: row.reason, + state: row.state, + }); + expect(registry.get('plugin').capabilities[capability]).toMatchObject({ + state: 'unavailable', + }); + } +}); + +it('pins the documented Claude dependency precedence and substitution field table', () => { + const lifecycle = claudeCapabilityTable.plugin.packageLifecycle; + + expect(lifecycle.nodeDependencyInstall).toMatchObject({ + commands: { + 'bun.lock': 'bun install --frozen-lockfile --ignore-scripts', + 'bun.lockb': 'bun install --frozen-lockfile --ignore-scripts', + 'npm-shrinkwrap.json': 'npm ci --ignore-scripts', + 'package-lock.json': 'npm ci --ignore-scripts', + }, + lockfilePrecedence: ['bun.lock', 'bun.lockb', 'npm-shrinkwrap.json', 'package-lock.json'], + manifest: 'package.json', + timeoutSeconds: 60, + }); + expect(lifecycle.yarnPnpmInstallAlternative).toMatchObject({ + persistentDataToken: '${CLAUDE_PLUGIN_DATA}', + skippedLockfiles: ['yarn.lock', 'pnpm-lock.yaml'], + }); + expect(lifecycle.pluginPathSubstitution.fields).toEqual({ + hookCommands: ['command', 'args'], + lsp: ['command', 'args', 'env', 'workspaceFolder'], + mcpRemote: ['url', 'headers', 'headersHelper'], + mcpStdio: ['command', 'args', 'env'], + monitorCommands: ['command'], + skillAndAgentContent: ['anywhere'], + }); +}); + it.each([ ['marketplaceManifest', 'completed marketplace manifest'], ['allowCrossMarketplaceDependenciesOn', 'cross-marketplace dependency allowlist'], diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index cfc7430ca..109a32adf 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -93,7 +93,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'claude')).toEqual({ - adapterRevision: '1.18.0', + adapterRevision: '1.19.0', observedVersion: '2.1.250', schemas: [ { @@ -164,7 +164,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.16.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.17.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { @@ -205,7 +205,7 @@ it('records observed capability versions and rehashes schema snapshots against p } if (target === 'claude') { - expect(sha256Hex(capability)).toBe('4db46064cce1e3f74bceb9e36a4c6da860acf183c1c1ccbb65f692d22d4e17d6'); + expect(sha256Hex(capability)).toBe('c8c191063ad7792e87d00d9d66b20efdd16f2fa660b32ec526667b023bbe665c'); } if (target === 'cursor') { expect(sha256Hex(capability)).toBe('d42dc98d3c7f1f91dd6ef733d6727618c30a7d8d41fdf030165e06ce46345223'); diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index a2f3c81c0..78b4764cf 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { access, chmod, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -286,6 +286,69 @@ nativeIt('accepts the emitted Claude marketplace under strict native validation' } }); +nativeIt('records that strict validation accepts package metadata without running the dependency install', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-package-validation-')); + + try { + await writeClaudeArtifact(root, model); + await Promise.all([ + writeFile(join(root, 'package.json'), `${JSON.stringify({ + dependencies: { 'native-validation-proof': '1.0.0' }, + name: 'native-package-proof', + version: '1.0.0', + })}\n`), + writeFile(join(root, 'package-lock.json'), `${JSON.stringify({ + lockfileVersion: 3, + name: 'native-package-proof', + packages: { + '': { + dependencies: { 'native-validation-proof': '1.0.0' }, + name: 'native-package-proof', + version: '1.0.0', + }, + }, + requires: true, + version: '1.0.0', + })}\n`), + ]); + const validation = await runClaudeValidation(root, root); + + expect(validation.code, validation.output).toBe(0); + expect(validation.output).toContain('Validation passed'); + expect(validation.output).not.toContain('package.json'); + expect(validation.output).not.toContain('package-lock.json'); + await expect(access(join(root, 'node_modules'))).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +nativeIt('records that strict validation does not catch a path-escaping plugin symlink', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-symlink-validation-')); + const externalSkill = `${root}-outside-skill`; + + try { + await writeClaudeArtifact(root, model); + await mkdir(externalSkill, { recursive: true }); + await writeFile( + join(externalSkill, 'SKILL.md'), + '---\nname: outside-skill\ndescription: Lives outside the plugin root.\n---\nOutside.\n', + ); + await mkdir(join(root, 'skills'), { recursive: true }); + await symlink(externalSkill, join(root, 'skills', 'outside-skill'), 'dir'); + const validation = await runClaudeValidation(root, root); + + expect(validation.code, validation.output).toBe(0); + expect(validation.output).toContain('Validation passed'); + expect(validation.output).not.toContain('outside-skill'); + } finally { + await Promise.all([ + rm(root, { force: true, recursive: true }), + rm(externalSkill, { force: true, recursive: true }), + ]); + } +}); + nativeIt('accepts the enriched Claude marketplace under strict native validation', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-marketplace-enriched-')); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 6b1147baa..9ddaef59f 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -997,7 +997,7 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen relativePath: '.claude-plugin/plugin.json', }, { - content: '{"mcpServers":{"http":{"headers":{"Authorization":"Bearer literal"},"type":"http","url":"https://mcp.example.test/stream"},"stdio":{"args":["--root","${CLAUDE_PLUGIN_ROOT}/tools/server.mjs"],"command":"node","cwd":"${CLAUDE_PLUGIN_ROOT}","env":{"AGENT_BUNDLE_PLUGIN_ROOT":"${CLAUDE_PLUGIN_ROOT}","CACHE_DIR":"cache"},"type":"stdio"}}}\n', + content: '{"mcpServers":{"http":{"headers":{"Authorization":"Bearer literal"},"type":"http","url":"https://mcp.example.test/stream"},"stdio":{"args":["--root","${CLAUDE_PLUGIN_ROOT}/tools/server.mjs"],"command":"node","env":{"AGENT_BUNDLE_PLUGIN_ROOT":"${CLAUDE_PLUGIN_ROOT}","CACHE_DIR":"cache"},"type":"stdio"}}}\n', kind: 'write', relativePath: '.mcp.json', }, @@ -1120,7 +1120,7 @@ it('plans a Cursor workspace/open event route without enabling the plain hook vo })); }); -it('anchors compiled Claude MCP entries with absolute arguments, plugin-root cwd, and the env anchor', () => { +it('anchors compiled Claude MCP entries with absolute arguments and the env anchor', () => { const compiled = { ...plugin, mcpServers: Object.freeze([Object.freeze({ @@ -1134,14 +1134,13 @@ it('anchors compiled Claude MCP entries with absolute arguments, plugin-root cwd const document = JSON.parse(entry?.kind === 'write' ? entry.content : '{}') as { mcpServers: Record }>; }; - // The absolute entry path stays as the hedge against Claude Code ignoring - // cwd at runtime; cwd is emitted anyway as schema-valid future-proofing, - // and the env anchor is the guaranteed working-directory-independent root. + // Claude's placeholder table does not substitute MCP cwd. The absolute + // entry path and env anchor keep the generated server independent of cwd. expect(document.mcpServers.stdio).toMatchObject({ args: ['${CLAUDE_PLUGIN_ROOT}/mcp/compiled-server.mjs'], - cwd: '${CLAUDE_PLUGIN_ROOT}', env: { AGENT_BUNDLE_PLUGIN_ROOT: '${CLAUDE_PLUGIN_ROOT}', CACHE_DIR: 'cache' }, }); + expect(document.mcpServers.stdio).not.toHaveProperty('cwd'); }); it('emits Claude LSP configuration and expands only the four documented token fields', () => { @@ -1159,11 +1158,11 @@ it('emits Claude LSP configuration and expands only the four documented token fi ROOT: pathTokens.pluginRoot, WORKSPACE: pathTokens.workspaceRoot, }, - extensionToLanguage: { '.ts': `typescript-${pathTokens.pluginRoot}` }, - initializationOptions: { token: pathTokens.pluginData }, + extensionToLanguage: { '.ts': 'typescript' }, + initializationOptions: { token: 'literal' }, maxRestarts: 3, restartOnCrash: true, - settings: { token: pathTokens.workspaceRoot }, + settings: { token: 'literal' }, shutdownTimeout: 2_000, startupTimeout: 5_000, transport: 'socket', @@ -1188,11 +1187,11 @@ it('emits Claude LSP configuration and expands only the four documented token fi ROOT: '${CLAUDE_PLUGIN_ROOT}', WORKSPACE: '${CLAUDE_PROJECT_DIR}', }, - extensionToLanguage: { '.ts': `typescript-${pathTokens.pluginRoot}` }, - initializationOptions: { token: pathTokens.pluginData }, + extensionToLanguage: { '.ts': 'typescript' }, + initializationOptions: { token: 'literal' }, maxRestarts: 3, restartOnCrash: true, - settings: { token: pathTokens.workspaceRoot }, + settings: { token: 'literal' }, shutdownTimeout: 2_000, startupTimeout: 5_000, transport: 'socket', @@ -1202,6 +1201,22 @@ it('emits Claude LSP configuration and expands only the four documented token fi expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('lspServers'); }); +it('rejects Claude path substitutions in undocumented LSP fields', () => { + const plan = createDefaultRegistry().get('claude').plan(withClaudeLsp(plugin, { + typescript: { + command: 'typescript-language-server', + extensionToLanguage: { '.ts': `typescript-${pathTokens.pluginRoot}` }, + initializationOptions: { data: '${CLAUDE_PLUGIN_DATA}' }, + }, + })); + + expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'claude.substitution.token.unsupported', + 'claude.substitution.token.unsupported', + ]); + expect(plan.entries.some((entry) => entry.relativePath === '.lsp.json')).toBe(false); +}); + it('emits sorted, allowlisted Claude userConfig declarations with config provenance', () => { const model = withClaudeUserConfig(plugin, { z_count: { @@ -2455,7 +2470,7 @@ it('records every selected component provenance for generated host documents', ( } }); -it('applies only native path-token semantics and surfaces exact capability diagnostics', () => { +it('accepts Claude path tokens only in the documented MCP fields and rejects stdio cwd', () => { const codex = createDefaultRegistry().get('codex').plan({ ...plugin, mcpServers: [ @@ -2490,7 +2505,7 @@ it('applies only native path-token semantics and surfaces exact capability diagn { args: [`${pathTokens.workspaceRoot}/tool`], command: pathTokens.pluginRoot, - cwd: pathTokens.pluginData, + cwd: '/workspace', env: { WORKSPACE: pathTokens.workspaceRoot }, id: 'mcp:workspace', name: 'workspace', @@ -2498,6 +2513,15 @@ it('applies only native path-token semantics and surfaces exact capability diagn targets: ['claude'], transport: 'stdio', }, + { + command: 'node', + cwd: pathTokens.pluginData, + id: 'mcp:unsupported-cwd', + name: 'unsupported-cwd', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + targets: ['claude'], + transport: 'stdio', + }, { headers: { [pathTokens.pluginRoot]: 'literal' }, id: 'mcp:header-key', @@ -2510,10 +2534,11 @@ it('applies only native path-token semantics and surfaces exact capability diagn ], }); expect(claude.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'claude.substitution.token.unsupported', 'claude.mcp.token.headers.key', ]); expect(claude.entries.find((entry) => entry.relativePath === '.mcp.json')).toEqual({ - content: '{"mcpServers":{"workspace":{"args":["${CLAUDE_PROJECT_DIR}/tool"],"command":"${CLAUDE_PLUGIN_ROOT}","cwd":"${CLAUDE_PLUGIN_DATA}","env":{"AGENT_BUNDLE_PLUGIN_ROOT":"${CLAUDE_PLUGIN_ROOT}","WORKSPACE":"${CLAUDE_PROJECT_DIR}"},"type":"stdio"}}}\n', + content: '{"mcpServers":{"workspace":{"args":["${CLAUDE_PROJECT_DIR}/tool"],"command":"${CLAUDE_PLUGIN_ROOT}","cwd":"/workspace","env":{"AGENT_BUNDLE_PLUGIN_ROOT":"${CLAUDE_PLUGIN_ROOT}","WORKSPACE":"${CLAUDE_PROJECT_DIR}"},"type":"stdio"}}}\n', kind: 'write', relativePath: '.mcp.json', sourceInputs: ['/workspace/agent-bundle.config.ts'], diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 8c2f95d17..ed87c36bb 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -105,7 +105,7 @@ it('lays both host manifests over one shared bundle root', () => { readonly mcpServers: Record }>; }; expect(claudeMcp.mcpServers['status']!.args[0]).toBe('${CLAUDE_PLUGIN_ROOT}/mcp/server.mjs'); - expect(claudeMcp.mcpServers['status']!.cwd).toBe('${CLAUDE_PLUGIN_ROOT}'); + expect(claudeMcp.mcpServers['status']).not.toHaveProperty('cwd'); expect(claudeMcp.mcpServers['status']!.env).toEqual({ AGENT_BUNDLE_PLUGIN_ROOT: '${CLAUDE_PLUGIN_ROOT}' }); const codexMcp = JSON.parse(documents['.codex-plugin/mcp.json']!) as {