From 6fe79466e9ab08440d07964121dcdfd7eaeb15fa Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 22:33:11 +0000 Subject: [PATCH 1/8] fix(claude): harden adapter declaration planning Validate and lower Claude declarations before they can ship unresolved or host-invalid configuration. --- .changeset/claude-adapter-review-batch-two.md | 5 + .../adapters/capabilities/claude-2.1.250.json | 6 +- packages/agent-bundle/src/adapters/claude.ts | 118 ++++++++++++------ .../adapters/schemas/claude/PROVENANCE.json | 4 +- .../adapters/schemas/claude/theme.schema.json | 2 +- .../tests/adapter-capability-states.test.ts | 35 +++++- .../tests/adapter-metadata.test.ts | 4 +- .../tests/host-adapters.native.test.ts | 11 +- .../agent-bundle/tests/host-adapters.test.ts | 33 ++++- 9 files changed, 153 insertions(+), 65 deletions(-) create mode 100644 .changeset/claude-adapter-review-batch-two.md diff --git a/.changeset/claude-adapter-review-batch-two.md b/.changeset/claude-adapter-review-batch-two.md new file mode 100644 index 000000000..2bbc83d74 --- /dev/null +++ b/.changeset/claude-adapter-review-batch-two.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Harden Claude adapter planning for monitor path tokens, theme presets, dependency resolution and ranges, indexed channel diagnostics, scoped installation capabilities, and Unicode marketplace topics. 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 f316f8b56..d18b30e6a 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 @@ -115,11 +115,11 @@ ] }, "pluginInstallScopes": { - "state": "unavailable", - "reason": "Agent Bundle emits installation instructions but cannot choose or persist a Claude plugin installation scope.", + "state": "supported", "evidence": [ "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference maps user, project, and local installs to ~/.claude/settings.json, .claude/settings.json, and .claude/settings.local.json respectively, while managed scope is read-only and update-only.", - "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents --scope user|project|local for install and uninstall, scope auto-detection for enable and disable, and --scope user|project|local|managed for update." + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents --scope user|project|local for install and uninstall, scope auto-detection for enable and disable, and --scope user|project|local|managed for update.", + "retrieved 2026-09-02: packages/agent-bundle/src/install/install.ts installPublicCli accepts the requested user, project, or local scope and forwards it to `claude plugin install ... --scope `." ] }, "pluginReload": { diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index ff63ee41d..9cb9c4d6a 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -167,9 +167,11 @@ export interface ClaudeSettingsConfig { } /** One experimental Claude Code color theme emitted under the plugin-root `themes/` directory. */ +export type ClaudeThemeBasePreset = 'dark' | 'light'; + export interface ClaudeThemeConfig { /** Built-in preset inherited before sparse token overrides are applied. */ - readonly base: string; + readonly base: ClaudeThemeBasePreset; /** Display name shown in `/theme`; defaults to the declaration key. */ readonly name?: string; /** Sparse color-token overrides. Values stay host-defined strings rather than being narrowed to hex colors. */ @@ -422,7 +424,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.16.0', + adapterRevision: '1.17.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -595,7 +597,7 @@ const semverSuffix = `(?:-${prereleaseSemverIdentifier}(?:\\.${prereleaseSemverI const fullSemverVersion = `${numericSemverIdentifier}\\.${numericSemverIdentifier}\\.${numericSemverIdentifier}${semverSuffix}`; const partialSemverVersion = `${numericSemverIdentifier}(?:\\.${numericSemverIdentifier})?`; const wildcardSemverVersion = `${numericSemverIdentifier}\\.(?:[xX*]|${numericSemverIdentifier}\\.[xX*])`; -const semverRangeVersion = `(?:${fullSemverVersion}|${wildcardSemverVersion}|${partialSemverVersion})`; +const semverRangeVersion = `(?:[xX*]|${fullSemverVersion}|${wildcardSemverVersion}|${partialSemverVersion})`; const hyphenRangePattern = new RegExp(`^${semverRangeVersion}\\s+-\\s+${semverRangeVersion}$`, 'u'); const comparatorPattern = new RegExp(`(?:~|\\^|>=|<=|>|<|=)?\\s*${semverRangeVersion}`, 'uy'); @@ -645,7 +647,10 @@ const dependencyDiagnostic = (code: string, message: string, recovery: string): recovery, }); -const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan => { +const planClaudeDependencies = ( + model: NormalizedPlugin, + emittedMarketplacePluginNames: ReadonlySet, +): ClaudeDependenciesPlan => { const extension = model.extensions[claudeName]; if (extension === undefined || !isDataRecord(extension.value)) return noDependenciesPlan; const declared = extension.value['dependencies']; @@ -701,6 +706,14 @@ const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan continue; } seen.add(identity); + if (!emittedMarketplacePluginNames.has(entry)) { + diagnostics.push(dependencyDiagnostic( + 'claude.dependencies.unresolved', + `Claude dependency ${JSON.stringify(entry)} has no marketplace, but the generated marketplace does not emit a plugin with that name.`, + 'Declare the marketplace that provides this plugin, or remove the dependency; bare names resolve only within the generated marketplace.', + )); + continue; + } document.push(entry); continue; } @@ -756,7 +769,7 @@ const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan )); continue; } - if (name === model.metadata.name) { + if (name === model.metadata.name && marketplace === undefined) { diagnostics.push(dependencyDiagnostic( 'claude.dependencies.self', `Claude plugin ${JSON.stringify(model.metadata.name)} cannot depend on itself.`, @@ -774,6 +787,14 @@ const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan continue; } seen.add(identity); + if (marketplace === undefined && !emittedMarketplacePluginNames.has(name)) { + diagnostics.push(dependencyDiagnostic( + 'claude.dependencies.unresolved', + `Claude dependency ${JSON.stringify(name)} has no marketplace, but the generated marketplace does not emit a plugin with that name.`, + 'Declare the marketplace that provides this plugin, or remove the dependency; bare names resolve only within the generated marketplace.', + )); + continue; + } document.push(Object.freeze({ ...(typeof marketplace === 'string' ? { marketplace } : {}), name, @@ -984,7 +1005,7 @@ const planMarketplaceRelevance = ( )); } const topic = declared['topic']; - if (topic !== undefined && (!isNonemptyString(topic) || topic.length > 64)) { + if (topic !== undefined && (!isNonemptyString(topic) || [...topic].length > 64)) { diagnostics.push(marketplaceDiagnostic( 'claude.marketplace.plugin.relevance.topic.invalid', 'Claude marketplace plugin relevance topic must be a nonempty string of at most 64 characters.', @@ -1941,13 +1962,18 @@ interface ClaudeUserConfigOptionPlan { * Validates and allowlist-copies one option independently so the same closed * declaration contract can be reused by a later channels.userConfig slice. */ -const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserConfigOptionPlan => { +const planClaudeUserConfigOption = ( + key: string, + declared: unknown, + path = 'userConfig', +): ClaudeUserConfigOptionPlan => { const diagnostics: Diagnostic[] = []; + const codePrefix = `claude.${path}`; if (!isPlainDataRecord(declared)) { diagnostics.push(userConfigDiagnostic( - 'claude.userConfig.option.invalid', - `Claude userConfig option "${key}" must be an option declaration object.`, - `Replace userConfig.${key} with an object containing type, title, and description, then rebuild.`, + `${codePrefix}.option.invalid`, + `Claude ${path} option "${key}" must be an option declaration object.`, + `Replace ${path}.${key} with an object containing type, title, and description, then rebuild.`, )); return { diagnostics }; } @@ -1955,34 +1981,34 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC for (const field of Object.keys(declared).sort()) { if (userConfigOptionFieldSet.has(field)) continue; diagnostics.push(userConfigDiagnostic( - 'claude.userConfig.field.unknown', - `Claude userConfig option "${key}" declares unknown field "${field}".`, - `Remove userConfig.${key}.${field} or replace it with a documented option field, then rebuild.`, + `${codePrefix}.field.unknown`, + `Claude ${path} option "${key}" declares unknown field "${field}".`, + `Remove ${path}.${key}.${field} or replace it with a documented option field, then rebuild.`, )); } const type = declared['type']; if (!isUserConfigOptionType(type)) { diagnostics.push(userConfigDiagnostic( - 'claude.userConfig.type.invalid', - `Claude userConfig option "${key}" requires type "string", "number", "boolean", "directory", or "file".`, - `Set userConfig.${key}.type to one of the five documented option types, then rebuild.`, + `${codePrefix}.type.invalid`, + `Claude ${path} option "${key}" requires type "string", "number", "boolean", "directory", or "file".`, + `Set ${path}.${key}.type to one of the five documented option types, then rebuild.`, )); } for (const field of ['title', 'description'] as const) { if (typeof declared[field] === 'string' && declared[field].length > 0) continue; diagnostics.push(userConfigDiagnostic( - `claude.userConfig.${field}.required`, - `Claude userConfig option "${key}" requires a nonempty ${field}.`, - `Set userConfig.${key}.${field} to the text Claude Code should show in its configuration dialog, then rebuild.`, + `${codePrefix}.${field}.required`, + `Claude ${path} option "${key}" requires a nonempty ${field}.`, + `Set ${path}.${key}.${field} to the text Claude Code should show in its configuration dialog, then rebuild.`, )); } for (const field of ['sensitive', 'required'] as const) { if (declared[field] === undefined || typeof declared[field] === 'boolean') continue; diagnostics.push(userConfigDiagnostic( - `claude.userConfig.${field}.invalid`, - `Claude userConfig option "${key}" field "${field}" must be a boolean when provided.`, - `Set userConfig.${key}.${field} to true or false, or remove it, then rebuild.`, + `${codePrefix}.${field}.invalid`, + `Claude ${path} option "${key}" field "${field}" must be a boolean when provided.`, + `Set ${path}.${key}.${field} to true or false, or remove it, then rebuild.`, )); } @@ -1992,9 +2018,9 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC (typeof multiple !== 'boolean' || (isUserConfigOptionType(type) && type !== 'string')) ) { diagnostics.push(userConfigDiagnostic( - 'claude.userConfig.multiple.invalid', - `Claude userConfig option "${key}" may declare boolean field "multiple" only for type "string".`, - `Remove userConfig.${key}.multiple or change the option type to "string", then rebuild.`, + `${codePrefix}.multiple.invalid`, + `Claude ${path} option "${key}" may declare boolean field "multiple" only for type "string".`, + `Remove ${path}.${key}.multiple or change the option type to "string", then rebuild.`, )); } @@ -2004,9 +2030,9 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC if (bound === undefined) continue; if (typeof bound !== 'number' || !Number.isFinite(bound) || (isUserConfigOptionType(type) && type !== 'number')) { diagnostics.push(userConfigDiagnostic( - `claude.userConfig.${field}.invalid`, - `Claude userConfig option "${key}" may declare finite numeric field "${field}" only for type "number".`, - `Remove userConfig.${key}.${field} or use it with a number option and a finite numeric value, then rebuild.`, + `${codePrefix}.${field}.invalid`, + `Claude ${path} option "${key}" may declare finite numeric field "${field}" only for type "number".`, + `Remove ${path}.${key}.${field} or use it with a number option and a finite numeric value, then rebuild.`, )); continue; } @@ -2014,9 +2040,9 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC } if (bounds.min !== undefined && bounds.max !== undefined && bounds.min > bounds.max) { diagnostics.push(userConfigDiagnostic( - 'claude.userConfig.bounds.invalid', - `Claude userConfig option "${key}" has min ${String(bounds.min)} greater than max ${String(bounds.max)}.`, - `Set userConfig.${key}.min less than or equal to userConfig.${key}.max, then rebuild.`, + `${codePrefix}.bounds.invalid`, + `Claude ${path} option "${key}" has min ${String(bounds.min)} greater than max ${String(bounds.max)}.`, + `Set ${path}.${key}.min less than or equal to ${path}.${key}.max, then rebuild.`, )); } @@ -2050,17 +2076,17 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC } if (!validDefault) { diagnostics.push(userConfigDiagnostic( - 'claude.userConfig.default.invalid', - `Claude userConfig option "${key}" has a default that does not match its type, multiple mode, or numeric bounds.`, - `Set userConfig.${key}.default to a valid ${type} value for this declaration, or remove it, then rebuild.`, + `${codePrefix}.default.invalid`, + `Claude ${path} option "${key}" has a default that does not match its type, multiple mode, or numeric bounds.`, + `Set ${path}.${key}.default to a valid ${type} value for this declaration, or remove it, then rebuild.`, )); } } if (declared['sensitive'] === true && defaultValue !== undefined) { diagnostics.push(userConfigDiagnostic( - 'claude.userConfig.sensitive.default', - `Claude userConfig option "${key}" cannot combine sensitive: true with a manifest default because that would ship a secure-storage value in the plugin manifest.`, - `Remove userConfig.${key}.default and let Claude Code prompt for the sensitive value, then rebuild.`, + `${codePrefix}.sensitive.default`, + `Claude ${path} option "${key}" cannot combine sensitive: true with a manifest default because that would ship a secure-storage value in the plugin manifest.`, + `Remove ${path}.${key}.default and let Claude Code prompt for the sensitive value, then rebuild.`, )); } @@ -2311,7 +2337,11 @@ export const planClaudeChannels = ( `Rename one channels[${index}].userConfig option so every key remains unique after uppercasing, then rebuild.`, )); } - const optionPlan = planClaudeUserConfigOption(key, userConfig[key]); + const optionPlan = planClaudeUserConfigOption( + key, + userConfig[key], + `channels[${index}].userConfig`, + ); diagnostics.push(...optionPlan.diagnostics); if (optionPlan.value !== undefined) plannedUserConfig[key] = optionPlan.value; } @@ -2691,6 +2721,7 @@ export const planClaudeSettings = (model: NormalizedPlugin): ClaudeSettingsPlan const themeFields: ReadonlySet = new Set(['base', 'name', 'overrides']); const themeKeyPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; +const themeBasePresets: ReadonlySet = new Set(['dark', 'light']); interface ClaudeThemeDocument { readonly document: Record; @@ -2757,6 +2788,11 @@ export const planClaudeThemes = (model: NormalizedPlugin): ClaudeThemesPlan => { 'claude.themes.base.required', `Claude theme "${key}" requires a nonempty base preset.`, )); + } else if (!themeBasePresets.has(base)) { + diagnostics.push(errorDiagnostic( + 'claude.themes.base.invalid', + `Claude theme "${key}" base must be one of the documented "dark" or "light" presets.`, + )); } else { document['base'] = base; } @@ -2883,7 +2919,7 @@ export const planClaudeMonitors = ( `Claude monitors[${index}] command cannot reference \`\${user_config.*}\`; Claude Code runs monitor commands through a shell and rejects them instead of substituting the value, and monitor processes do not receive CLAUDE_PLUGIN_OPTION_ environment variables.`, )); } else { - planned['command'] = command; + planned['command'] = expandClaudeToken(command); } const description = monitor['description']; @@ -2985,7 +3021,7 @@ export const planClaudeArtifacts = ( diagnostics.push(...themes.diagnostics); const monitors = planClaudeMonitors(model, targetName); diagnostics.push(...monitors.diagnostics); - const dependencies = planClaudeDependencies(model); + const dependencies = planClaudeDependencies(model, new Set([model.metadata.name])); diagnostics.push(...dependencies.diagnostics); const generatedHooks = planHooks(model, targetName, hookContract); diagnostics.push(...generatedHooks.diagnostics); @@ -3241,7 +3277,7 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ 'The pinned Claude plugin contract does not document the plugin-root output-styles surface.', ), pluginCliLifecycle: unavailableCapability(distributionPolicy.pluginCliLifecycle.reason), - pluginInstallScopes: unavailableCapability(distributionPolicy.pluginInstallScopes.reason), + pluginInstallScopes: supportedCapability(evidence), pluginReload: unavailableCapability(distributionPolicy.pluginReload.reason), pluginTrustGates: unavailableCapability(distributionPolicy.pluginTrustGates.reason), rules: unavailableCapability( diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 0eb080655..2b555e2c6 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -41,8 +41,8 @@ "url": "https://code.claude.com/docs/en/plugins" }, "theme.schema.json": { - "bytes": 458, - "sha256": "1ee6c8485855bf90402a1c527830efeca0643be7037ca2ff3e178c51e5973089", + "bytes": 451, + "sha256": "721aa9b0bc7c60cd9359343e5c7205ffbdfea82da312f601720c9dfaa2d8cf0d", "url": "https://code.claude.com/docs/en/plugins-reference" } }, diff --git a/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json index 708e29366..8cf13ff95 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json @@ -3,7 +3,7 @@ "$id": "https://agent-bundle.dev/schemas/claude/2.1.250/theme.schema.json", "additionalProperties": false, "properties": { - "base": { "minLength": 1, "type": "string" }, + "base": { "enum": ["dark", "light"] }, "name": { "minLength": 1, "type": "string" }, "overrides": { "additionalProperties": { "minLength": 1, "type": "string" }, diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index dea954b5c..e6313e474 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -290,6 +290,22 @@ const claudeDistributionPolicyCapabilities = [ 'marketplaceCliLifecycle', ] as const; +it('reports Claude plugin install scopes from the scoped installer implementation', () => { + const row = claudeCapabilityTable.plugin.distributionPolicy.pluginInstallScopes; + const capability = createDefaultRegistry().get('claude').capabilities.pluginInstallScopes; + + expect(row).toMatchObject({ + evidence: expect.arrayContaining([ + expect.stringContaining('src/install/install.ts'), + ]), + state: 'supported', + }); + expect(capability).toMatchObject({ + evidence: { observedVersion: '2.1.250', target: 'claude' }, + state: 'supported', + }); +}); + it('records dated unavailable Claude distribution and policy capability rows', () => { const registry = createDefaultRegistry(); const distributionPolicy = ( @@ -311,14 +327,21 @@ it('records dated unavailable Claude distribution and policy capability rows', ( expect(Object.keys(distributionPolicy).sort()).toEqual([...claudeDistributionPolicyCapabilities].sort()); for (const capability of claudeDistributionPolicyCapabilities) { const row = distributionPolicy[capability]; - expect(row.state).toBe('unavailable'); - expect(row.reason.length).toBeGreaterThan(0); + expect(row.state).toBe(capability === 'pluginInstallScopes' ? 'supported' : 'unavailable'); + if (capability !== 'pluginInstallScopes') 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]).toEqual({ - reason: row.reason, - state: 'unavailable', - }); + if (capability === 'pluginInstallScopes') { + expect(registry.get('claude').capabilities[capability]).toMatchObject({ + evidence: { observedVersion: '2.1.250', target: 'claude' }, + state: 'supported', + }); + } else { + expect(registry.get('claude').capabilities[capability]).toEqual({ + reason: row.reason, + state: 'unavailable', + }); + } expect(registry.get('plugin').capabilities[capability]).toMatchObject({ state: 'unavailable', }); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index d0757290a..8e08fb5fd 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.16.0', + adapterRevision: '1.17.0', observedVersion: '2.1.250', schemas: [ { @@ -134,7 +134,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'theme', revision: '2.1.250', - sha256: '1ee6c8485855bf90402a1c527830efeca0643be7037ca2ff3e178c51e5973089', + sha256: '721aa9b0bc7c60cd9359343e5c7205ffbdfea82da312f601720c9dfaa2d8cf0d', }, ], }); diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index 0f229cdb1..f2d39265e 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -7,7 +7,7 @@ import { expect, it } from '@rstest/core'; import { claudeAdapter } from '../src/adapters/claude.ts'; import { emitPlanEntries } from '../src/build/emit.ts'; -import type { NormalizedPlugin } from '../src/core/types.ts'; +import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; const nativeIt = process.env.AGENT_BUNDLE_NATIVE_HOST_CONTRACTS === '1' ? it : it.skip; @@ -584,7 +584,7 @@ nativeIt('accepts emitted Claude experimental themes and monitors under strict n try { const written = await writeClaudeArtifact(root, withClaudeExperimental({ monitors: [{ - command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', + command: `node ${pathTokens.pluginRoot}/scripts/watch.mjs`, description: 'Watch the review queue.', name: 'review-queue', when: 'always', @@ -600,6 +600,9 @@ nativeIt('accepts emitted Claude experimental themes and monitors under strict n 'monitors/monitors.json', 'themes/dracula.json', ])); + expect(JSON.parse( + await readFile(join(root, 'monitors', 'monitors.json'), 'utf8'), + )[0].command).toBe('node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs'); const validation = await runClaudeValidation(root, root); expect(validation.code, validation.output).toBe(0); @@ -883,8 +886,8 @@ nativeIt('accepts emitted Claude plugin dependencies under strict native validat try { await writeClaudeArtifact(root, withClaudeDependencies([ - 'audit-logger', - { name: 'secrets-vault', version: '~2.1.0' }, + { marketplace: 'acme-shared', name: 'audit-logger' }, + { marketplace: 'acme-shared', name: 'secrets-vault', version: '~2.1.0' }, ])); const validation = await runClaudeValidation(root, root); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 7053cbbfa..a2b42e472 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -643,6 +643,19 @@ it('enriches the generated Claude marketplace with the complete authored catalog await validateDocuments('claude', writeContents(model, 'claude')); }); +it('counts Claude marketplace relevance topics by Unicode code point', () => { + const topic = '😀'.repeat(64); + const model = withClaudeMarketplace(plugin, { + plugin: { relevance: { signals: { cli: ['git'] }, topic } }, + }); + const plan = createDefaultRegistry().get('claude').plan(model); + + expect(plan.diagnostics).toEqual([]); + expect(JSON.parse( + writeContents(model, 'claude')['.claude-plugin/marketplace.json']!, + ).plugins[0].relevance.topic).toBe(topic); +}); + it.each([ ['./plugins/review-tools', undefined], ['review-tools', { pluginRoot: './plugins' }], @@ -1454,7 +1467,7 @@ it.each([ server: 'stdio', userConfig: { bot_token: { description: 'Token.', title: 'Token', type: 'secret' } }, }], - code: 'claude.userConfig.type.invalid', + code: 'claude.channels[0].userConfig.type.invalid', label: 'an invalid per-channel option declaration', }, ])('rejects $label without emitting channels', ({ channels, code }) => { @@ -1925,7 +1938,7 @@ it('emits no Claude settings document when the host config declares none', () => it('emits validated Claude themes and monitors at their default experimental locations', () => { const model = withClaudeExperimental(plugin, { monitors: [{ - command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', + command: `node ${pathTokens.pluginRoot}/scripts/watch.mjs`, description: 'Watch the review queue.', name: 'review-queue', when: 'on-skill-invoke:review', @@ -1985,6 +1998,7 @@ it.each([ { code: 'claude.themes.field.unknown', label: 'an unknown theme field', themes: { dark: { base: 'dark', typo: true } } }, { code: 'claude.themes.base.required', label: 'a missing theme base', themes: { dark: { name: 'Dark' } } }, { code: 'claude.themes.base.required', label: 'an empty theme base', themes: { dark: { base: '' } } }, + { code: 'claude.themes.base.invalid', label: 'an unknown theme base preset', themes: { dark: { base: 'drak' } } }, { code: 'claude.themes.name.invalid', label: 'an empty theme display name', themes: { dark: { base: 'dark', name: '' } } }, { code: 'claude.themes.overrides.invalid', label: 'a non-object overrides map', themes: { dark: { base: 'dark', overrides: [] } } }, { code: 'claude.themes.overrides.value.invalid', label: 'a non-string override', themes: { dark: { base: 'dark', overrides: { error: 7 } } } }, @@ -2030,6 +2044,8 @@ it('pins and registers the closed Claude theme and monitor schemas', async () => const validateMonitors = validator.compile(monitorsModule.default); expect(validateTheme({ base: 'dark', name: 'Dracula', overrides: { claude: '#bd93f9' } })).toBe(true); + expect(validateTheme({ base: 'light', name: 'Paper' })).toBe(true); + expect(validateTheme({ base: 'drak', name: 'Typo' })).toBe(false); expect(validateTheme({ base: 'dark', typo: true })).toBe(false); expect(validateTheme({ name: 'No base' })).toBe(false); expect(validateTheme({ base: 'dark', overrides: { error: 7 } })).toBe(false); @@ -2049,8 +2065,8 @@ it('pins and registers the closed Claude theme and monitor schemas', async () => it('emits Claude plugin dependencies in authored order with closed object keys and extension provenance', async () => { const model = withClaudeDependencies(plugin, [ - 'audit-logger', - { version: '~2.1.0', name: 'secrets-vault' }, + { marketplace: 'acme-shared', name: 'review-tools', version: '*' }, + { marketplace: 'acme-shared', version: '~2.1.0', name: 'secrets-vault' }, { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0.0-0' }, ]); const plan = createDefaultRegistry().get('claude').plan(model); @@ -2060,8 +2076,8 @@ it('emits Claude plugin dependencies in authored order with closed object keys a expect(entry?.kind).toBe('write'); if (entry?.kind !== 'write') throw new Error('Expected an emitted Claude plugin manifest.'); expect(JSON.parse(entry.content).dependencies).toEqual([ - 'audit-logger', - { name: 'secrets-vault', version: '~2.1.0' }, + { marketplace: 'acme-shared', name: 'review-tools', version: '*' }, + { marketplace: 'acme-shared', name: 'secrets-vault', version: '~2.1.0' }, { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0.0-0' }, ]); expect(entry.sourceInputs).toContain('/workspace/dependencies.config.ts'); @@ -2086,6 +2102,8 @@ it.each([ label: 'a duplicate cross-marketplace name', }, { dependencies: ['review-tools'], code: 'claude.dependencies.self', label: 'a self dependency' }, + { dependencies: ['audit-logger'], code: 'claude.dependencies.unresolved', label: 'an unresolvable bare-name dependency' }, + { dependencies: [{ name: 'audit-logger', version: '^2.0' }], code: 'claude.dependencies.unresolved', label: 'an unresolvable same-marketplace dependency object' }, { dependencies: [{ name: 'audit-logger', version: 'latest' }], code: 'claude.dependencies.version.invalid', label: 'an invalid version range' }, { dependencies: [{ marketplace: '', name: 'audit-logger' }], code: 'claude.dependencies.marketplace.invalid', label: 'an empty marketplace' }, { dependencies: [{ marketplace: 7, name: 'audit-logger' }], code: 'claude.dependencies.marketplace.invalid', label: 'a non-string marketplace' }, @@ -2108,6 +2126,9 @@ it.each([ '^2.0', '>=1.4', '=2.1.0', + '*', + 'x', + 'X', '2.x', '1.2.3 - 2.0.0', '>=2.0 <3.0', From 5dcde9e0ae38ba88fb6cb14ce4574a1f56a5cad8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 22:45:14 +0000 Subject: [PATCH 2/8] test(claude): qualify unified dependency fixture Keep the unified target proof aligned with emitted-marketplace dependency resolution. --- packages/agent-bundle/tests/plugin-bundle.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index a48210971..8f45252ca 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -621,7 +621,7 @@ it('emits Claude-only dependencies from the unified plugin target', () => { target: 'claude', value: { dependencies: [ - 'audit-logger', + { marketplace: 'acme-shared', name: 'audit-logger' }, { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, ], }, @@ -633,7 +633,7 @@ it('emits Claude-only dependencies from the unified plugin target', () => { expect(plan.diagnostics).toEqual([]); expect(JSON.parse(documents['.claude-plugin/plugin.json']!).dependencies).toEqual([ - 'audit-logger', + { marketplace: 'acme-shared', name: 'audit-logger' }, { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, ]); expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('dependencies'); From 2530cc32be8454c9f48ab8f6eb477cdcd062adcd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 22:33:11 +0000 Subject: [PATCH 3/8] fix(claude): harden adapter declaration planning Validate and lower Claude declarations before they can ship unresolved or host-invalid configuration. --- .changeset/claude-adapter-review-batch-two.md | 5 + .../adapters/capabilities/claude-2.1.250.json | 6 +- packages/agent-bundle/src/adapters/claude.ts | 118 ++++++++++++------ .../adapters/schemas/claude/PROVENANCE.json | 4 +- .../adapters/schemas/claude/theme.schema.json | 2 +- .../tests/adapter-capability-states.test.ts | 35 +++++- .../tests/adapter-metadata.test.ts | 4 +- .../tests/host-adapters.native.test.ts | 11 +- .../agent-bundle/tests/host-adapters.test.ts | 33 ++++- 9 files changed, 153 insertions(+), 65 deletions(-) create mode 100644 .changeset/claude-adapter-review-batch-two.md diff --git a/.changeset/claude-adapter-review-batch-two.md b/.changeset/claude-adapter-review-batch-two.md new file mode 100644 index 000000000..2bbc83d74 --- /dev/null +++ b/.changeset/claude-adapter-review-batch-two.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Harden Claude adapter planning for monitor path tokens, theme presets, dependency resolution and ranges, indexed channel diagnostics, scoped installation capabilities, and Unicode marketplace topics. 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 f316f8b56..d18b30e6a 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 @@ -115,11 +115,11 @@ ] }, "pluginInstallScopes": { - "state": "unavailable", - "reason": "Agent Bundle emits installation instructions but cannot choose or persist a Claude plugin installation scope.", + "state": "supported", "evidence": [ "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference maps user, project, and local installs to ~/.claude/settings.json, .claude/settings.json, and .claude/settings.local.json respectively, while managed scope is read-only and update-only.", - "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents --scope user|project|local for install and uninstall, scope auto-detection for enable and disable, and --scope user|project|local|managed for update." + "retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents --scope user|project|local for install and uninstall, scope auto-detection for enable and disable, and --scope user|project|local|managed for update.", + "retrieved 2026-09-02: packages/agent-bundle/src/install/install.ts installPublicCli accepts the requested user, project, or local scope and forwards it to `claude plugin install ... --scope `." ] }, "pluginReload": { diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index ff63ee41d..9cb9c4d6a 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -167,9 +167,11 @@ export interface ClaudeSettingsConfig { } /** One experimental Claude Code color theme emitted under the plugin-root `themes/` directory. */ +export type ClaudeThemeBasePreset = 'dark' | 'light'; + export interface ClaudeThemeConfig { /** Built-in preset inherited before sparse token overrides are applied. */ - readonly base: string; + readonly base: ClaudeThemeBasePreset; /** Display name shown in `/theme`; defaults to the declaration key. */ readonly name?: string; /** Sparse color-token overrides. Values stay host-defined strings rather than being narrowed to hex colors. */ @@ -422,7 +424,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.16.0', + adapterRevision: '1.17.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -595,7 +597,7 @@ const semverSuffix = `(?:-${prereleaseSemverIdentifier}(?:\\.${prereleaseSemverI const fullSemverVersion = `${numericSemverIdentifier}\\.${numericSemverIdentifier}\\.${numericSemverIdentifier}${semverSuffix}`; const partialSemverVersion = `${numericSemverIdentifier}(?:\\.${numericSemverIdentifier})?`; const wildcardSemverVersion = `${numericSemverIdentifier}\\.(?:[xX*]|${numericSemverIdentifier}\\.[xX*])`; -const semverRangeVersion = `(?:${fullSemverVersion}|${wildcardSemverVersion}|${partialSemverVersion})`; +const semverRangeVersion = `(?:[xX*]|${fullSemverVersion}|${wildcardSemverVersion}|${partialSemverVersion})`; const hyphenRangePattern = new RegExp(`^${semverRangeVersion}\\s+-\\s+${semverRangeVersion}$`, 'u'); const comparatorPattern = new RegExp(`(?:~|\\^|>=|<=|>|<|=)?\\s*${semverRangeVersion}`, 'uy'); @@ -645,7 +647,10 @@ const dependencyDiagnostic = (code: string, message: string, recovery: string): recovery, }); -const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan => { +const planClaudeDependencies = ( + model: NormalizedPlugin, + emittedMarketplacePluginNames: ReadonlySet, +): ClaudeDependenciesPlan => { const extension = model.extensions[claudeName]; if (extension === undefined || !isDataRecord(extension.value)) return noDependenciesPlan; const declared = extension.value['dependencies']; @@ -701,6 +706,14 @@ const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan continue; } seen.add(identity); + if (!emittedMarketplacePluginNames.has(entry)) { + diagnostics.push(dependencyDiagnostic( + 'claude.dependencies.unresolved', + `Claude dependency ${JSON.stringify(entry)} has no marketplace, but the generated marketplace does not emit a plugin with that name.`, + 'Declare the marketplace that provides this plugin, or remove the dependency; bare names resolve only within the generated marketplace.', + )); + continue; + } document.push(entry); continue; } @@ -756,7 +769,7 @@ const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan )); continue; } - if (name === model.metadata.name) { + if (name === model.metadata.name && marketplace === undefined) { diagnostics.push(dependencyDiagnostic( 'claude.dependencies.self', `Claude plugin ${JSON.stringify(model.metadata.name)} cannot depend on itself.`, @@ -774,6 +787,14 @@ const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan continue; } seen.add(identity); + if (marketplace === undefined && !emittedMarketplacePluginNames.has(name)) { + diagnostics.push(dependencyDiagnostic( + 'claude.dependencies.unresolved', + `Claude dependency ${JSON.stringify(name)} has no marketplace, but the generated marketplace does not emit a plugin with that name.`, + 'Declare the marketplace that provides this plugin, or remove the dependency; bare names resolve only within the generated marketplace.', + )); + continue; + } document.push(Object.freeze({ ...(typeof marketplace === 'string' ? { marketplace } : {}), name, @@ -984,7 +1005,7 @@ const planMarketplaceRelevance = ( )); } const topic = declared['topic']; - if (topic !== undefined && (!isNonemptyString(topic) || topic.length > 64)) { + if (topic !== undefined && (!isNonemptyString(topic) || [...topic].length > 64)) { diagnostics.push(marketplaceDiagnostic( 'claude.marketplace.plugin.relevance.topic.invalid', 'Claude marketplace plugin relevance topic must be a nonempty string of at most 64 characters.', @@ -1941,13 +1962,18 @@ interface ClaudeUserConfigOptionPlan { * Validates and allowlist-copies one option independently so the same closed * declaration contract can be reused by a later channels.userConfig slice. */ -const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserConfigOptionPlan => { +const planClaudeUserConfigOption = ( + key: string, + declared: unknown, + path = 'userConfig', +): ClaudeUserConfigOptionPlan => { const diagnostics: Diagnostic[] = []; + const codePrefix = `claude.${path}`; if (!isPlainDataRecord(declared)) { diagnostics.push(userConfigDiagnostic( - 'claude.userConfig.option.invalid', - `Claude userConfig option "${key}" must be an option declaration object.`, - `Replace userConfig.${key} with an object containing type, title, and description, then rebuild.`, + `${codePrefix}.option.invalid`, + `Claude ${path} option "${key}" must be an option declaration object.`, + `Replace ${path}.${key} with an object containing type, title, and description, then rebuild.`, )); return { diagnostics }; } @@ -1955,34 +1981,34 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC for (const field of Object.keys(declared).sort()) { if (userConfigOptionFieldSet.has(field)) continue; diagnostics.push(userConfigDiagnostic( - 'claude.userConfig.field.unknown', - `Claude userConfig option "${key}" declares unknown field "${field}".`, - `Remove userConfig.${key}.${field} or replace it with a documented option field, then rebuild.`, + `${codePrefix}.field.unknown`, + `Claude ${path} option "${key}" declares unknown field "${field}".`, + `Remove ${path}.${key}.${field} or replace it with a documented option field, then rebuild.`, )); } const type = declared['type']; if (!isUserConfigOptionType(type)) { diagnostics.push(userConfigDiagnostic( - 'claude.userConfig.type.invalid', - `Claude userConfig option "${key}" requires type "string", "number", "boolean", "directory", or "file".`, - `Set userConfig.${key}.type to one of the five documented option types, then rebuild.`, + `${codePrefix}.type.invalid`, + `Claude ${path} option "${key}" requires type "string", "number", "boolean", "directory", or "file".`, + `Set ${path}.${key}.type to one of the five documented option types, then rebuild.`, )); } for (const field of ['title', 'description'] as const) { if (typeof declared[field] === 'string' && declared[field].length > 0) continue; diagnostics.push(userConfigDiagnostic( - `claude.userConfig.${field}.required`, - `Claude userConfig option "${key}" requires a nonempty ${field}.`, - `Set userConfig.${key}.${field} to the text Claude Code should show in its configuration dialog, then rebuild.`, + `${codePrefix}.${field}.required`, + `Claude ${path} option "${key}" requires a nonempty ${field}.`, + `Set ${path}.${key}.${field} to the text Claude Code should show in its configuration dialog, then rebuild.`, )); } for (const field of ['sensitive', 'required'] as const) { if (declared[field] === undefined || typeof declared[field] === 'boolean') continue; diagnostics.push(userConfigDiagnostic( - `claude.userConfig.${field}.invalid`, - `Claude userConfig option "${key}" field "${field}" must be a boolean when provided.`, - `Set userConfig.${key}.${field} to true or false, or remove it, then rebuild.`, + `${codePrefix}.${field}.invalid`, + `Claude ${path} option "${key}" field "${field}" must be a boolean when provided.`, + `Set ${path}.${key}.${field} to true or false, or remove it, then rebuild.`, )); } @@ -1992,9 +2018,9 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC (typeof multiple !== 'boolean' || (isUserConfigOptionType(type) && type !== 'string')) ) { diagnostics.push(userConfigDiagnostic( - 'claude.userConfig.multiple.invalid', - `Claude userConfig option "${key}" may declare boolean field "multiple" only for type "string".`, - `Remove userConfig.${key}.multiple or change the option type to "string", then rebuild.`, + `${codePrefix}.multiple.invalid`, + `Claude ${path} option "${key}" may declare boolean field "multiple" only for type "string".`, + `Remove ${path}.${key}.multiple or change the option type to "string", then rebuild.`, )); } @@ -2004,9 +2030,9 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC if (bound === undefined) continue; if (typeof bound !== 'number' || !Number.isFinite(bound) || (isUserConfigOptionType(type) && type !== 'number')) { diagnostics.push(userConfigDiagnostic( - `claude.userConfig.${field}.invalid`, - `Claude userConfig option "${key}" may declare finite numeric field "${field}" only for type "number".`, - `Remove userConfig.${key}.${field} or use it with a number option and a finite numeric value, then rebuild.`, + `${codePrefix}.${field}.invalid`, + `Claude ${path} option "${key}" may declare finite numeric field "${field}" only for type "number".`, + `Remove ${path}.${key}.${field} or use it with a number option and a finite numeric value, then rebuild.`, )); continue; } @@ -2014,9 +2040,9 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC } if (bounds.min !== undefined && bounds.max !== undefined && bounds.min > bounds.max) { diagnostics.push(userConfigDiagnostic( - 'claude.userConfig.bounds.invalid', - `Claude userConfig option "${key}" has min ${String(bounds.min)} greater than max ${String(bounds.max)}.`, - `Set userConfig.${key}.min less than or equal to userConfig.${key}.max, then rebuild.`, + `${codePrefix}.bounds.invalid`, + `Claude ${path} option "${key}" has min ${String(bounds.min)} greater than max ${String(bounds.max)}.`, + `Set ${path}.${key}.min less than or equal to ${path}.${key}.max, then rebuild.`, )); } @@ -2050,17 +2076,17 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC } if (!validDefault) { diagnostics.push(userConfigDiagnostic( - 'claude.userConfig.default.invalid', - `Claude userConfig option "${key}" has a default that does not match its type, multiple mode, or numeric bounds.`, - `Set userConfig.${key}.default to a valid ${type} value for this declaration, or remove it, then rebuild.`, + `${codePrefix}.default.invalid`, + `Claude ${path} option "${key}" has a default that does not match its type, multiple mode, or numeric bounds.`, + `Set ${path}.${key}.default to a valid ${type} value for this declaration, or remove it, then rebuild.`, )); } } if (declared['sensitive'] === true && defaultValue !== undefined) { diagnostics.push(userConfigDiagnostic( - 'claude.userConfig.sensitive.default', - `Claude userConfig option "${key}" cannot combine sensitive: true with a manifest default because that would ship a secure-storage value in the plugin manifest.`, - `Remove userConfig.${key}.default and let Claude Code prompt for the sensitive value, then rebuild.`, + `${codePrefix}.sensitive.default`, + `Claude ${path} option "${key}" cannot combine sensitive: true with a manifest default because that would ship a secure-storage value in the plugin manifest.`, + `Remove ${path}.${key}.default and let Claude Code prompt for the sensitive value, then rebuild.`, )); } @@ -2311,7 +2337,11 @@ export const planClaudeChannels = ( `Rename one channels[${index}].userConfig option so every key remains unique after uppercasing, then rebuild.`, )); } - const optionPlan = planClaudeUserConfigOption(key, userConfig[key]); + const optionPlan = planClaudeUserConfigOption( + key, + userConfig[key], + `channels[${index}].userConfig`, + ); diagnostics.push(...optionPlan.diagnostics); if (optionPlan.value !== undefined) plannedUserConfig[key] = optionPlan.value; } @@ -2691,6 +2721,7 @@ export const planClaudeSettings = (model: NormalizedPlugin): ClaudeSettingsPlan const themeFields: ReadonlySet = new Set(['base', 'name', 'overrides']); const themeKeyPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; +const themeBasePresets: ReadonlySet = new Set(['dark', 'light']); interface ClaudeThemeDocument { readonly document: Record; @@ -2757,6 +2788,11 @@ export const planClaudeThemes = (model: NormalizedPlugin): ClaudeThemesPlan => { 'claude.themes.base.required', `Claude theme "${key}" requires a nonempty base preset.`, )); + } else if (!themeBasePresets.has(base)) { + diagnostics.push(errorDiagnostic( + 'claude.themes.base.invalid', + `Claude theme "${key}" base must be one of the documented "dark" or "light" presets.`, + )); } else { document['base'] = base; } @@ -2883,7 +2919,7 @@ export const planClaudeMonitors = ( `Claude monitors[${index}] command cannot reference \`\${user_config.*}\`; Claude Code runs monitor commands through a shell and rejects them instead of substituting the value, and monitor processes do not receive CLAUDE_PLUGIN_OPTION_ environment variables.`, )); } else { - planned['command'] = command; + planned['command'] = expandClaudeToken(command); } const description = monitor['description']; @@ -2985,7 +3021,7 @@ export const planClaudeArtifacts = ( diagnostics.push(...themes.diagnostics); const monitors = planClaudeMonitors(model, targetName); diagnostics.push(...monitors.diagnostics); - const dependencies = planClaudeDependencies(model); + const dependencies = planClaudeDependencies(model, new Set([model.metadata.name])); diagnostics.push(...dependencies.diagnostics); const generatedHooks = planHooks(model, targetName, hookContract); diagnostics.push(...generatedHooks.diagnostics); @@ -3241,7 +3277,7 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ 'The pinned Claude plugin contract does not document the plugin-root output-styles surface.', ), pluginCliLifecycle: unavailableCapability(distributionPolicy.pluginCliLifecycle.reason), - pluginInstallScopes: unavailableCapability(distributionPolicy.pluginInstallScopes.reason), + pluginInstallScopes: supportedCapability(evidence), pluginReload: unavailableCapability(distributionPolicy.pluginReload.reason), pluginTrustGates: unavailableCapability(distributionPolicy.pluginTrustGates.reason), rules: unavailableCapability( diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 0eb080655..2b555e2c6 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -41,8 +41,8 @@ "url": "https://code.claude.com/docs/en/plugins" }, "theme.schema.json": { - "bytes": 458, - "sha256": "1ee6c8485855bf90402a1c527830efeca0643be7037ca2ff3e178c51e5973089", + "bytes": 451, + "sha256": "721aa9b0bc7c60cd9359343e5c7205ffbdfea82da312f601720c9dfaa2d8cf0d", "url": "https://code.claude.com/docs/en/plugins-reference" } }, diff --git a/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json index 708e29366..8cf13ff95 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json @@ -3,7 +3,7 @@ "$id": "https://agent-bundle.dev/schemas/claude/2.1.250/theme.schema.json", "additionalProperties": false, "properties": { - "base": { "minLength": 1, "type": "string" }, + "base": { "enum": ["dark", "light"] }, "name": { "minLength": 1, "type": "string" }, "overrides": { "additionalProperties": { "minLength": 1, "type": "string" }, diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index dea954b5c..e6313e474 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -290,6 +290,22 @@ const claudeDistributionPolicyCapabilities = [ 'marketplaceCliLifecycle', ] as const; +it('reports Claude plugin install scopes from the scoped installer implementation', () => { + const row = claudeCapabilityTable.plugin.distributionPolicy.pluginInstallScopes; + const capability = createDefaultRegistry().get('claude').capabilities.pluginInstallScopes; + + expect(row).toMatchObject({ + evidence: expect.arrayContaining([ + expect.stringContaining('src/install/install.ts'), + ]), + state: 'supported', + }); + expect(capability).toMatchObject({ + evidence: { observedVersion: '2.1.250', target: 'claude' }, + state: 'supported', + }); +}); + it('records dated unavailable Claude distribution and policy capability rows', () => { const registry = createDefaultRegistry(); const distributionPolicy = ( @@ -311,14 +327,21 @@ it('records dated unavailable Claude distribution and policy capability rows', ( expect(Object.keys(distributionPolicy).sort()).toEqual([...claudeDistributionPolicyCapabilities].sort()); for (const capability of claudeDistributionPolicyCapabilities) { const row = distributionPolicy[capability]; - expect(row.state).toBe('unavailable'); - expect(row.reason.length).toBeGreaterThan(0); + expect(row.state).toBe(capability === 'pluginInstallScopes' ? 'supported' : 'unavailable'); + if (capability !== 'pluginInstallScopes') 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]).toEqual({ - reason: row.reason, - state: 'unavailable', - }); + if (capability === 'pluginInstallScopes') { + expect(registry.get('claude').capabilities[capability]).toMatchObject({ + evidence: { observedVersion: '2.1.250', target: 'claude' }, + state: 'supported', + }); + } else { + expect(registry.get('claude').capabilities[capability]).toEqual({ + reason: row.reason, + state: 'unavailable', + }); + } expect(registry.get('plugin').capabilities[capability]).toMatchObject({ state: 'unavailable', }); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index d0757290a..8e08fb5fd 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.16.0', + adapterRevision: '1.17.0', observedVersion: '2.1.250', schemas: [ { @@ -134,7 +134,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'theme', revision: '2.1.250', - sha256: '1ee6c8485855bf90402a1c527830efeca0643be7037ca2ff3e178c51e5973089', + sha256: '721aa9b0bc7c60cd9359343e5c7205ffbdfea82da312f601720c9dfaa2d8cf0d', }, ], }); diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index 0f229cdb1..f2d39265e 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -7,7 +7,7 @@ import { expect, it } from '@rstest/core'; import { claudeAdapter } from '../src/adapters/claude.ts'; import { emitPlanEntries } from '../src/build/emit.ts'; -import type { NormalizedPlugin } from '../src/core/types.ts'; +import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; const nativeIt = process.env.AGENT_BUNDLE_NATIVE_HOST_CONTRACTS === '1' ? it : it.skip; @@ -584,7 +584,7 @@ nativeIt('accepts emitted Claude experimental themes and monitors under strict n try { const written = await writeClaudeArtifact(root, withClaudeExperimental({ monitors: [{ - command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', + command: `node ${pathTokens.pluginRoot}/scripts/watch.mjs`, description: 'Watch the review queue.', name: 'review-queue', when: 'always', @@ -600,6 +600,9 @@ nativeIt('accepts emitted Claude experimental themes and monitors under strict n 'monitors/monitors.json', 'themes/dracula.json', ])); + expect(JSON.parse( + await readFile(join(root, 'monitors', 'monitors.json'), 'utf8'), + )[0].command).toBe('node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs'); const validation = await runClaudeValidation(root, root); expect(validation.code, validation.output).toBe(0); @@ -883,8 +886,8 @@ nativeIt('accepts emitted Claude plugin dependencies under strict native validat try { await writeClaudeArtifact(root, withClaudeDependencies([ - 'audit-logger', - { name: 'secrets-vault', version: '~2.1.0' }, + { marketplace: 'acme-shared', name: 'audit-logger' }, + { marketplace: 'acme-shared', name: 'secrets-vault', version: '~2.1.0' }, ])); const validation = await runClaudeValidation(root, root); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 252279e5f..6b1147baa 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -643,6 +643,19 @@ it('enriches the generated Claude marketplace with the complete authored catalog await validateDocuments('claude', writeContents(model, 'claude')); }); +it('counts Claude marketplace relevance topics by Unicode code point', () => { + const topic = '😀'.repeat(64); + const model = withClaudeMarketplace(plugin, { + plugin: { relevance: { signals: { cli: ['git'] }, topic } }, + }); + const plan = createDefaultRegistry().get('claude').plan(model); + + expect(plan.diagnostics).toEqual([]); + expect(JSON.parse( + writeContents(model, 'claude')['.claude-plugin/marketplace.json']!, + ).plugins[0].relevance.topic).toBe(topic); +}); + it.each([ ['./plugins/review-tools', undefined], ['review-tools', { pluginRoot: './plugins' }], @@ -1454,7 +1467,7 @@ it.each([ server: 'stdio', userConfig: { bot_token: { description: 'Token.', title: 'Token', type: 'secret' } }, }], - code: 'claude.userConfig.type.invalid', + code: 'claude.channels[0].userConfig.type.invalid', label: 'an invalid per-channel option declaration', }, ])('rejects $label without emitting channels', ({ channels, code }) => { @@ -1925,7 +1938,7 @@ it('emits no Claude settings document when the host config declares none', () => it('emits validated Claude themes and monitors at their default experimental locations', () => { const model = withClaudeExperimental(plugin, { monitors: [{ - command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', + command: `node ${pathTokens.pluginRoot}/scripts/watch.mjs`, description: 'Watch the review queue.', name: 'review-queue', when: 'on-skill-invoke:review', @@ -1985,6 +1998,7 @@ it.each([ { code: 'claude.themes.field.unknown', label: 'an unknown theme field', themes: { dark: { base: 'dark', typo: true } } }, { code: 'claude.themes.base.required', label: 'a missing theme base', themes: { dark: { name: 'Dark' } } }, { code: 'claude.themes.base.required', label: 'an empty theme base', themes: { dark: { base: '' } } }, + { code: 'claude.themes.base.invalid', label: 'an unknown theme base preset', themes: { dark: { base: 'drak' } } }, { code: 'claude.themes.name.invalid', label: 'an empty theme display name', themes: { dark: { base: 'dark', name: '' } } }, { code: 'claude.themes.overrides.invalid', label: 'a non-object overrides map', themes: { dark: { base: 'dark', overrides: [] } } }, { code: 'claude.themes.overrides.value.invalid', label: 'a non-string override', themes: { dark: { base: 'dark', overrides: { error: 7 } } } }, @@ -2030,6 +2044,8 @@ it('pins and registers the closed Claude theme and monitor schemas', async () => const validateMonitors = validator.compile(monitorsModule.default); expect(validateTheme({ base: 'dark', name: 'Dracula', overrides: { claude: '#bd93f9' } })).toBe(true); + expect(validateTheme({ base: 'light', name: 'Paper' })).toBe(true); + expect(validateTheme({ base: 'drak', name: 'Typo' })).toBe(false); expect(validateTheme({ base: 'dark', typo: true })).toBe(false); expect(validateTheme({ name: 'No base' })).toBe(false); expect(validateTheme({ base: 'dark', overrides: { error: 7 } })).toBe(false); @@ -2049,8 +2065,8 @@ it('pins and registers the closed Claude theme and monitor schemas', async () => it('emits Claude plugin dependencies in authored order with closed object keys and extension provenance', async () => { const model = withClaudeDependencies(plugin, [ - 'audit-logger', - { version: '~2.1.0', name: 'secrets-vault' }, + { marketplace: 'acme-shared', name: 'review-tools', version: '*' }, + { marketplace: 'acme-shared', version: '~2.1.0', name: 'secrets-vault' }, { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0.0-0' }, ]); const plan = createDefaultRegistry().get('claude').plan(model); @@ -2060,8 +2076,8 @@ it('emits Claude plugin dependencies in authored order with closed object keys a expect(entry?.kind).toBe('write'); if (entry?.kind !== 'write') throw new Error('Expected an emitted Claude plugin manifest.'); expect(JSON.parse(entry.content).dependencies).toEqual([ - 'audit-logger', - { name: 'secrets-vault', version: '~2.1.0' }, + { marketplace: 'acme-shared', name: 'review-tools', version: '*' }, + { marketplace: 'acme-shared', name: 'secrets-vault', version: '~2.1.0' }, { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0.0-0' }, ]); expect(entry.sourceInputs).toContain('/workspace/dependencies.config.ts'); @@ -2086,6 +2102,8 @@ it.each([ label: 'a duplicate cross-marketplace name', }, { dependencies: ['review-tools'], code: 'claude.dependencies.self', label: 'a self dependency' }, + { dependencies: ['audit-logger'], code: 'claude.dependencies.unresolved', label: 'an unresolvable bare-name dependency' }, + { dependencies: [{ name: 'audit-logger', version: '^2.0' }], code: 'claude.dependencies.unresolved', label: 'an unresolvable same-marketplace dependency object' }, { dependencies: [{ name: 'audit-logger', version: 'latest' }], code: 'claude.dependencies.version.invalid', label: 'an invalid version range' }, { dependencies: [{ marketplace: '', name: 'audit-logger' }], code: 'claude.dependencies.marketplace.invalid', label: 'an empty marketplace' }, { dependencies: [{ marketplace: 7, name: 'audit-logger' }], code: 'claude.dependencies.marketplace.invalid', label: 'a non-string marketplace' }, @@ -2108,6 +2126,9 @@ it.each([ '^2.0', '>=1.4', '=2.1.0', + '*', + 'x', + 'X', '2.x', '1.2.3 - 2.0.0', '>=2.0 <3.0', From 53bc86f31b87109cfe63ec9533db22c0d93ee035 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 22:45:14 +0000 Subject: [PATCH 4/8] test(claude): qualify unified dependency fixture Keep the unified target proof aligned with emitted-marketplace dependency resolution. --- packages/agent-bundle/tests/plugin-bundle.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 8900548a0..8c2f95d17 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -621,7 +621,7 @@ it('emits Claude-only dependencies from the unified plugin target', () => { target: 'claude', value: { dependencies: [ - 'audit-logger', + { marketplace: 'acme-shared', name: 'audit-logger' }, { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, ], }, @@ -633,7 +633,7 @@ it('emits Claude-only dependencies from the unified plugin target', () => { expect(plan.diagnostics).toEqual([]); expect(JSON.parse(documents['.claude-plugin/plugin.json']!).dependencies).toEqual([ - 'audit-logger', + { marketplace: 'acme-shared', name: 'audit-logger' }, { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, ]); expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('dependencies'); From e6c8febab25e06c4144509e933e08904f488876c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 23:15:31 +0000 Subject: [PATCH 5/8] test(validation): expect both native host reports Keep packed-consumer validation aligned with the newly merged Codex validator while retaining failure checks. --- .../tests/packed-consumer.test.ts | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/agent-bundle/tests/packed-consumer.test.ts b/packages/agent-bundle/tests/packed-consumer.test.ts index 07e573b61..cf01944d6 100644 --- a/packages/agent-bundle/tests/packed-consumer.test.ts +++ b/packages/agent-bundle/tests/packed-consumer.test.ts @@ -234,23 +234,16 @@ it('uses only an installed tarball after source deletion', async () => { readonly target: string; }[]; }; - expect(validationDocument.hostValidation).toHaveLength(1); - expect(validationDocument.hostValidation[0]).toMatchObject({ - host: 'claude', - target: 'claude', - }); - if (validationDocument.hostValidation[0]!.status === 'passed') { - expect(validationDocument.diagnostics).toEqual([]); - expect(validationDocument.hostValidation[0]!.diagnostics).toEqual([]); - } else { - expect(validationDocument.hostValidation[0]).toMatchObject({ - diagnostics: [{ code: 'AB6019', severity: 'info' }], - status: 'unavailable', - }); - expect(validationDocument.diagnostics).toEqual([ - expect.objectContaining({ code: 'AB6019', severity: 'info' }), - ]); + expect(validationDocument.hostValidation.map((report) => report.target).sort()) + .toEqual(['claude', 'codex']); + for (const report of validationDocument.hostValidation) { + expect(report.host).toBe(report.target); + expect(['passed', 'unavailable', 'warnings']).toContain(report.status); + expect(report.diagnostics.every((diagnostic) => diagnostic.severity !== 'error')).toBe(true); } + expect(validationDocument.diagnostics).toEqual( + validationDocument.hostValidation.flatMap((report) => report.diagnostics), + ); const bundlePath = join(artifact, 'portable', 'scripts', 'bundle.mjs'); await expect(execFile(process.execPath, [ From 3601a2aaace43b14e1a71a7a423e3f45565db675 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 23:32:02 +0000 Subject: [PATCH 6/8] test: align validation and status expectations Keep dependency, host-validation, and project-status proofs current with the merged compiler surfaces. --- packages/agent-bundle/tests/api.test.ts | 4 ++-- packages/agent-bundle/tests/cli.test.ts | 23 ++++++++++++++----- packages/workbench/tests/overview.e2e.test.ts | 8 ++++++- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index a68a51832..0f799d882 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -262,7 +262,7 @@ it('accepts the public claude.dependencies config surface and plans its manifest " plugin: { name: 'api-fixture', version: '1.0.0' },", " targets: ['claude'],", ' claude: {', - " dependencies: ['audit-logger', { name: 'policy-kit', version: '^2.0', marketplace: 'acme-shared' }],", + " dependencies: [{ name: 'audit-logger', marketplace: 'acme-shared' }, { name: 'policy-kit', version: '^2.0', marketplace: 'acme-shared' }],", ' },', '};', '', @@ -276,7 +276,7 @@ it('accepts the public claude.dependencies config surface and plans its manifest expect(manifest?.kind).toBe('write'); if (manifest?.kind !== 'write') throw new Error('Expected an emitted Claude plugin manifest.'); expect(JSON.parse(manifest.content).dependencies).toEqual([ - 'audit-logger', + { marketplace: 'acme-shared', name: 'audit-logger' }, { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, ]); } finally { diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 822dceaa6..7176075fc 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -343,16 +343,27 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( const artifactValidation = await runCli(project.root, [ 'validate', '--root', project.root, '--artifact', project.output, '--json', ]); - expect(artifactValidation).toEqual({ - code: 0, - stderr: '', - stdout: '{"diagnostics":[]}\n', - }); + expect(artifactValidation).toMatchObject({ code: 0, stderr: '' }); + const validationDocument = JSON.parse(artifactValidation.stdout) as { + readonly diagnostics: readonly { readonly severity: string }[]; + readonly hostValidation: readonly { + readonly diagnostics: readonly { readonly severity: string }[]; + readonly host: string; + readonly status: string; + readonly target: string; + }[]; + }; + expect(validationDocument.hostValidation).toHaveLength(1); + expect(validationDocument.hostValidation[0]).toMatchObject({ host: 'codex', target: 'codex' }); + expect(['passed', 'unavailable', 'warnings']).toContain(validationDocument.hostValidation[0]!.status); + expect(validationDocument.diagnostics.every((diagnostic) => diagnostic.severity !== 'error')).toBe(true); + expect(validationDocument.diagnostics).toEqual(validationDocument.hostValidation[0]!.diagnostics); const humanValidation = await runCli(project.root, [ 'validate', '--root', project.root, '--artifact', project.output, ]); - expect(humanValidation).toEqual({ code: 0, stderr: '', stdout: 'Validation succeeded\n' }); + expect(humanValidation).toMatchObject({ code: 0, stderr: '' }); + expect(humanValidation.stdout).toContain('Validation succeeded'); } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); } diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index 5dbe1265d..d7c5f737b 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -314,7 +314,13 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime }, fixture.url); const initialProjectSource = await readProjectSource(); // Source status carries the package identity derived from package.json (#94). - expect(initialProjectSource).toEqual({ diagnostics: [], packageName: '@agent-bundle/rsc-agent-runtime-demo', revision: sourceRevision, state: 'ready' }); + expect(initialProjectSource).toEqual({ + diagnostics: [], + packageName: '@agent-bundle/rsc-agent-runtime-demo', + packageVersion: '1.0.0', + revision: sourceRevision, + state: 'ready', + }); const expectRuntimeProfileInspection = async (preview: Locator, expectedSourceRevision: string): Promise => { await expect(preview.getByLabel('Simulated MCP App profile')).toContainText('Portable MCP Apps'); await expect(preview.getByLabel('Simulated MCP App profile')).toContainText('agent-bundle:mcp-apps:2026-01-26'); From 311ee4e49eac638c5fd71b5db31d33159781c3d6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 00:01:27 +0000 Subject: [PATCH 7/8] test(workbench): track dynamic subscription baseline Keep the Runtime restart leak proof relative to server-owned subscriptions as the server adds streams. --- packages/workbench/tests/overview.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index 7e47bd4e2..f6f62637a 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -667,7 +667,7 @@ e2e('keeps runtime MCP routing constrained after direct navigation from a bound e2e('restarts the real Runtime MCP App session when definition or transport authority changes', { timeout: 150_000 }, async ({ page }) => { const fixture = await startRuntimePlaygroundFixture(); const serverOwnedProjectSubscriptions = fixture.eventHubState.subscriptionCount; - expect(serverOwnedProjectSubscriptions).toBe(1); + expect(serverOwnedProjectSubscriptions).toBeGreaterThan(0); const pageErrors: Error[] = []; const artifactMcpSessionRequests: string[] = []; const projectEventStreams: string[] = []; From 341759fceb9f24f415e0b4664245c1fab53dc46f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 00:07:39 +0000 Subject: [PATCH 8/8] test(cli): honor no-host validation proof Keep artifact-only CLI validation aligned with the newly merged opt-out flag. --- packages/agent-bundle/tests/cli.test.ts | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 7b0205de3..87ba053ff 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -376,27 +376,16 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( const artifactValidation = await runCli(project.root, [ 'validate', '--root', project.root, '--artifact', project.output, '--no-host-validation', '--json', ]); - expect(artifactValidation).toMatchObject({ code: 0, stderr: '' }); - const validationDocument = JSON.parse(artifactValidation.stdout) as { - readonly diagnostics: readonly { readonly severity: string }[]; - readonly hostValidation: readonly { - readonly diagnostics: readonly { readonly severity: string }[]; - readonly host: string; - readonly status: string; - readonly target: string; - }[]; - }; - expect(validationDocument.hostValidation).toHaveLength(1); - expect(validationDocument.hostValidation[0]).toMatchObject({ host: 'codex', target: 'codex' }); - expect(['passed', 'unavailable', 'warnings']).toContain(validationDocument.hostValidation[0]!.status); - expect(validationDocument.diagnostics.every((diagnostic) => diagnostic.severity !== 'error')).toBe(true); - expect(validationDocument.diagnostics).toEqual(validationDocument.hostValidation[0]!.diagnostics); + expect(artifactValidation).toEqual({ + code: 0, + stderr: '', + stdout: '{"diagnostics":[]}\n', + }); const humanValidation = await runCli(project.root, [ 'validate', '--root', project.root, '--artifact', project.output, '--no-host-validation', ]); - expect(humanValidation).toMatchObject({ code: 0, stderr: '' }); - expect(humanValidation.stdout).toContain('Validation succeeded'); + expect(humanValidation).toEqual({ code: 0, stderr: '', stdout: 'Validation succeeded\n' }); } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); }