Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-caches-linger.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
102 changes: 85 additions & 17 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ import { stringify as stringifyYaml } from 'yaml';
import {
commandWriteEntries,
createAdapterValidator,
hasPathToken,
schemaDescriptorsFrom,
sortedEntries,
sourceInputs,
Expand Down Expand Up @@ -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),
});
Expand All @@ -436,6 +435,7 @@ const agentCapabilities = Object.freeze(Object.fromEntries(
unavailableCapability(row.reason),
]),
));
const packageLifecycle = capabilityTable.plugin.packageLifecycle;

export const claudeArtifactValidation = deepFreeze({
documents: [
Expand Down Expand Up @@ -511,21 +511,27 @@ 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.`,
));
}
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]}`;
}
Expand All @@ -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',
},
Expand All @@ -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.`,
Expand Down Expand Up @@ -593,6 +599,45 @@ const lspServerFields: ReadonlySet<string> = new Set([
const isDataRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
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<object> = 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<Record<string, unknown>> =>
isDataRecord(value) && [null, Object.prototype].includes(Object.getPrototypeOf(value));
const dependencyFields: ReadonlySet<string> = new Set(['marketplace', 'name', 'version']);
Expand Down Expand Up @@ -1800,6 +1845,13 @@ const planClaudeMarketplace = (model: NormalizedPlugin): ClaudeMarketplacePlan =
const expandLspToken = (value: unknown): unknown =>
typeof value === 'string' ? expandClaudeToken(value) : value;

const lspPathSubstitutionFields: ReadonlySet<string> = new Set([
'args',
'command',
'env',
'workspaceFolder',
]);

const planLspServer = (
name: string,
declared: unknown,
Expand All @@ -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) {
Expand All @@ -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.`,
Expand Down Expand Up @@ -2641,7 +2700,7 @@ const planSubagentStatusLine = (
));
return { diagnostics };
}
if (hasPathToken(command)) {
if (findClaudePathSubstitutionToken(command) !== undefined) {
diagnostics.push(settingsTokenDiagnostic('subagentStatusLine.command'));
return { diagnostics };
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
),
Expand Down
32 changes: 31 additions & 1 deletion packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading