Repository navigation
claude adapter drops cwd for source-built stdio servers: no working-directory anchor on bare installs (portable and codex both provide one) #47
Description
Package: agent-bundle claude adapter, at main 4e546516. Surfaced during final hardening of the movie-library port (its commit af0ab2a), where a copied claude artifact wrote persistent state into whatever directory the host happened to launch from.
What differs across the three adapters
Normalization gives every source-built (entry:) stdio server a plugin-root working directory in the canonical model — packages/agent-bundle/src/config/normalize.ts emits command: 'node', args: ['mcp/<name>.mjs', …], cwd: pathTokens.pluginRoot. The three adapters then diverge on what reaches the emitted config:
- portable (
src/adapters/portable.tsL159, L178): expands the token and emitscwd: "${PLUGIN_ROOT}". Anchor present. - codex (
src/adapters/codex.tsL218–L226, L256): plugin-root cwd becomescwd: "./", and plugin-root-prefixed command/args/env values are rewritten./-relative against it — the anchor is load-bearing there (values embedding the plugin-root token without it are hard diagnostics). Anchor present. - claude (
src/adapters/claude.tsL153–L161): absolutizesargs[0]to${CLAUDE_PLUGIN_ROOT}/mcp/…, then dropscwdfor any server withsourceset:
if (server.source !== undefined && server.cwd === pathTokens.pluginRoot && args?.[0] !== undefined) {
args[0] = `${hookContract.commandRoot}/${args[0]}`;
}
// …
...(server.cwd === undefined || server.source !== undefined ? {} : { cwd: expandClaudeToken(server.cwd) }),So the bundled script itself resolves (absolute arg), but the spawned process's working directory is whatever the Claude host launched from — the plugin-root anchor that both sibling targets provide is simply absent. (Command-based servers with an explicit cwd do get the field emitted, so the drop is specific to source-built entries.)
Consumer consequence
Any plugin whose runtime resolves state or assets relative to its install root gets no anchor on a bare claude install. In the movie-library port, persistent stores (per-tracker policy ledger, Trakt device-flow auth) fragmented into <launch-cwd>/.runtime per launch directory — live credentials effectively lost between sessions. The port had to fix it consumer-side twice over (commit af0ab2a):
// agent-bundle.config.ts — every server ships an env anchor, expanded per target
const withStateRootEnv = Object.fromEntries(
Object.entries(base.mcp!.servers).map(([serverName, server]) => [
serverName,
{ ...server, env: { MOVIE_LIBRARY_ROOT: pathTokens.pluginRoot } },
]),
);The claude adapter runs env values through its token expander, so this lands as env: { MOVIE_LIBRARY_ROOT: "${CLAUDE_PLUGIN_ROOT}" } and works. As a second layer, the state-root resolver detects the artifact layout (bundle at <install>/mcp/ beside the generated manifest ⇒ root is <install>) so even an env-stripping host cannot land state in a random cwd.
What the Claude config format actually supports
Checked, and it cuts both ways:
- The
cwdfield is documented in Claude's plugins reference for stdio servers, and this repo's own emitted-config schema (src/adapters/schemas/claude/mcp.schema.json) accepts it — the adapter already emits it for command-based servers. - But Claude Code currently ignores the field at runtime — silently, on all platforms: anthropics/claude-code#17565, #42883, #54786. The adapter's
args[0]absolutization looks like a deliberate hedge against exactly that; it covers the script path but not runtime state. (It also means thecwdthe adapter emits for command-based servers is a no-op upstream today.)
Suggested fix directions
- Adapter-provided env anchor (works today). For source-built stdio servers, have every adapter emit a well-known env var — e.g.
AGENT_BUNDLE_PLUGIN_ROOT— set to its native plugin-root representation (${PLUGIN_ROOT}/${CLAUDE_PLUGIN_ROOT}/ the codex./-relative form), and document it as the sanctioned install-root anchor for runtime code. This is exactly the consumer workaround, hoisted into the framework so every plugin doesn't rediscover it after a state-fragmentation incident. - Also emit
cwd: "${CLAUDE_PLUGIN_ROOT}"for source-built servers. Documented format, schema-valid, harmless, and it becomes the real fix the day Claude Code honors the field — but per the linked upstream bugs it cannot be the only fix today. - Documentation-only. Bless the per-server
env: { X: pathTokens.pluginRoot }pattern in the adapter docs as the claude-target equivalent of portable/codex cwd anchoring. Weakest: the failure mode it papers over is silent state fragmentation, discovered only after data lands in the wrong place.
Related: #42, #43, #44, #45 — surfaced by the same consumer port (movie-library); this one came out of its final hardening pass rather than the initial scaffold.
Activity
fix(docs,test): closed-issue audit G4 — correct stale claims and pin …
ScriptedAlchemy commentedon Sep 3, 2026
Closed-issue re-audit (lane G4) — anchor VERIFIED on
mainc94df11c; thecwdhalf was superseded by #347 and the published README has been corrected.withPluginRootEnvAnchor(src/adapters/types.tsL113–116, declaredenvspread after the anchor so user keys win) is applied inportable.tsL189 (${PLUGIN_ROOT}),cursor.tsL227 (${CURSOR_PLUGIN_ROOT}),claude.tsL596 (${CLAUDE_PLUGIN_ROOT}),codex.tsL742 (./, only with a plugin-root cwd). Public exportpluginRootEnvAnchor(src/core/types.tsL748,src/index.tsL6). Tests green in my worktree:host-adapters.test.ts :: anchors compiled Claude MCP entries with absolute arguments and the env anchor(L1354),:: omits the Codex env anchor when a stdio server has no plugin-root cwd to resolve it against(L2520), plus portable/cursor/plugin-bundle anchor pins andadapter-metadata.test.tsrevision pins (342 + 164 tests passed across the two batches).cwd: "${CLAUDE_PLUGIN_ROOT}"), shipped by fix(adapters): anchor emitted stdio MCP entries with AGENT_BUNDLE_PLUGIN_ROOT and restore claude cwd #49, was deliberately removed in feat(claude): validate substitution tokens and record lifecycle contract #347 (27b3b993): Claude's placeholder-substitution table excludescwd, so the host would have received a literal unsubstituted string; token-bearingcwdvalues are now rejected (claude.tsL574–595). The absoluteargs[0]+ env anchor carry the guarantee. Treating this as out-of-scope-by-design per feat(claude): validate substitution tokens and record lifecycle contract #347.packages/agent-bundle/README.mdstill said the adapter "still emitscwd: "${CLAUDE_PLUGIN_ROOT}"". PR fix(docs,test): closed-issue audit G4 — stale Claude cwd/preview-peer docs, restore rendered-skill docs, pin the #23 handshake queue #368 (mergec94df11c,agent-bundlepatch changeset) rewrites that sentence to the feat(claude): validate substitution tokens and record lifecycle contract #347 contract.