Skip to content

Spike: VS Code E2E harness with a host↔remote serialization guard - #91

Merged
dnegstad merged 5 commits into
mainfrom
claude/vscode-e2e-spike
Sep 17, 2026
Merged

dnegstad merged 5 commits into
mainfrom
claude/vscode-e2e-spike

Conversation

@dnegstad

@dnegstad dnegstad commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

What this is

A spike, not a comprehensive suite. It establishes that @vscode/test-electron can load both extensions into one real VS Code window, drives one vertical slice of the cert flow, and guards the main fidelity gap that approach introduces. The goal was feasibility plus a minimal working slice — please read it as evidence for a decision, not as test coverage.

Verified in CI: the VS Code E2E (spike) job passes on ubuntu-latest, 13 assertions, ~39s for the job including the VS Code download.

devcontainer-dev-certs :: VS Code E2E

  ok   both extensions are loaded into the same window (0ms)
  ok   activate() does not throw for either extension (90ms)
  ok   UI extension registers its cross-host commands (3ms)
  ok   workspace extension registers its commands past the remote gate (2ms)
  ok   host serves the configured user cert over getAllCertMaterialV3 (17ms)
  ok   workspace extension installs the material into the sandbox (4ms)
  ok   guard rejects a Buffer payload (self-test) (0ms)
  ok   guard rejects other non-JSON values (self-test) (1ms)
  ok   guard accepts a plain payload with undefined optionals (self-test) (0ms)
  ok   V3 bundle survives the host↔remote hop unchanged (1ms)
  ok   V2 bundle survives the host↔remote hop unchanged (2ms)
  ok   command arguments survive the host↔remote hop unchanged (0ms)
  ok   the bundle is byte-identical after a JSON round trip (1ms)

13 passed, 0 failed

The constraint this had to work around

src/vscode-workspace-extension/src/extension.ts no-ops unless vscode.env.remoteName is set. That property is read-only and populated only by a resolver extension that has claimed an authority, so nothing inside a test can set it. Exercising the workspace extension in a local window needs a seam.

isRemoteContext() also accepts DEVCONTAINER_DEV_CERTS_TEST_REMOTE=1, but only when context.extensionMode !== ExtensionMode.Production. Production is what VS Code assigns to every installed extension — marketplace VSIX, sideloaded VSIX, --install-extension copy — so reaching the override requires launching VS Code with --extensionDevelopmentPath or --extensionTestsPath pointed at a source checkout. An environment variable alone cannot flip it on.

This is a test-only branch in product code, and the gating is the only reason it should be acceptable. tests/remoteGate.test.ts pins that directly: the override is refused under Production for every truthy spelling of the variable. It runs in the fast vitest suite, not the E2E job, so the guarantee does not depend on a VS Code download succeeding. If this is ever relaxed to an env-var-only check, that should fail review.

The alternative — a resolver extension implementing resolveAuthority to populate remoteName for real — avoids the product-code seam and would be more faithful, but rides a proposed API needing --enable-proposed-api. Choosing between the two is the decision this spike exists to inform; see the trade-off note in AGENTS.md.

The serialization guard (test/vscode-e2e/src/wireGuard.ts)

This is the part worth reviewing closely.

Within a single extension host, executeCommand passes objects by reference. Across the real host↔remote hop they are serialized. So a payload carrying a Buffer would pass this suite and fail in production, with a failure mode — silently mangled cert bytes — that is miserable to diagnose. The guard compensates by running every cross-host payload through two independent checks:

  1. A structural walk rejecting anything that is not a JSON primitive, plain object, or array: Buffer, Uint8Array, Date, Map, class instances, functions, bigint, non-finite numbers, cycles, own toJSON, symbol keys.
  2. Real structuredClone and JSON.parse(JSON.stringify(...)) round trips compared back against the original.

undefined-valued object properties are the one tolerated difference — VS Code's RPC drops them exactly as JSON does, and CertMaterialV3 sets absent optionals to a literal undefined, so flagging it would fire on every real bundle. undefined inside an array is still rejected, since it becomes null.

The negative self-tests are load-bearing rather than decoration: without them, a guard that silently accepted everything would produce an identical green run.

The vertical slice

getAllCertMaterialV3 driven from the workspace extension to the UI extension, then the container-side install asserted on disk: PEM present and matching the configured cert, {hash}.N symlink resolving (checked with plain fs, not only via hasHashSymlink, so it does not test the installer with the installer's own helper), .NET Root store PFX written, and the My-store PFX correctly absent because the cert never opted in.

It uses a user certificate rather than the auto-generated dev cert deliberately: that path skips the modal consent dialog nothing headless can dismiss, and user certs are never added to the host OS trust store. All writes are redirected into a mkdtemp sandbox via HOME and DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY, so the runner's real ~/.aspnet and ~/.dotnet are untouched.

What this does NOT cover

  • The actual host↔server hop. Both extensions share one extension host. The wire guard compensates; it does not replace it.
  • The container filesystem. The "container" is a temp dir on the same machine — no separate users, permissions, mounts, or $HOME.
  • The dotnet dev-cert path and reverse-sync. generateDotNetCert is off for the run, so dev-cert generation and acceptContainerDevCert are untested.

loadCheck.cjs, and why it exists

The first CI run failed inside the extension host with tsyringe requires a reflect polyfill — the suite bundles the shared package, which pulls in @peculiar/x509 → tsyringe, and the E2E entry point was the one place missing import "reflect-metadata" (both extensions and vitest.setup.ts already had it).

Finding that cost a full job: npm ci, two extension builds, a ~110MB download, an Electron launch. It reproduces in ~200ms by stubbing vscode and require()-ing the bundle. So npm run check:e2e-load does exactly that, and CI runs it before the download step. Cheapest failure, earliest.

CI

New vscode-e2e job, deliberately separate from extensions rather than a step inside it: it is the only thing in CI that downloads VS Code at run time, so a CDN failure shows up as "vscode-e2e red, extensions green" instead of being mistaken for a unit-test regression.

Suggest not making it a required status check yet — one green run shows the approach works, not that it is stable across VS Code releases.

Recommendation

Keep this, but don't grow it. The expensive tier earns its place for exactly one assertion that nothing cheaper can make: the two extensions really do co-load and call each other in a real host. Everything else belongs in the cheap tier — the highest-value follow-up is moving the wire guard into the unit suite as a contract test over the V2/V3 builders, where it runs in seconds with no VS Code at all.

I would not reach for the resolver-extension approach until a concrete bug appears that only it could catch.

Deliberately left out of this PR: wire guard → unit suite, .vscode-test caching, macOS/Windows E2E jobs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AX4749FUyTUSGTb3VWMSC1

Loads both extensions into one real VS Code window and drives the
host->workspace cert flow, plus a guard for the fidelity gap that
approach introduces.

The workspace extension no-ops unless `vscode.env.remoteName` is set,
and that property is read-only and populated only by a resolver
extension claiming an authority — nothing in a test can set it. So
`isRemoteContext()` also accepts DEVCONTAINER_DEV_CERTS_TEST_REMOTE=1,
but only when `context.extensionMode !== ExtensionMode.Production`.
Production is what VS Code assigns to every installed extension, so the
override is unreachable in any shipped build: an env var alone cannot
reach it. tests/remoteGate.test.ts pins that, in the fast vitest suite
rather than only in the E2E job.

The vertical slice runs getAllCertMaterialV3 from the workspace
extension to the UI extension and asserts the container-side install on
disk — PEM present and matching, {hash}.N symlink resolving, .NET Root
store PFX written, My-store PFX correctly absent. It uses a user
certificate rather than the auto-generated dev cert: that path skips the
modal consent prompt and never touches the host OS trust store. All
writes are redirected into a mkdtemp sandbox via HOME and
DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY.

Within one extension host executeCommand passes objects by reference,
while the real host<->remote hop serializes them — so a payload carrying
a Buffer would pass this suite and fail in production. wireGuard.ts
walks every cross-host payload for non-JSON values and round-trips it
through structuredClone and JSON. Its negative self-tests are
load-bearing: without them a guard that accepted everything would
produce an identical green run.

CI gets a separate `vscode-e2e` job so a VS Code download failure can't
be mistaken for a unit-test regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AX4749FUyTUSGTb3VWMSC1
The hosted ubuntu images ship it, but pinning the dependency costs a
few seconds and removes a reliance on the runner image's contents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AX4749FUyTUSGTb3VWMSC1
The suite bundles @devcontainer-dev-certs/shared, which pulls in
@peculiar/x509 -> tsyringe, and tsyringe binds its @Injectable decorators
to Reflect.metadata at module-init time. Without the polyfill the
extension host threw while requiring --extensionTestsPath, before any
test ran:

  Error: tsyringe requires a reflect polyfill.
    at node_modules/tsyringe/dist/cjs/index.js (suite.cjs:11081:13)
    at node_modules/@peculiar/x509/build/x509.cjs.js
    at src/shared/src/cert/types.ts

Both extensions' entry points and vitest.setup.ts already carry this
import; the E2E entry point was the one place that didn't.
reflect-metadata becomes an explicit root devDependency rather than
something inherited by workspace hoisting.

Also adds test/vscode-e2e/loadCheck.cjs, which stubs `vscode` and
requires the built bundle. It reproduces this exact failure in ~200ms.
Finding it the other way cost an npm ci, two extension builds, a ~110MB
VS Code download and an Electron launch, so the CI job now runs the load
check before the download step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AX4749FUyTUSGTb3VWMSC1
It is a new script with a non-obvious reason to exist and a specific
place in the ordering (before the VS Code download), neither of which
was written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AX4749FUyTUSGTb3VWMSC1
@dnegstad dnegstad changed the title Add VS Code E2E test suite with serialization guard Spike: VS Code E2E harness with a host↔remote serialization guard Sep 17, 2026
Three findings from review, all confirmed:

esbuild and typescript back the new root-level build:e2e and
typecheck:e2e scripts but resolved only through workspace hoisting. A
version conflict or a nested install strategy would have broken the CI
job for reasons with nothing to do with the code. Declared at the root,
matching the workspace versions, as @vscode/test-electron and
reflect-metadata already are.

The sandbox set only HOME, which os.homedir() ignores on Windows in
favour of %USERPROFILE% — so `npm run test:e2e` on a Windows checkout
would have written the Root-store PFX into the developer's real profile.
Both variables are now set. The containment check also moves ahead of
the install and covers all three target directories, so a future break
in the redirect refuses to run rather than reporting the damage
afterwards. It compares with path.relative instead of startsWith, which
additionally stops `<sandbox>-evil` reading as inside `<sandbox>`.

process.exit() truncates pending pipe writes, discarding the stack trace
and the sandbox path precisely when the job is red and they are the only
things worth having. Setting process.exitCode lets Node drain first;
verified the launcher still exits 1 with the diagnostics intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AX4749FUyTUSGTb3VWMSC1
@dnegstad
dnegstad merged commit f10246d into main Sep 17, 2026
9 checks passed
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.

2 participants