Skip to content

build: make agent SDK tarballs a function of version and target - #334113

Merged
TylerLeonhardt merged 7 commits into
mainfrom
agent-sdk-omit-peer-deps
Sep 2, 2026
Merged

build: make agent SDK tarballs a function of version and target#334113
TylerLeonhardt merged 7 commits into
mainfrom
agent-sdk-omit-peer-deps

Conversation

@TylerLeonhardt

@TylerLeonhardt TylerLeonhardt commented Sep 2, 2026

Copy link
Copy Markdown
Member

Follow-up to #334094, which reverted a lockfile bump because the resulting tarball collided with an already-published CDN blob.

The problem

CDN paths are content-addressed and immutable. upload.ts HEADs first and refuses to overwrite a blob whose sha256 differs. So the tarball at agent-sdk/claude/<version>/<target>.tgz has to be a function of the version and target and nothing else.

It wasn't. npm 7+ auto-installs peerDependencies, so 100 packages (@modelcontextprotocol/sdk, zod, ajv and their graph) landed in node_modules and got tarred. Bump any of them transitively and the bytes change while the version does not, and the next upload fails against the published blob. That is what #333870 did.

The fix

npm ci --ignore-scripts --omit=peer. A claude tarball is now exactly two packages, @anthropic-ai/claude-agent-sdk and its claude-agent-sdk-<target> binary package, both pinned to the SDK version. Peer churn can no longer move the bytes.

The peers were never loaded from that tarball:

  • sdk.mjs statically imports node builtins and nothing else. The one external module it resolves at runtime is its own native binary package. It inlines MCP, zod and ajv at publish time.
  • @modelcontextprotocol/sdk is import type in all eight places it appears in non-test src/.
  • zod is the interesting one. claudeJsonSchemaToZod.ts imports z at runtime, so this is not an unused dependency. But that zod is VS Code's own root dependency, shipped in the product, and the raw shapes it builds are passed into the SDK. Nothing resolves zod out of the downloaded tree. That narrower invariant is what --omit=peer actually needs, and it is what the load probe is built around.
  • Empirically, an --omit=peer install drives a full MCP initialize / tools/list / tools/call round-trip with identical output.

Size is not the point. The peers are about 4% of a 90MB tarball.

--omit=optional would be a very different flag, since the native binary ships as an optional dependency. That is what findMissingNativeOptionalDep exists to catch.

Also bumps claude to 0.3.258 from 0.3.239, since the flag changes the bytes and the version has to move anyway. 0.3.258 adds updateSettings as a required Query member, so three test fakes get a stub. Worth flagging: the compile-time _assertBindingsMatchSdk drift check did not catch that, because it only covers bindings we opt into. The fakes' implements Query did the wider structural check by accident.

Verifying it, for every SDK

That the SDK inlines its peers is an implementation detail Anthropic never promised, and the peerDependencies block says the opposite. So the assumption needs a guard. Since --omit=peer applies to every SDK, that guard cannot be special-cased to one. Sdk is an open string type, so adding an SDK is one folder under agents/, which would otherwise inherit the flag unchecked.

verifyStagedTree runs against the finished tree just before it is tarred, and every SDK must have a case. The default branch fails the build rather than skipping. The checks are deliberately different, because copying one onto the other would assert something we do not depend on.

claude. The agent host dynamic-imports sdk.mjs out of the tarball, so the build imports it too, in a child process and under a timeout. It then repeats the shipped call shape from buildClientToolMcpServer: a zod raw shape into sdk.tool(), the result into createSdkMcpServer(). Checking that the exports merely exist would miss a peer resolved lazily inside tool(). The native binary is then asserted present, non-empty and executable.

codex. The agent host never loads JS from that tarball. It spawns vendor/<rust-triple>/bin/codex[.exe] directly. So the check is structural: exactly one vendored triple, holding a runnable binary. codexAgent.ts's sdkTarget → triple table is deliberately not copied here, because a second copy could drift and then validate a path nothing uses. A renamed triple stays the runtime's to catch.

Both are cross-target safe. The file checks are pure stat, and the claude probe only runs platform-independent JS that never reaches for the native binary.

