Spike: VS Code E2E harness with a host↔remote serialization guard - #91
Merged
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
A spike, not a comprehensive suite. It establishes that
@vscode/test-electroncan 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 onubuntu-latest, 13 assertions, ~39s for the job including the VS Code download.The constraint this had to work around
src/vscode-workspace-extension/src/extension.tsno-ops unlessvscode.env.remoteNameis 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 acceptsDEVCONTAINER_DEV_CERTS_TEST_REMOTE=1, but only whencontext.extensionMode !== ExtensionMode.Production.Productionis what VS Code assigns to every installed extension — marketplace VSIX, sideloaded VSIX,--install-extensioncopy — so reaching the override requires launching VS Code with--extensionDevelopmentPathor--extensionTestsPathpointed 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.tspins that directly: the override is refused underProductionfor 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
resolveAuthorityto populateremoteNamefor 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 inAGENTS.md.The serialization guard (
test/vscode-e2e/src/wireGuard.ts)This is the part worth reviewing closely.
Within a single extension host,
executeCommandpasses objects by reference. Across the real host↔remote hop they are serialized. So a payload carrying aBufferwould 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:Buffer,Uint8Array,Date,Map, class instances, functions,bigint, non-finite numbers, cycles, owntoJSON, symbol keys.structuredCloneandJSON.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, andCertMaterialV3sets absent optionals to a literalundefined, so flagging it would fire on every real bundle.undefinedinside an array is still rejected, since it becomesnull.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
getAllCertMaterialV3driven from the workspace extension to the UI extension, then the container-side install asserted on disk: PEM present and matching the configured cert,{hash}.Nsymlink resolving (checked with plainfs, not only viahasHashSymlink, 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
mkdtempsandbox viaHOMEandDOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY, so the runner's real~/.aspnetand~/.dotnetare untouched.What this does NOT cover
$HOME.generateDotNetCertis off for the run, so dev-cert generation andacceptContainerDevCertare untested.loadCheck.cjs, and why it existsThe 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 missingimport "reflect-metadata"(both extensions andvitest.setup.tsalready had it).Finding that cost a full job:
npm ci, two extension builds, a ~110MB download, an Electron launch. It reproduces in ~200ms by stubbingvscodeandrequire()-ing the bundle. Sonpm run check:e2e-loaddoes exactly that, and CI runs it before the download step. Cheapest failure, earliest.CI
New
vscode-e2ejob, deliberately separate fromextensionsrather 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-testcaching, macOS/Windows E2E jobs.🤖 Generated with Claude Code
https://claude.ai/code/session_01AX4749FUyTUSGTb3VWMSC1