Drive-by

chmodPlatformBinaries's claude branch looked for claude on every target, silently skipping win32's claude.exe. Nothing shipped broken, since the registry already publishes that binary 0755 and Windows ignores POSIX modes on extract. But the assumption was wrong, and the new assertion checks the same path the loop chmods. Both now derive the name from the target.

Testing

Fault injection against a real extracted codex tree. Every check fires, and an untouched tree passes:

PASS  control: untouched tree passes
PASS  vendor/ missing
PASS  vendor/ has two triples
PASS  vendor/ has zero triples
PASS  binary missing
PASS  binary empty
PASS  binary not executable
PASS  unknown SDK ('gemini') hits the default branch

The claude probe is non-vacuous in three separate ways, each checked by substituting a broken SDK:

  • a static peer import in sdk.mjs gives exit 1 with ERR_MODULE_NOT_FOUND
  • a tool() that resolves a peer from disk gives the same
  • a probe that never exits is killed by the timeout as signal: 'SIGTERM'

Five real builds, all exit 0:

SDK target bytes
claude darwin-arm64 88352373
claude win32-x64 100890027
claude linux-x64-musl 95920117
codex darwin-arm64 114149426
codex win32-arm64 130874738

The claude darwin-arm64 sha is byte-identical across builds made before the verification existed and after the probe was extended, so the checks do not perturb the artifact. The codex sha is unchanged from main, since codex declares no peers and the flag is inert there. The win32-x64 claude build confirms the .exe path resolves.

cd build && npm run typecheck and eslint are clean.

Supersedes the lockfile-freeze follow-up suggested in #334094.

npm 7+ installs peerDependencies automatically, so the claude tarball has
been carrying 100 packages the agent host never loads —
@modelcontextprotocol/sdk, zod, ajv and their transitive graph. The SDK
inlines all of that into sdk.mjs at publish time: sdk.mjs statically imports
node builtins and nothing else, and the one external module it resolves at
runtime is its own native binary package. Every reference to those packages
on the VS Code side is an `import type`, which TypeScript erases.

Adding --omit=peer to the packaging install leaves exactly two packages in
the tarball, both pinned to the SDK version. That makes the bytes a function
of (SDK version, target) and nothing else, so a transitive peer bump can no
longer change the content at a CDN path that is already published — the
failure that took #333870 and its revert #334094. Unlike --omit=optional,
this doesn't touch the native binary package. codex declares no peers, so
its tarball is byte-identical either way.

That the SDK inlines its peers is an implementation detail Anthropic never
promised, so package.ts now runs a load probe before tarring: in a child
process it imports sdk.mjs out of the staged tree and builds an MCP server
from it, peers absent. If a future version starts importing a peer for real,
the build fails there rather than on a user's machine against a tarball that
is already immutable on the CDN.

Bumping claude in the same change since the CDN path moves regardless.
0.3.258 adds a required Query.updateSettings, hence the three test fakes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings September 2, 2026 20:39
@TylerLeonhardt
TylerLeonhardt marked this pull request as draft September 2, 2026 20:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The load probe needs a bounded timeout and must exercise the production sdk.tool(...) path.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 2 Medium severity · 1 Low severity

New issues introduced by this change (3)
Severity Finding
Medium severity build/​agent-sdk/​package.ts — The guard only checks that tool is a function; it never invokes the production sdk.tool(...)
Medium severity build/​agent-sdk/​package.ts — A child process does not prevent a stray top-level handle from wedging this synchronous build:…
Low severity build/​agent-sdk/​README.md — This statement is inaccurate: `src/vs/platform/agentHost/node/claude/clientTools/claudeJsonSchemaToZ…
What changed in this PR

Makes Agent SDK tarballs reproducible by excluding peer dependencies, validating Claude SDK loading, and upgrading Claude to 0.3.258.

Changes:

  • Adds --omit=peer and Claude SDK load validation.
  • Updates Claude SDK pins and lockfiles.
  • Updates packaging documentation and test fakes.
File Description
src/​vs/​platform/​agentHost/​test/​node/​claudeSdkPipeline.test.ts Updates the query fake.
src/​vs/​platform/​agentHost/​test/​node/​claudeAgent.test.ts Updates the agent query fake.
src/​vs/​platform/​agentHost/​test/​node/​claudeAgent.integrationTest.ts Updates the integration query fake.
package.json Updates the development SDK pin.
package-lock.json Locks root Claude dependencies.
build/​agent-sdk/​README.md Documents tarball composition.
build/​agent-sdk/​package.ts Omits peers and adds load validation.
build/​agent-sdk/​agents/​claude/​package.json Updates the build SDK pin.
build/​agent-sdk/​agents/​claude/​package-lock.json Locks Claude 0.3.258 packages.
Files not reviewed (1)
  • build/agent-sdk/agents/claude/package-lock.json: Generated file
Suppressed comments (2)

build/agent-sdk/package.ts:190

  • This 20-line JSDoc exceeds the repository's 1–2 short sentence limit and duplicates rationale already documented in the README. Condense it to the function contract and keep the detailed packaging rationale in build/agent-sdk/README.md; the same applies to the narrative inline blocks within this helper.
/**
 * Smoke-checks the packaged claude tree before it is tarred: imports the SDK's
 * ESM entry point and builds an in-process MCP server out of it, with the
 * peerDependencies absent (see `npmCi`).
 *

build/agent-sdk/package.ts:250

  • This narrative block exceeds the repository's one-line limit for inline method comments and repeats the README. Keep only the non-obvious link between peer omission and the load guard here.
	// `--omit=peer` drops the auto-installed peerDependencies. The agent host
	// never loads them out of the tarball: the SDK's own entry points inline
	// everything they need (MCP, zod, ajv) at bundle time, and the workbench
	// passes its own zod / MCP types across the boundary. Omitting them keeps
	// the tarball a pure function of (SDK version, target), so a transitive

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread build/agent-sdk/package.ts Outdated
Comment thread build/agent-sdk/package.ts Outdated
Comment thread build/agent-sdk/README.md Outdated
`--omit=peer` applies to every SDK, and `Sdk` is an open string type, so
adding one is a single folder under `agents/`. The load probe that
justified the flag only ran for claude, which left any other SDK — codex
today, anything added later — inheriting the flag with nothing checking
it.

Replace the `if (sdk === 'claude')` guard with a `verifyStagedTree`
dispatcher whose `default` branch fails the build. The per-SDK checks
stay different on purpose: claude's tarball is dynamic-imported by the
agent host, so the build imports it too; codex's never is, since the
host spawns the vendored binary directly, so the binary layout is what's
worth asserting.

codex gets a structural check — the platform package vendors exactly one
rust triple, holding a non-empty executable binary. It deliberately does
not copy `codexAgent.ts`'s `sdkTarget → triple` table; a second copy
could drift and then validate a path nothing uses.

Also fixes a latent bug in `chmodPlatformBinaries`: the claude branch
looked for `claude` on every target, so it silently skipped win32's
`claude.exe`. Nothing shipped broken — the registry already publishes
that binary 0755 and Windows ignores POSIX modes on extract — but the
loop's filename assumption was wrong, and the new assertion checks the
same path it chmods.

Verified by fault injection against a real extracted tree: all seven
codex checks and the unknown-SDK branch fire, and an untouched tree
passes. Five real builds (claude darwin-arm64/win32-x64/linux-x64-musl,
codex darwin-arm64/win32-arm64) succeed; the claude darwin-arm64 sha is
byte-identical to one built before these checks existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TylerLeonhardt TylerLeonhardt changed the title build: omit peer deps from agent SDK tarballs, bump claude to 0.3.258 build: make agent SDK tarballs a function of version and target Sep 2, 2026
TylerLeonhardt and others added 5 commits September 2, 2026 14:07
…with a timeout

Three fixes from PR review.

The probe checked that `tool` was a function but never called it. The
shipped path is `buildClientToolMcpServer`, which passes a zod raw shape
into `sdk.tool()` and the result into `createSdkMcpServer()`. A future SDK
that resolved zod lazily inside `tool()` would sail past the old check and
break at runtime. The probe now makes that exact call, using VS Code's own
zod, which is what the agent host hands across the boundary. Verified by
substituting a `tool()` that resolves a peer from disk: exit 1 with
ERR_MODULE_NOT_FOUND.

`spawnSync` without a timeout blocks forever, so the old comment claiming a
child process kept a stray handle from wedging the build was wrong. Added a
2 minute timeout and a `result.signal` check, since a timeout surfaces as
SIGTERM with a null status and would otherwise report a confusing exit code.

The README claimed every reference to the peers in non-test `src/` was
`import type`. That is true of `@modelcontextprotocol/sdk` but not of zod:
`claudeJsonSchemaToZod.ts` imports `z` at runtime. The invariant that
`--omit=peer` actually needs is narrower, that zod comes from VS Code's own
dependency rather than the downloaded tree, so the README says that instead.

Tarball sha is unchanged, since the probe file sits outside `node_modules`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback: the comments on the new verification code ran far longer
than the code they described. Cut them roughly in half, and point at
README.md for the rationale instead of restating it in three places.

Also drops `nativeBinaryName` for a one-line `exeName(base, sdkTarget)`.
It took an `Sdk` parameter every call site already knew statically, and
`sdk === 'claude' ? 'claude' : 'codex'` would have silently returned
'codex' for any SDK added later. The only rule the two share is the
`.exe` suffix on win32.

No behavior change: claude darwin-arm64 still builds to sha256
1050d42b5e86f1d5b0c3a910e5325894d7b1dcfb684fe08ff4ffbf09dcfe0cda and
codex darwin-arm64 to a32d7afd7f088e8e4fb9f237283bfb93f656ac1da5c78fb879d31e48422a241d.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
createSdkMcpServer() is what validates and converts the zod raw shape;
tool() is a plain constructor that never touches zod. Verified by passing
a non-zod shape: tool() returns fine, createSdkMcpServer() throws
"inputSchema must be a Zod schema or raw shape".

Comment and README said tool() was the load-bearing call. The sequence
was already right, only the explanation was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
verifyStagedTree was a switch with a claude case that imported sdk.mjs and
replayed buildClientToolMcpServer's call shape (zod raw shape into tool(),
result into createSdkMcpServer()) and a codex case that asserted the
vendor/<triple>/bin layout. That put one SDK's API into the packaging step
for a small gain: a peer that stops being inlined will come back as a
static import, which a plain import of the entry point already catches.

Now nothing in the check is conditioned on which SDK is building:

- The entry point comes from the installed manifest's `main`, which is the
  same path claudeAgentSdkService.ts imports at runtime. codex declares no
  `main`, so it is skipped without a special case.
- Every native binary must be present, non-empty and executable.

The per-SDK binary layouts move into listPlatformBinaries, which
chmodPlatformBinaries now shares, so the chmod and the assertion can no
longer disagree about where the binaries are. That is also the new-SDK
guard: no layout entry means no binaries found, and the build fails naming
the function to edit.

Removes the zod dependency from the build script and ~50 lines.

Fault-injected, all caught: binary missing / empty / not executable,
codex vendor/ removed, sdk.mjs importing an uninstalled peer (inserted
after the shebang so it is a real ERR_MODULE_NOT_FOUND), and
listPlatformBinaries returning [] for an unknown SDK.

Tarball bytes unchanged: claude darwin-arm64 1050d42b…, claude win32-x64
b4e00f75…, codex darwin-arm64 a32d7afd….

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bumped CLI now backticks the model name in its `/model` slash command
output, so the recorded request no longer matched the live one and the E2E
replay failed on Linux and macOS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TylerLeonhardt
TylerLeonhardt marked this pull request as ready for review September 2, 2026 22:28
@TylerLeonhardt
TylerLeonhardt enabled auto-merge (squash) September 2, 2026 22:28
@TylerLeonhardt
TylerLeonhardt merged commit 06ac30f into main Sep 2, 2026
41 checks passed
@TylerLeonhardt
TylerLeonhardt deleted the agent-sdk-omit-peer-deps branch September 2, 2026 22:37
@vs-code-engineering vs-code-engineering Bot added this to the 1.137.0 milestone Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants