Skip to content

Extract shared cert layer; hostCertGenerator setting + macOS PFX support - #74

Merged
dnegstad merged 54 commits into
mainfrom
claude/devcontainer-non-vscode-analysis-CLJg9
Jul 28, 2026
Merged

Extract shared cert layer; hostCertGenerator setting + macOS PFX support#74
dnegstad merged 54 commits into
mainfrom
claude/devcontainer-non-vscode-analysis-CLJg9

Conversation

@dnegstad

@dnegstad dnegstad commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Summary

Three threads pulled into one PR:

  1. Extract a @devcontainer-dev-certs/shared workspace containing the platform-store implementations (Windows / macOS / Linux), cert generation + export + management, NSS trust, and process-execution helpers. The VS Code host extension now imports from this layer instead of owning the code; the workspace extension is unchanged.
  2. Add a hostCertGenerator setting to the host VS Code extension, backed by a new backend abstraction in shared (NativeBackend / DotnetBackend / selectBackend). auto prefers dotnet dev-certs on macOS when available (better keychain UX) and falls back to the in-process native generator everywhere else.
  3. Make the macOS dotnet dev-certs disk cache loadable. ASP.NET Core's MacOSCertificateManager.SaveCertificateCore writes its PFX using legacy pbeWithSHA1And3-KeyTripleDES-CBC (OID 1.2.840.113549.1.12.1.3) because certificate.Export(X509ContentType.Pfx) with no password defaults to 3DES on Unix. A narrow legacy-PBE handler (pkcs12LegacyPbe.ts) accepts that one OID; every other legacy PBE algorithm in the PKCS#12 family is still rejected.

Key Changes

Shared workspace extraction (src/shared/):

  • Platform stores: windowsStore.ts, macStore.ts, linuxStore.ts, plus the baseStore.ts candidate-classification layer.
  • Cert primitives: generator.ts, manager.ts, exporter.ts, loader.ts, pfx.ts.
  • Platform utilities: processUtil.ts (now with resolveSafeExecPath for Windows cwd-hijack defense), nssTrust.ts, types.ts.
  • Generic Logger / Localizer interfaces in shared; VS Code-specific wiring in loggerVscode.ts.

Backend abstraction (src/shared/src/backends/):

  • NativeBackend has two paths: noTrust: true writes only to outDir and never touches the platform store; noTrust: false drives CertManager end-to-end.
  • DotnetBackend shells dotnet dev-certs https --trust, then discovers the resulting cert from the host's .dotnet/corefx/cryptography/x509stores/my/ directory and exports it through our primitives. On Linux it also supplements with our NSS trust step (which dotnet dev-certs --trust doesn't touch).
  • selectBackend resolves auto / native / dotnet and surfaces availability errors with a setting-aware message.
  • Host extension's certProvider.provisionViaConfiguredBackend() honors the hostCertGenerator setting; the Linux NSS trust reporter is wired through every entry point so failures surface as VS Code toasts instead of silent fall-throughs. After out-of-process provisioning, the provider verifies the store cert against the manager's in-memory cert and reloads on mismatch so the served bundle can't pair stale key material with a fresh thumbprint.

macOS PFX format compatibility:

  • pkcs12LegacyPbe.ts implements RFC 7292 Appendix B's SHA-1 diversifier KDF and 3DES-CBC decrypt, restricted to OID 1.2.840.113549.1.12.1.3 (every other legacy PBE OID still falls through to rejection).
  • Handles both empty-password encodings (.NET's bare-empty vs OpenSSL's UTF-16BE null-terminated), with structural (BER SEQUENCE) validation of each candidate plaintext so a padding false-accept can't commit the wrong convention.
  • New dotnetMacosCache.integration.test.ts (macOS + dotnet only; self-skips elsewhere) drives dotnet dev-certs https, loads the resulting aspnetcore-localhost-*.pfx through parsePfx, and pins the on-disk PBE algorithm OID. When aspnetcore eventually switches the macOS writer to ExportPkcs12(PbeParameters(Aes256Cbc, …)) and emits PBES2 instead, the assertion fires with an actionable error pointing the maintainer at pkcs12LegacyPbe.ts's removal checklist — that's the only way we'll know the workaround has aged out without manually monitoring upstream.

Platform-store hardening (independent of the above, surfaced by review):

  • resolveSafeExecPath defends against the Windows CreateProcess cwd-first executable lookup behavior — runProcess now resolves commands through PATH before spawning, and returns exit code 127 instead of falling through to an unsafe spawn when resolution fails.
  • windowsStore.ts only caches the positive result of probing for pwsh; a negative result re-probes on every call so deferred PATH updates work.
  • macStore.removeCertificates calls untrust before delete with the proper cert file (was passing the keychain path as a positional and using -d incorrectly).
  • linuxStore.isTrusted requires both authoritative trust surfaces (OpenSSL PEM and .NET Root store PFX) so a partially-cleared trust state triggers the idempotent repair path instead of reporting healthy.

Devcontainer feature install scripts:

  • install.sh delivers scripts/setup-cert.sh to /usr/local/bin/devcontainer-dev-certs-install (sysroot-aware so the hermetic test never touches the host) and exports DEVCONTAINER_DEV_CERTS_* path-hint variables.
  • setup-cert.sh honors those env vars, gains a --bundle-json multi-cert form and read-only --doctor diagnostics (unparseable store files fail the doctor rather than passing silently). Shellcheck cleanups throughout.
  • Deliberately undocumented. Per docs: restructure READMEs so the happy path leads and advanced usage is findable #80, the non-VS Code path is effectively unsupported — setup-cert.sh is too bare-bones to stand alone, and documenting it would promise something the project doesn't back. The script, schema/bundle.schema.json, and examples/manual-setup/ ship in the tree for a potential future non-VS Code effort but are not user-facing documentation surfaces. (JetBrains support is tracked separately in JetBrains support: single local plugin via EelApi (Kotlin/BouncyCastle port) #81.)

CI / build:

  • Adds a macos-dotnet-dev-certs-cache job that exercises the new integration test against real dotnet dev-certs output on macos-latest. The login keychain is unlocked first so dotnet dev-certs https can write to CurrentUser\My.
  • Workspaces / esbuild / vitest config updated for the new shared package.

Verifying Locally

  • npm ci && npm run typecheck -w src/shared -w src/vscode-ui-extension -w src/vscode-workspace-extension — type-clean.
  • npm test -w src/vscode-ui-extension — 258 passed / 11 skipped (skips are the macOS-only and Windows-only integration tests).
  • npm test -w src/vscode-workspace-extension — 79 passed / 1 skipped.
  • npm run lint, node test/validate-feature.mjs, node test/install-sh.test.mjs — clean.

https://claude.ai/code/session_014vzhpQL2M5H6ah9RVHPbxA

claude added 30 commits May 25, 2026 20:43
The setup-cert.sh fallback the README points at for JetBrains / CLI users
never actually reached the running container - install.sh ran from a temp
build mount that gets discarded after the layer commit. Copy it to
/usr/local/bin/devcontainer-dev-certs-install so the documented fallback
is actually invokable.

Also surface the canonical store and trust-dir paths (plus the installer
location) as DEVCONTAINER_DEV_CERTS_* env vars in both /etc/profile.d
and /etc/environment, so manual integrations don't have to hardcode
~/.dotnet/corefx/... and friends. New opt-in installFallbackTools option
installs openssl + jq (the script's runtime prerequisites) for images
that don't already provide them.
Non-VS Code users have no equivalent of the workspace extension's
warnOnStaleDevCerts notifications or its silent post-install verification.
When TLS misbehaves, the only way to find out why today is to grep the
source.

The new --doctor mode runs read-only checks across the trust
infrastructure: prerequisite presence, trust-directory existence and
writability, SSL_CERT_DIR / DOTNET_GENERATE_ASPNET_CERTIFICATE state,
cert listings (subject / notAfter / SHA1) for both X509Store dirs, hash
symlink integrity in the OpenSSL trust dir, and expired-cert / multiple-
dev-cert detection. Exit code is non-zero only when something is
demonstrably broken, so it's safe to wire into postStartCommand or CI
checks.
A single "Limitations" bullet pointing at setup-cert.sh isn't enough for
a JetBrains / Vim / CLI user to actually drive the fallback. The bundle
JSON schema lived only in a script comment, there was no end-to-end
walkthrough, and the lifecycle question (when to re-run the installer)
was unaddressed.

Three coordinated pieces:

- schema/bundle.schema.json — a JSON Schema for the bundle format,
  consumable via $schema by any editor that honors it (JetBrains, vim
  + LSP, plain VS Code). Documents every field and constraint in a
  machine-readable form.

- examples/manual-setup/ — a runnable example: devcontainer.json
  snippet with the postStartCommand pattern, a bundle.json referencing
  the schema, and a README walking through dotnet dev-certs export →
  thumbprint computation → verification.

- README.md "Manual / non-VS Code use" section — pulls the bundle
  schema, lifecycle pattern, env-var hints, and --doctor invocation
  into one place; updates the feature-options table with the new
  installFallbackTools knob; replaces the dismissive Limitations bullet
  with an honest description of what's actually supported and what's
  still on the user.
Phase 1a of carving a reusable host-side cert library out of the UI
extension so a future host CLI (and an eventual non-VS Code distribution
path) can consume the same code. Continues the existing
`@devcontainer-dev-certs/shared` re-export pattern already in use for
loader / pfx / properties / cert-types.

`src/shared/src/cert/exporter.ts` and `src/shared/src/cert/generator.ts`
now hold the canonical implementations; the previous UI-extension copies
become thin re-export shims so existing `./cert/exporter` /
`./cert/generator` imports across the extension and its 18-file test
suite resolve unchanged.

No behavior change: 207 tests, type-check, and full-repo lint all pass.
Phase 1b of carving the host-side cert library out of the UI extension.
Moves `CertManager`, `BaseCertificateStore`, the per-OS stores
(Linux / Mac / Windows), `nssTrust`, and `processUtil` from
`src/vscode-ui-extension/src/{cert,platform}/` into
`src/shared/src/{cert,platform}/`. The UI extension keeps thin
re-export shims so existing import paths across the extension and its
test suite resolve without a rename.

`baseStore` / `macStore` / `windowsStore` previously imported
`vscode.l10n.t` directly for skip-log and multi-candidate-warning
strings — that's the only thing keeping the platform layer pinned to
the extension host. To break it, the move introduces a `Localizer`
callback (signature-compatible with `vscode.l10n.t`) plumbed through
`BaseStoreOptions` / `CertManagerOptions`. The extension wires up
`vscode.l10n.t` at the `CertManager` construction site in
`extension.ts`; non-VS-Code consumers (a future host CLI, scripts) get
an identity localizer that performs the same `{0}` placeholder
substitution `vscode.l10n.t` produces in the no-translation-loaded
case, so log output stays identical.

Test mocks for `runProcess` / `trustInNss` / `createPlatformStore`
previously targeted the extension's local `../src/platform/*` paths.
After the move the implementations import from the shared internal
modules; the mock targets have to follow the import the
implementation actually uses, so the affected tests now mock the
`@devcontainer-dev-certs/shared/src/platform/*` subpaths. No
production-code behavior change: 207 UI tests, 79 workspace tests,
type-check, esbuild bundle, and full-repo lint all pass.
Phase 2 of carving the host-side cert toolkit out of the UI extension.
Introduces a new `@devcontainer-dev-certs/cli` workspace producing a
`ddc` binary that wraps the same shared cert / platform layer the VS
Code host extension uses, so users on JetBrains / Vim / raw CLI / CI
get a first-class generation and bundle-emission flow without needing
VS Code to be involved.

Commands (commander-driven):

- `ddc generate` — pick a backend (auto / native / dotnet, with auto
  preferring dotnet on macOS when available for its signed-binary
  keychain-trust UX, otherwise native everywhere), produce a PFX +
  PEM + key in the out-dir, run the host trust step unless
  `--no-trust` is passed, and emit `bundle.json` with paths
  rewritten to a configurable container-mount target
  (default `/host-dev-certs`) so the in-container installer can
  read them directly.
- `ddc inspect <cert-path>` — load a PFX or PEM and print subject CN,
  thumbprints (SHA-1 + SHA-256), validity window, SAN entries
  (flagged non-local), ASP.NET dev-cert OID + version byte, and
  validation warnings. Text by default; `--json` switches to a
  scripting-friendly schema.
- `ddc bundle <cert-path>` — produce a bundle.json referencing an
  already-existing cert file. Auto-discovers sibling `.pem` / `.key`
  / `.pfx` files by naming convention.
- `ddc trust <cert-path>` — add an existing cert to the host's OS
  trust store via the shared platform layer, with an
  isCertTrusted short-circuit so repeated invocations don't re-prompt.
- `ddc doctor` — read-only diagnostics: backend availability,
  `--backend auto` resolution, out-dir / bundle.json presence, host
  platform-store state, and (on Linux) `openssl` / `certutil`
  presence on PATH.

The Aspire pass-through backend was deferred per the phasing plan;
`--backend aspire` is rejected with a clear "not implemented yet"
error so the option stays visible in help text for when it lands.

`shared/src/logger.ts` was vscode-free'd to make the CLI bundleable —
the previous `initLogger` (which calls `vscode.window.createOutput-
Channel`) was extracted into a new `loggerVscode.ts` submodule that
only the extensions import; the shared logger now exposes
`setLogSink`/`LogSink` so the CLI can plug a stderr-backed sink in
via `--verbose`. Extensions and tests updated to import `initLogger`
from the submodule path.

All checks pass: 207 UI tests, 79 workspace tests, 17 new CLI tests,
type-check across all four workspaces, esbuild bundles for the
extensions and the CLI, full-repo lint. Smoke-tested `ddc generate /
inspect / bundle / doctor` end-to-end against an isolated out-dir
and confirmed the produced bundle.json matches the existing
`schema/bundle.schema.json`.
Phase 5 of the non-VS-Code carveout. The dotnet-pass-through backend
that landed in `ddc` is now also available to the VS Code host
extension, controlled by a new
`devcontainerDevCerts.hostCertGenerator` setting with three values:

- `auto` (default): on macOS, prefer dotnet when the dotnet CLI is on
  PATH (better keychain-trust UX via a signed binary); fall back to
  native everywhere else. Identical resolution to `ddc --backend auto`.
- `native`: always use the in-tree cert primitives + CertManager. No
  dotnet SDK required. Identical to the historical extension behavior.
- `dotnet`: always shell out to `dotnet dev-certs https`. Requires the
  dotnet SDK on PATH; trust is fired by the dotnet CLI rather than the
  extension's own binary.

To make this work without duplicating code between `ddc` and the host
extension, the backend abstraction
(`Backend` / `NativeBackend` / `DotnetBackend` / `selectBackend` /
`describeAutoBackend`) was promoted from `src/cli/src/backends/` into
`src/shared/src/backends/` and exported from the shared barrel. The
CLI now imports the same backends the extension does; the CLI's
removed `backends/` directory is gone entirely (the local
`generateAndWriteFiles` lower-level helper was not used anywhere yet
and was dropped along with it).

CertProvider wires the setting through a new
`provisionViaConfiguredBackend()` private method. When the resolved
backend is native (either explicitly `native` or `auto` falling back
on a non-macOS host / macOS without dotnet), CertProvider keeps using
the in-process CertManager — preserving its `vscode.l10n.t` locale
injection and Linux NSS trust reporter wiring that the shared
NativeBackend's bare CertManager construction would lose. When the
resolved backend is dotnet, CertProvider creates a per-provisioning
tmp dir, delegates `generate({outDir, noTrust: false})` to the
backend, and then discards the dir — the side effect we care about
is that the cert lands in the OS platform store, which the
subsequent existing `certManager.exportCert(...)` flow reads
identically regardless of which backend put it there.

Five new tests in `tests/hostCertGenerator.test.ts` cover the
dispatch logic with a partial-mocked shared module: native short-
circuit, auto-resolves-to-native defers to manager, dotnet routes
through backend.generate, errors from selectBackend propagate, and
the per-provisioning tmp dir is cleaned up on the throw path.

Aspire backend remains intentionally out of scope (phase 4 was
deferred per the original plan); `selectBackend('aspire')` continues
to reject with a clear "not implemented yet" error so the option
stays visible without bait-and-switch ergonomics.

All checks pass: 212 UI tests (+5 new), 79 workspace tests, 17 CLI
tests, type-check across all four workspaces, esbuild bundles
correctly, full-repo lint.
Phase 4 stays deferred but the placeholder was leaking out of internal
notes into user-facing surfaces — `--backend aspire` showed up in
`ddc generate --help` and `BackendKind` listed it as a valid choice
in the shared type. Both did nothing except throw "not implemented
yet," which is a strictly worse UX than not advertising the option at
all (users tab-complete it, try it, get an error, file an issue).

Removed:
- `"aspire"` from the `BackendKind` union in
  `shared/src/backends/types.ts`.
- The rejection branch in `selectBackend()` — unreachable once the
  type narrows.
- `"aspire"` from commander's `--backend` choice list in the CLI.
- The CLI test asserting the rejection behavior; with the option no
  longer in the union, the test wouldn't compile anyway.

The VS Code setting's enum never included `aspire` (the host-extension
landing in phase 5 was already conservative), so no `package.json`
change was needed.

When an aspire-aware backend lands, add it back the same way the
dotnet backend was wired up — type union entry + `select.ts` branch +
commander choice + setting enum + tests.

16 CLI tests (-1), 212 UI tests, type-check + lint clean.
`child_process.spawn` (and `execFile`, which we use) ultimately calls
`CreateProcess` on Windows. When given a bare command name like
`"dotnet"`, `CreateProcess` searches the application directory, the
current working directory, the system directories, and then PATH —
in that order. A malicious `dotnet.exe` dropped into a workspace by
a compromised dev-container repo would hijack our shell-outs before
the real PATH-resolved binary is ever consulted.

The dotnet backend (`runProcess("dotnet", ...)`), the Windows store
(`runProcess("certutil.exe", ...)`, `runProcess("pwsh", ...)`), and
any future shell-outs all go through `runProcess`. Fixing it in the
single chokepoint closes the gap uniformly — there's no longer a
spawn site in the codebase that hands a bare name to `execFile` on
Windows.

The new `resolveSafeExecPath(command, options)` helper:

- Returns the command unchanged on non-Windows hosts. `execvp`-style
  syscalls on Linux / macOS never consult `cwd`, so the cwd-first
  hijack vector doesn't exist there and we save the disk walk.
- Passes through absolute paths and any path containing a separator
  verbatim — the caller has expressed explicit intent.
- On Windows, scans PATH directories (using `path.win32` semantics
  so tests can drive the Windows branch from a Linux host) and
  appends each PATHEXT entry to the bare name. Returns the first
  absolute hit, or `null` if nothing matches.
- Filters relative PATH entries (`.`, `bin`, etc.) on Windows. A user
  who's added them re-introduces the exact hijack vector we're
  defending against; filtering costs nothing for normal setups and
  provides defense in depth for hostile ones.
- Honors PATHEXT case-insensitively and short-circuits when the
  caller already supplied a known extension (so `certutil.exe`
  doesn't get probed as `certutil.exe.cmd`, etc.).

`runProcess` wires the resolver into its first step. When the
resolver returns `null` on Windows (command not on PATH), we
synthesize a `ProcessResult { exitCode: 127, stderr: "command not
found on PATH: ..." }` rather than falling through to the unsafe
spawn. That gives callers a more actionable error than the raw
ENOENT from execFile would have produced, and it's consistent with
Unix exit-127 semantics.

Ten focused tests in `tests/resolveSafeExecPath.test.ts` cover the
matrix: non-Windows no-op, absolute / separator pass-through, PATH +
PATHEXT scan, no-match → null, no double-extension, relative entries
skipped, case-insensitive PATHEXT, PATH order preserved. All driven
synthetically via the `platform` / `searchPath` / `fileExists`
options so the suite runs identically on Linux / macOS / Windows.

CLI smoke-tested end-to-end (`generate --backend native --no-trust`,
`doctor`); existing 222 UI + 79 workspace + 16 CLI tests still green;
lint clean.
`ddc` has been in the tree for a couple of commits but nothing pointed
at it from the docs, so non-VS-Code users following
`examples/manual-setup/README.md` would still walk through the
`dotnet dev-certs https` + hand-compute-thumbprint + hand-edit-JSON
ritual that `ddc generate` now collapses into one command.

- `src/cli/README.md` (new): the canonical CLI reference. Covers what
  ddc is, the build-from-source install path (no published binary
  yet — the README is honest about this), each of the five commands
  with their full flag tables and short rationales, an explanation of
  the backend selection, and a Limitations section that calls out
  three real footguns (no published binary, native backend writes to
  the platform store even with `--no-trust`, no reverse-sync).
- `examples/manual-setup/README.md`: leads with `ddc generate` and
  keeps the existing `dotnet dev-certs` + `openssl` walkthrough as a
  fallback path. Cross-references the CLI README for install and
  command details rather than duplicating them. Adds `ddc doctor` to
  the verification section.
- Root `README.md`: brief subsection under "Manual / non-VS Code use"
  pointing at `src/cli/README.md` so people who land on the root doc
  discover the CLI without having to click through to the example
  first.

Cross-checked every technical claim (cert key size, SAN list, default
out-dir, default container mount, `--name` default, `ddc trust`
already-trusted short-circuit) against the actual source before
committing.

No code changes; lint / type-check / tests unaffected.
Before this commit, `ddc generate --no-trust` still wrote the cert
into `~/.dotnet/corefx/cryptography/x509stores/my/` (the .NET X509
store) because the native backend always drove `CertManager`, and
`CertManager.generate(false)` persists to the store as part of its
contract. That was a footgun: the typical `--no-trust` caller is
asking for "cert files I can bind-mount into a container" and doesn't
expect their host's .NET dev cert state to be mutated.

The native backend now has two code paths:

- `noTrust: true` generates the cert purely in memory via the shared
  `generateCertificate` primitive and writes only to `--out-dir`.
  `CertManager` is never instantiated, so the platform store stays
  untouched. The trade-off is that `--no-trust` invocations are no
  longer idempotent w.r.t. an existing store cert — but with no store
  residency that's the right semantics: the user has opted out of host
  state.
- `noTrust: false` keeps the existing `CertManager.trust()` flow.
  Writing to the .NET store on this path is the host-trust contract,
  not a side effect — it's where `dotnet dev-certs --check`, host-
  running Kestrel, and the VS Code host extension all look.

The dotnet backend's `--no-trust` semantics are necessarily different.
`dotnet dev-certs https` always persists the cert into the .NET store
regardless of `--trust`; we can't paper over that without
reimplementing what the dotnet binary does, which is the entire point
of using that backend. A comment on `DotnetBackend` calls this out so
callers with strict-isolation requirements know to pick `native`.

Four new tests in `tests/nativeBackend.test.ts` redirect `$HOME` to a
fresh tmpdir, run `generate({noTrust: true})`, and assert (a) the
cert files land in `outDir`, (b) the thumbprint round-trips through
the PFX reparse, (c) nothing was created under `$HOME` —
`fakeHome/.dotnet/...` doesn't exist and `fakeHome` itself stays empty
— and (d) successive invocations produce distinct certs (confirming
the path doesn't accidentally fall through to a store-based cache).

Smoke-tested end-to-end: `HOME=$tmp ddc generate --no-trust` writes
four files to `--out-dir` and leaves the redirected home empty.

CLI README's Limitations section was outdated by the fix; replaced
the bullet with a `--no-trust semantics` subsection explaining the
per-backend behavior so users know which backend honors strict
isolation.

226 UI tests (+4 new), 79 workspace, 16 CLI; lint + type-check clean.
Two distinct issues, one commit because the surface area is small and
they share the per-OS tool-check infrastructure from the resolver
hardening commit.

# ddc bundle — silent-broken-bundle warning

`ddc bundle ~/Downloads/foo.pfx --out-dir ~/.dev-certs` would produce
a `bundle.json` referencing `~/Downloads/foo.pfx` verbatim, because
the writer's containerize step only rewrites paths under `--out-dir`.
With only `~/.dev-certs` bind-mounted to `/host-dev-certs` in the
container, the in-container installer can't read the cert and the
bundle is silently broken.

The fix: after we've assembled the bundle entry but before writing
it, walk the cert-file paths and warn (stderr) on any that don't
live under `--out-dir`. The warning names the offending field and
path on every line so a user can act on it without `--verbose`, and
points them at the two ways to fix it (copy into `--out-dir`, or
arrange additional mounts). No erroring or auto-copying — both are
legitimate setups in unusual layouts and a warning is the right
default.

# ddc doctor — macOS and Windows parity

Before this commit only the Linux branch had platform-specific tool
checks (`openssl`, `certutil`). macOS and Windows users running
`ddc doctor` got the universal checks (dotnet, auto backend, store
state) but nothing about whether the tools their native backend
actually depends on were on PATH.

The new structure factors the per-OS checks into
`checkLinuxTools()` / `checkMacosTools()` / `checkWindowsTools()`,
dispatched by `process.platform`:

- Linux (unchanged): `openssl`, `certutil` (NSS browser trust)
- macOS (new): `security` (the keychain CLI macStore drives)
- Windows (new): `pwsh` *or* `powershell` (PowerShell 7+ preferred,
  5.1 accepted as a fallback with a note) and `certutil.exe` (Windows
  trust store)

Windows uses `resolveSafeExecPath` rather than shelling to `where.exe`
— same lookup `runProcess` uses internally, no spawn overhead, no risk
of `where.exe` itself being hijacked from cwd. Linux / macOS keep
`which` since `resolveSafeExecPath` is a no-op there.

# Tests

- `tests/bundle.test.ts` (4 tests): asserts no-warn when paths are
  under `--out-dir`, warn-with-paths when they aren't, every
  out-of-dir field gets listed, and the exact-out-dir-base case
  doesn't trip the check (regression guard).
- `tests/doctor.test.ts` (10 tests): all three OS branches driven
  from a Linux host via `stubPlatform` + a vi.mocked shared module.
  Covers the happy path on each OS, each missing-tool warning, the
  PowerShell 7-vs-5.1 fallback note, and a regression guard that
  Windows never calls `which`.

# Sweep

Bundle warning smoke-tested end-to-end: `ddc bundle` against a cert
in one tmpdir with --out-dir in another emits the warning to stderr
naming all three artifact fields and both candidate fix paths.
Doctor smoke-tested on Linux (existing checks unchanged).

226 UI tests, 79 workspace tests, 30 CLI tests (+14 new); lint +
type-check clean.

# CLI README

Updated the `ddc bundle` and `ddc doctor` sections to describe the
new behaviors — the warning condition and what to do about it, the
per-OS tool matrix.
The unrelated `ddc` package already exists on npm (PowerShell / Vim
ecosystem), and even a scoped publish wouldn't sidestep the bin-name
collision in users' `node_modules/.bin/`. Renaming the binary now is
strictly easier than reckoning with the collision after the first
release.

# Binary rename

`ddc` → `dcdc` (DevContainer Dev Certs — short, pronounceable,
unlikely to collide). Touched every callsite: command name in
commander, error prefix, all five command docstrings, the shared
layer's references to `dcdc doctor` / `dcdc generate --no-trust`,
tmp-dir prefixes in tests, every code block in the three READMEs.

# Package metadata for npm publish

`src/cli/package.json` is now publishable:

- Dropped `private: true`.
- `bin: { "dcdc": "./dist/dcdc.js" }`.
- `files: ["dist/dcdc.js", "LICENSE", "README.md"]` — only the
  bundled binary ships, not the source. `npm pack --dry-run` reports
  a 184 kB tarball (788 kB unpacked) with the production build.
- All runtime deps moved to `devDependencies` because esbuild bundles
  everything into `dist/dcdc.js`. The published package has zero
  install-time dependencies; users pull the binary and that's it.
- `publishConfig: { access: "public", provenance: true }` — provenance
  attestations match the SLSA pattern already used for VSIXes / OCI
  artifacts elsewhere in this repo (they're emitted automatically by
  npm publish when running under GitHub Actions OIDC; locally they're
  silently skipped, so the field is safe to set unconditionally).
- `engines.node: ">=18"`, `keywords`, full `repository.directory`
  pointer so npm's "GitHub" link lands on the right subtree.
- `prepublishOnly: "node esbuild.mjs --production"` so any publish
  attempt — local or CI — always produces a minified bundle, never
  ships a dev build by accident.

LICENSE copied from the repo root into `src/cli/` so the published
tarball carries its own copy (npm doesn't follow workspace links).

# CI

`build-extensions.yml` now also builds the CLI, runs its test suite,
and runs `npm pack --dry-run -w src/cli` on every PR and main push.
The pack dry-run catches metadata regressions (missing files, bad
bin paths, broken LICENSE/README references) before a release would
discover them. The CLI has no Windows-specific integration tests —
`resolveSafeExecPath` mocks the platform — so we don't need a
separate Windows runner for it.

# README updates

- `src/cli/README.md`: install section rewritten to `npm install -g
  @devcontainer-dev-certs/cli`, with `npx` one-off as an alternative
  and "build from source" as a development fallback. Calls out the
  `ddc` collision and explains the rename.
- `examples/manual-setup/README.md`: install instructions for `dcdc`
  now point at the npm package instead of "build from source."
- Root `README.md`: every `ddc` mention renamed.

# Sweep

226 UI tests, 79 workspace tests, 30 CLI tests; type-check + lint
clean across all four workspaces; `npm pack --dry-run` happy;
end-to-end smoke (`dcdc generate --no-trust` + `dcdc doctor`) green
under the new name.
There was never a `ddc` release; mentioning the rename in user-facing
docs gives weight to a name that never shipped. Replaced the
collision paragraph with a one-line lead-in for the `npx` example
that immediately follows.
CLI releases now ride the same `release: [released]` trigger that
already publishes the feature to GHCR and uploads VSIXes to the
GitHub Release. Same version across every component, single source
of truth for "what shipped when," matching the convention `release-
feature.yml` already established.

# `bump-version.yml`

Adds `src/cli/package.json` to the bumped-in-lockstep list so the
post-release dev-version PR keeps every component at the same
prerelease tag.

# `release-feature.yml`

- `validate-release` now also asserts `src/cli/package.json`'s
  version matches the GitHub Release tag. A mismatch (e.g. forgot
  to merge the bump PR) fails the release before any publish runs.
- New `publish-cli` job, runs in parallel with `attest-vsix` after
  the shared build. Steps:
    1. setup-node with `registry-url: https://registry.npmjs.org`
       so `npm publish` knows where to talk.
    2. Pin npm to ^11.5.0. Node 22 ships npm 10.x; OIDC trusted
       publishing landed as a stable feature in npm 11.5, so we
       upgrade explicitly rather than depend on Node-bundled drift.
    3. Download the `cli-tarball` artifact produced by the build
       reusable workflow.
    4. `npm publish <tarball> --provenance --access public`.
       Authentication is via npm's OIDC trusted publisher policy —
       no `NPM_TOKEN` is stored anywhere. The runner's OIDC token
       (minted from `id-token: write`) is exchanged for a short-
       lived publish credential, and `--provenance` causes the
       same token to sign a SLSA attestation that gets stored on
       npm's registry alongside the package.
    5. `actions/attest-build-provenance` against the tarball file —
       parallel to the VSIX attestation step, stores a sigstore
       bundle on GitHub's attestation store. This is independent
       of the npm-side provenance from step 4, so users can verify
       the tarball with either `gh attestation verify` or npm's
       provenance UI.
    6. `gh release upload` attaches the tarball to the GitHub
       Release alongside the VSIXes.

  Job permissions: `contents: write` (release upload), `id-token:
  write` (OIDC for both npm publish AND attest-build-provenance),
  `attestations: write` (sigstore bundle storage). Runs in the
  `release` environment so it inherits the same protections that
  gate `publish-feature` and `attest-vsix`.

# `build-extensions.yml`

- Input renamed: `upload-vsix` → `upload-artifacts`. The flag now
  controls both VSIX and CLI tarball uploads, and the old name
  would lie about that.
- New step: `Package CLI tarball` runs `npm pack -w src/cli
  --pack-destination src/cli/dist` after the prod build. The
  tarball it produces is the exact bytes the `publish-cli` job
  hands to npm — no rebuild between build and publish, so what
  was tested in CI is what ships to users.
- New step: `Upload CLI tarball` adds it as an artifact named
  `cli-tarball` for downstream jobs to download.

# `ci.yml`

Input rename. CI runs (which produce VSIX + CLI tarball as build
artifacts on every main push) get the same upload behavior as
before plus the new CLI tarball.

# One-time setup required before the first release

npmjs.com trusted publisher config:
  Org settings → Packages → Trusted publishers → Add publisher
    - Publisher: GitHub Actions
    - Org: dnegstad
    - Repo: devcontainer-dev-certs
    - Workflow path: .github/workflows/release-feature.yml
    - Environment: release

Without this entry, the `npm publish` step will fail with an auth
error on the first release attempt. After it's configured, all
future releases authenticate automatically.

# Tests

YAML parses cleanly for all 5 workflow files. CLI tests + lint
unaffected (no code changes).
The release pipeline is repo-wide (feature + two VSIXes + CLI on one
trigger), but the CLI piece has a chicken-and-egg bootstrap because
npm trusted publishing requires the package to exist before the
trust policy can be configured. Documenting the procedure in chat
history isn't durable — future-maintainer-me (or a successor) will
hit this when the next greenfield package is added to the org.

Two sections:

- **Normal release procedure**: cut a GitHub Release with the right
  tag, the workflow does everything. Concise — the workflow is the
  source of truth, the doc just explains the human inputs.
- **One-time bootstrap**: publish a content-stub `0.0.0-bootstrap`
  prerelease from a local terminal, deprecate it immediately,
  configure the trust policy now that the package exists, then let
  CI publish the first real version end-to-end with full provenance.
  The stub is invisible to default range queries (npm semver
  excludes prereleases by default) and the deprecate message points
  any explicit pinners at `@latest`.

Uses `npm version 0.0.0-bootstrap --no-git-tag-version` instead of
the original jq-based version-mangling — the npm CLI has the right
tool built in and reaches for fewer external utilities.

Navigation path for the trust policy config corrected from my
earlier write-up: it's per-package (npm.com/package/<name> →
Settings → Trusted Publisher), not org-wide. Also calls out the
post-May-2026 requirement to explicitly select allowed actions when
adding the policy.
`dotnet dev-certs https --format Pfx --no-password` errors out on every
currently-shipping .NET SDK (6/7/8/9) with "Unrecognized command or
argument '--no-password'" — the flag was always PEM-only and the open
feature request to extend it to PFX (dotnet/sdk#48482) is in Backlog.
The dotnet backend has therefore been broken since it landed: any
caller picking `--backend dotnet` (or `auto` on macOS with dotnet
available, or `hostCertGenerator: "dotnet"` in the extension) would
trip the PFX-export pass on the first invocation, with no PFX
written and no host trust performed.

The fix is structural rather than tactical — we stop using dotnet's
`--export-path` entirely:

  1. `dotnet dev-certs https [--trust]` — no export, just generate
     (if absent) and trust. The cert lands in the .NET store at the
     conventional location for the platform.
  2. `createPlatformStore().findExistingDevCert()` — the same hook
     the rest of the codebase uses to discover the cert dotnet
     just deposited.
  3. `exportPfx` + `exportPem` from our own primitives — write the
     files into `outDir` under our naming conventions.

This also fixes a secondary issue: `dotnet dev-certs --format PEM
--export-path foo.pem` writes `foo.pem` + `foo.pem.key`, but our
bundle / inspect sibling-discovery probes `${stem}.key` (i.e.
`foo.key`). With the rework, the dotnet backend emits
`aspnetcore-dev.key` like the native backend does, so the in-container
installer + downstream `dcdc bundle` / `dcdc inspect` all see the key
where they expect it.

NSS on Linux is supplemented through the same backend. `dotnet
dev-certs --trust` on Linux only writes the OpenSSL trust dir + the
.NET Root store; the Firefox / Chromium NSS DBs are missed. The
dotnet backend now invokes `trustInNss(pemPath)` after a successful
trust step on Linux, with the new optional `linuxNssTrustReporter`
flowing through `GenerateOptions` so callers (CLI, extension) can
surface the outcome.

`NativeBackend.generateAndTrust` now accepts and threads the same
reporter into `CertManager`'s options — previously it constructed a
bare manager that silently dropped NSS reporting. CLI `dcdc generate`
defaults to a stderr-logging reporter via the new
`src/cli/src/nssReporter.ts`. The host extension already had a toast
reporter wired to `CertManager`; this change just lets the CLI's
direct-NativeBackend path inherit equivalent surfacing.

Eight new tests in `tests/dotnetBackend.test.ts` lock in the contract:
no `--no-password` / `--format` / `--export-path` flags ever, naming
matches the native backend, NSS is invoked on Linux only when trust
is requested, non-zero dotnet exits and empty-store outcomes both
produce actionable errors.
Linux NSS browser-trust failures were passing silently in three
places, all variations on the same dropped-callback bug:

- `dcdc trust <cert>` constructed `createPlatformStore()` with no
  `linuxNssTrustReporter`, so when the user's `certutil` was missing
  or the NSS DB was locked, trust completed quietly without surfacing
  the gap. Users saw "Trust step complete." and then wondered why
  Firefox / Chromium still rejected the cert.

- `CertProvider.provisionViaConfiguredBackend`'s dotnet branch called
  `backend.generate({ noTrust: false })` without forwarding any NSS
  hook. The native branch went through `this.certManager.trust()`
  which had the host extension's toast reporter wired in; the dotnet
  branch silently lost it. Linux users with `hostCertGenerator:
  "dotnet"` got the same gap as the CLI surface above plus no
  toast-based recovery guidance.

- `NativeBackend.generateAndTrust` (the prior commit added the
  reporter to its options surface but the wiring also needed CLI
  callers to actually pass one).

Single-source-of-truth fix: the reporter is an explicit constructor
arg on `CertProvider`, the same one wired into the manager, so the
extension's `showBrowserTrustFailureGuidance` toast fires uniformly
regardless of which backend the user picked. On the CLI side,
`stderrNssTrustReporter` (new, `src/cli/src/nssReporter.ts`) prints
a clearly-flagged warning to stderr naming the cert and recommending
the libnss3-tools / nss-tools package — wired into both `dcdc
generate` and `dcdc trust`.

One new test in `tests/hostCertGenerator.test.ts` round-trips a
sentinel reporter through CertProvider → dotnet backend so a future
refactor that silently drops the wiring fails the suite instead of
silently breaking the Linux UX again.
Two unrelated CLI ergonomics issues, batched because they share the
same test scaffolding.

# generate: --no-trust didn't propagate to trustInContainer

`dcdc generate --no-trust` suppressed the host-side trust step but
emitted `trustInContainer: true` in the bundle.json regardless. A
user passing `--no-trust` to get files-only output, then committing
the bundle to a repo or copying it to a CI box, would have the
host opt-out honored AND silently reversed inside the container —
directly contradicting the user's intent at exactly the moment
they'd assume the configuration was symmetric.

Fix: bundle `trustInContainer` now mirrors `!noTrust`. A user who
genuinely wants host-untrust + container-trust can still construct
that bundle via `dcdc bundle` after the fact, which is explicit
about it.

# bundle / inspect: sibling-file probes missed valid combinations

Two asymmetries:

- For a `.pem` cert path, the sibling-PFX probe only checked
  `${stem}.pfx`. Openssl writes `.p12` by default (`openssl pkcs12
  -export -out cert.p12`), so a user with a cert.pem + cert.p12 pair
  got `pfxPath: null` in the bundle and the in-container installer
  had no PFX for Kestrel.

- The sibling-key probe only checked `${stem}.key`. `dotnet
  dev-certs --format PEM --export-path foo.pem` writes
  `foo.pem.key` (filename-suffixed, not stem-based). A user running
  `dcdc bundle foo.pem` against dotnet's native naming got
  `pemKeyPath: null` despite a valid sibling key existing, and
  `dcdc inspect` reported `hasPrivateKey: false` for the same
  inputs.

Both fixes use a shared `findSiblingKey` helper that probes both
conventions in order, and bundle's PFX probe now iterates both
`.pfx` and `.p12`. Stem-based naming wins when both exist (matches
the more common case + our own exporter).

Six new tests cover the trustInContainer propagation (both
directions) and the new sibling-file discovery (p12 acceptance,
dotnet `.pem.key` discovery, stem-form preference when both exist).
`install.sh` painstakingly exports `DEVCONTAINER_DEV_CERTS_*_DIR`
into login shells (via `/etc/profile.d`) and PAM sessions (via
`/etc/environment`) so non-VS-Code users get the canonical paths
without having to know where the feature decided to chown things.
`setup-cert.sh` then completely ignored those env vars and
re-derived the paths from `_REMOTE_USER`, which is only set during
the feature-build step — NOT in the runtime shell where the
fallback installer actually runs.

The net effect: a JetBrains / SSH user `developer` running
`devcontainer-dev-certs-install --doctor` would have the script
probe `/home/vscode/.dotnet/...` (the build-time _REMOTE_USER
default) instead of `/home/developer/.dotnet/...` (where install.sh
actually chowned things to). Doctor reported failures that didn't
exist; `--bundle-json` mode wrote certs the developer's session
couldn't read. The fallback installer was effectively broken for
any image whose remote user isn't vscode — i.e. exactly the
JetBrains / Vim / raw-CLI audience this branch's manual-setup
example was meant to serve.

Fix is three lines of shell — the env vars take precedence; the
`REMOTE_USER_HOME` fallback now also honors `$HOME` (the current
shell's user) before falling back to `/home/${REMOTE_USER}`.
Legacy path resolution stays available for anyone invoking the
script outside a login shell or PAM session.
The cleanup path was calling
  security remove-trusted-cert -d <keychain-path>
which is wrong in three independent ways:

1. The positional must be a CERT FILE (DER or PEM), not a keychain
   path. Passing the keychain DB caused `security` to either error
   out silently (exit code never checked) or treat the keychain as
   if it were a cert.
2. `-d` is the admin-trust-domain flag. Our `add-trusted-cert` call
   uses USER trust domain (no `-d`), so removing with `-d` looks in
   the wrong TrustSettings.plist and our entries are never matched
   even if the rest of the syntax were right.
3. No thumbprint / cert-identifier was supplied, so even if the
   positional and domain were both fixed the call would be ambiguous.

The result: every time a user ran "Remove dev certificates" on macOS,
the `.pfx` files and keychain entries got deleted but trust-settings
entries for the now-deleted certs were left dangling. A subsequent
regenerate could then trip macOS's duplicate-trust-settings handling
in confusing ways.

Fix is structural: each PFX we manage gets handled top-to-bottom in
the correct order — untrust (with the cert file as the positional,
no `-d`), then `delete-certificate -Z <thumbprint> <keychain>`,
then `unlinkSync` the PFX. Unlink is last so a mid-cleanup
interruption leaves the file in place and the cleanup is restartable.

Five new tests in `tests/macStore.test.ts`:
- Untrust receives a temp cert file path (under os.tmpdir), no `-d`,
  no keychain positional.
- Untrust runs BEFORE delete-certificate.
- PFX is unlinked after keychain teardown.
- Regression guard: `remove-trusted-cert -d <keychain-path>` is
  never invoked.
- Multiple dev cert PFXes each get the full untrust + delete +
  unlink sequence independently.
- No-op cleanup when devCertsDir doesn't exist.
`resolvedPwsh` cached the negative result permanently. The first time
`getPowerShell()` couldn't find pwsh on PATH — which on Windows can
happen for a perfectly mundane reason: the user installed pwsh
AFTER the VS Code extension host started, and Windows installers
commonly defer PATH propagation to existing processes — the cache
pinned the running extension host to PowerShell 5.1 (`powershell`)
for the rest of the session. Every subsequent cert store operation
then ran the older, slower PS 5.1 instead of pwsh, with no
indication that anything was off until the user happened to reload
the window.

Fix is minimal: only cache the POSITIVE result (pwsh confirmed
working). When the probe doesn't find pwsh, return the powershell
fallback THIS time but don't pin — next call re-probes pwsh in
case it became available in the meantime. The probe cost is ~50ms
on Windows; happening once per cert op (a handful of times per
session) is well below where it would matter.
…ive check

Two cleanup-grade fixes from the code review, batched because they
touch the same native-backend path.

# native.ts: thumbprint from manager.check(), not a PFX reparse

`NativeBackend.generateAndTrust` was re-reading the just-exported
PFX via `loadPfx` solely to recover a thumbprint that
`manager.trust()` had already put into the platform store. The
comment justifying the reparse ("symmetric with the dotnet
backend's recovery step") went stale when the dotnet backend rework
stopped reparsing, leaving a 30-line round-trip — PKCS#12 parse +
disk read + null-check error path — for state derivable from
`(await manager.check()).thumbprint`.

The fix uses `check()` directly. `manager.trust()` guarantees a
trusted cert in the store on return, so the thumbprint is
guaranteed non-null; we keep one defensive check to surface a
broken contract rather than crash on a `.thumbprint` access.
loadPfx is no longer imported here.

# certProvider.ts: collapse the double native short-circuit

`provisionViaConfiguredBackend` checked `setting === "native"` AND
then `backend.kind === "native"` six lines apart, both routing to
the same `this.certManager.trust()` call. Since `selectBackend("native")`
always returns `kind: "native"`, the first check was fully subsumed
by the second; the comment claiming "selectBackend is bypassed
entirely" was the same staleness pattern as native.ts above —
true when it was written, false after the dotnet rework normalized
the dispatcher.

Now `selectBackend(setting)` runs unconditionally and the single
`backend.kind === "native"` arm covers explicit-native + auto-resolved-
to-native uniformly. The updated comment is honest about WHY we
still defer to `this.certManager` instead of `NativeBackend.generate`:
the bare manager in NativeBackend doesn't carry the extension's
`localize` (vscode.l10n.t) wiring. NSS reporter is no longer a
reason — that's threadable through GenerateOptions now.

The "uses certManager.trust() when 'native'" test had to update its
assertion (selectBackend is now called even on the native path)
and now also pins that backend.generate() does NOT run on the
native arm — same guarantee the old `not.toHaveBeenCalled` gave,
expressed through the new flow.
Both findings flagged duplication that would silently drift if
sibling-discovery or default paths change.

# findSiblingKey → shared/cert/loader

Identical copies lived in `src/cli/src/commands/bundle.ts` and
`src/cli/src/commands/inspect.ts`, probing `<stem>.key` (our
exporter + openssl) and `<filename>.key` (dotnet) in order. A
future change — new ed25519 key naming, an SDK convention shift —
would have required edits in two places, with `dcdc bundle` and
`dcdc inspect` reporting silently-different views of the same cert
directory if one were missed.

Promoted to `src/shared/src/cert/loader.ts` next to `loadPemPair`,
re-exported from the package barrel. Both CLI commands now import
the shared helper.

# CLI defaults → src/cli/src/defaults.ts

`DEFAULT_CONTAINER_MOUNT` ("/host-dev-certs") appeared in
`bundle.ts` and `generate.ts`; `DEFAULT_OUT_DIR` (~/.dev-certs) in
`generate.ts` and `doctor.ts`. Changing either default to support
a new mount layout or a different out-dir convention required
2-3 synchronized edits; missing one would surface as `dcdc generate`
writing to one location while `dcdc bundle` or `dcdc doctor`
inspected another — the kind of inconsistency that surfaces as a
user-visible misdiagnosis (doctor reports "out-dir does not exist"
because it's looking somewhere generate doesn't write).

Both defaults now live in `src/cli/src/defaults.ts` and are
imported by every command that needs them.

No behavior changes; lint + type-check + 36 CLI / 240 UI tests all
green.
`runDoctor` was calling `new DotnetBackend().isAvailable()` (spawn 1)
and then `describeAutoBackend()` (which on macOS spawned `dotnet
--version` again as spawn 2) in series — two ~200ms shellouts where
one suffices. The remaining checks (out-dir / bundle.json existence,
platform-store enumeration, per-platform tool presence) were also
awaited serially despite being data-independent: the platform-store
check needed PowerShell on Windows and could take a few hundred
milliseconds while the cheap fs.existsSync calls sat behind it.

Two fixes, one refactor:

1. `describeAutoBackend` now accepts an optional `dotnetAvailable`
   hint so callers that have already probed dotnet can skip the
   re-probe. Default behavior (no hint) is unchanged — the auto-
   backend resolution path used by `selectBackend("auto")` still
   probes lazily.

2. `runDoctor` probes dotnet once at the top, then runs four
   independent check groups (`checkBackends`, `checkOutDir`,
   `checkPlatformStore`, `checkPlatformTools`) under `Promise.all`.
   Each returns `Check[]`; the final composition preserves stable
   output order regardless of which Promise settles first.

The check-group split also pulled apart the inline body of
`runDoctor`, which had grown to ~100 lines of mixed concerns. Each
group is now ~15 lines, named after what it inspects, and testable
on its own (existing tests still pass — they assert on the rendered
output, not the call orchestration).

10 doctor tests + 36 CLI tests + 241 UI tests all green.
The same `stubPlatform` body lived in `cli/tests/select.test.ts`,
`cli/tests/doctor.test.ts`, and `ui-extension/tests/dotnetBackend.test.ts`,
with two of the three using a self-contained restore-callback pattern
and select.test.ts using a divergent outer-state pattern (originalPlatform
let-binding + before/afterEach hooks). Any change to how platform-stubbing
must work (a future Node tightening process.platform's descriptor, or a
new platform we want to detect) would have needed three edits with the
risk of one being missed — and the existing divergence in select.test.ts
was already a smaller version of that problem.

Two per-workspace helper files (`tests/_helpers.ts` in CLI and in the UI
extension) export a single `stubPlatform` that returns its restore
callback. The three test files import it; select.test.ts is also
converted to the restore-callback pattern so all three callsites look
identical now.

Cross-workspace duplication remains (no test-helpers package exists)
but in-workspace duplication is gone, which was the reviewer's specific
complaint. Adding such a package wouldn't pay for itself for one
~10-line function.

36 CLI + 241 UI tests still green; lint clean.
The code review flagged that `DotnetBackend.generate` on macOS reads
the cert dotnet writes to ~/.aspnet/dev-certs/https/aspnetcore-localhost-
{thumb}.pfx via our parsePfx, and parsePfx strictly rejects
non-PBES2 PKCS#12 encryption. The verifier's chain concluded
CONFIRMED based on a "Export(X509ContentType.Pfx) → UnixExportProvider
→ Windows3desPbe" claim, but on closer reading that chain
conflated the `--export-path` user-facing export with the macOS
disk-cache writer, and the no-password leg of the runtime-internal
path wasn't independently verified. The byte-level outcome of
`certificate.Export(X509ContentType.Pfx)` (no password) on macOS
in current SDKs is what actually decides whether DotnetBackend
works on its default-preferred platform.

Rather than hunting more remote citations, settle the question
empirically. New integration test:

- Self-skips unless `process.platform === "darwin"` AND dotnet >= 6
  on PATH, so Linux CI and local dev see it skipped.
- Sets HOME to a tmpdir so the disk cache lands somewhere we can
  audit without touching the developer's real ~/.aspnet/dev-certs/.
- Runs `dotnet dev-certs https` (no --trust — avoids the keychain
  trust-settings prompt that would block headless CI).
- Locates the aspnetcore-localhost-*.pfx in the redirected cache
  dir and feeds it to `loadPfx`.
- Asserts the load succeeds + returns a SHA-1 thumbprint + carries
  a private key. On failure, parsePfx's error message names the
  offending OID, so CI logs will tell us exactly which algorithm
  to handle if the assumption breaks. Diagnostic line is written
  to stderr regardless of pass/fail so the result is durable in
  workflow logs.

CI: new `macos-dotnet-dev-certs-cache` job on `macos-latest` parallel
to `windows-certificate-validation`. Same shape — checkout, Node 22,
npm ci, build UI extension, setup-dotnet 10.0.x, run the one
integration test. Runs on every PR and CI push so an aspnetcore
algorithm change would surface immediately.

Outcomes:
  - test green → finding 1 REFUTED; DotnetBackend's macOS path
    works as written and we close that review item.
  - test red → finding 1 CONFIRMED with the exact OID; we then
    pick the fix (accept the algorithm in parsePfx, or switch the
    backend to dotnet's PEM export which sidesteps the question
    entirely) with real data instead of assumed behavior.
…on-vscode-analysis-CLJg9

# Conflicts:
#	src/devcontainer-feature/src/devcontainer-dev-certs/install.sh
# 1. hostCertGenerator.test.ts: type the manager spies explicitly

Main added a `typecheck` step that runs `tsc --noEmit -p
tsconfig.lint.json` — and the lint tsconfig includes test files
that the per-workspace `tsconfig.json` excludes, so test-only
typecheck errors slip past local `npm run test` and surface in CI.

`ManagerSpyHandles.trustSpy: ReturnType<typeof vi.fn>` widens to
`Mock<Procedure | Constructable>` (callable OR newable). TS won't
let you invoke that without first narrowing which side of the
union you mean — both `void trustSpy()` at line 190 and `await
trustSpy()` at line 241 trip TS2348.

Type the spies as `Mock<() => Promise<void>>` / `Mock<() =>
Promise<unknown>>` — concrete callable signatures match the call
sites' shape. Same pattern the surrounding tests
(containerCertAccept, containerCertPush) already use.

# 2. macOS workflow: unlock the login keychain before dotnet dev-certs

The `macos-dotnet-dev-certs-cache` job failed at the `dotnet
dev-certs https` step with `"There was an error saving the HTTPS
developer certificate to the current user personal certificate
store."`. The error is keychain-side: macOS GitHub Actions runners
ship with the login keychain locked, and `dotnet dev-certs`
delegates the save to SecurityFramework which requires an unlocked
target keychain.

Add a step that runs `security unlock-keychain -p ''` against the
runner's login keychain before invoking dotnet. The empty password
is the documented GH Actions convention for the runner user.
`security set-keychain-settings` (no args) disables the auto-lock
timeout so a slow dotnet run can't have the keychain re-lock
mid-write.

This doesn't change the test's contract — we're still observing
what `dotnet dev-certs https` writes to the on-disk cache. It just
lets dotnet actually get that far.

If the test STILL fails after this, the failure mode will be on
the parse side (our parsePfx vs the cache file's bytes), which is
the question we set out to answer in the first place.
# install.sh SC2129 (shellcheck)

Three consecutive `echo "..." >> "${PROFILE_SCRIPT}"` lines I added
for the per-user $HOME-expanded path hints trip SC2129 in CI's
shellcheck step ("Consider using { cmd1; cmd2; } >> file instead of
individual redirects").

Collapsed into a single block redirect. Same content emitted, same
ordering preserved, one open of PROFILE_SCRIPT instead of three.

# dotnetMacosCache test: stop redirecting $HOME

Previous attempt unlocked the runner's login keychain
(`$HOME/Library/Keychains/login.keychain-db`) in the workflow step,
but the test then ran `dotnet dev-certs https` with
`env: { HOME: tmpHome }`. macOS keychain APIs resolve the login
keychain relative to `$HOME` — with HOME redirected to a tmpdir
where no keychain file exists, dotnet's save call failed with the
exact same "There was an error saving the HTTPS developer
certificate to the current user personal certificate store." error
as before, and the workflow unlock did nothing useful because it
unlocked a different file.

The HOME override was overcautious for the CI context anyway. The
runner is ephemeral, so writes to `~/.aspnet/dev-certs/https/` and
the login keychain disappear with the runner VM. Drop the override:
the test now reads the cache from `os.homedir()` and trusts the
workflow's keychain unlock to apply where dotnet actually looks.

Local self-skip on Linux still passes. The next macOS run will
either succeed (parsing the dotnet-authored cache file → finding 1
REFUTED) or fail at the parse step with parsePfx's specific OID
error message (→ finding 1 CONFIRMED with a definitive answer).
Aligns every manifest validate-release watches to the same version
in preparation for the first coordinated release that includes the
CLI:

  - src/shared/package.json
  - src/cli/package.json
  - src/vscode-ui-extension/package.json
  - src/vscode-workspace-extension/package.json
  - src/devcontainer-feature/src/devcontainer-dev-certs/
    devcontainer-feature.json

The `.1` suffix on the prerelease tag is intentional. After the
RELEASING.md bootstrap procedure (stub publish + trusted-publisher
config) is run for `@devcontainer-dev-certs/cli`, this commit's
version is a publishable prerelease — `npm publish --tag next` will
land 1.4.0-pre.1 on npm so we can exercise the OIDC publish path
end-to-end before cutting the real 1.4.0 release. Subsequent prerelease
spins can iterate the trailing integer (`-pre.2`, `-pre.3`, …)
without colliding.

Bumped via `jq --arg v 1.4.0-pre.1 '.version = $v'` for each manifest
(matches the shape `bump-version.yml` produces) + `npm install
--package-lock-only` to resync the lockfile.

No code changes; 252 UI / 79 workspace / 36 CLI tests green; lint
clean.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 92 out of 94 changed files in this pull request and generated 1 comment.

Comment thread src/vscode-ui-extension/tests/nativeBackend.test.ts Outdated
claude added 5 commits June 12, 2026 23:59
Style nit from Copilot review — both imports were from
`@devcontainer-dev-certs/shared`, no reason to split them across
two statements. Matches the convention everywhere else in the test
tree (one import line per source module).
Two UX gaps surfaced during local testing:

# generate output didn't say it reused

`dcdc generate` silently reuses an existing trusted dev cert when one's
already in the platform store. The output reported the resulting
thumbprint and "Trusted on host: yes" — but didn't distinguish "fresh
generation" from "we just observed the cert that was already there."
Indistinguishable from outside, which made it look like a no-op had
side effects.

Fix: snapshot the platform store BEFORE the backend runs, compare the
pre-existing thumbprint to the result thumbprint afterwards, and
add a `Cert source:` line that says one of three things:
  - "reused (existing trusted cert already in the host platform store)"
  - "newly generated (added to the host platform store)"
  - "newly generated (in memory; --no-trust skips platform-store write)"

The pre-check is best-effort: if `createPlatformStore` or `checkStatus`
throws (corrupt state, permission issue, etc.) we skip the diagnostic
and report "newly generated" by default rather than blocking the
generate. `--no-trust` skips the pre-check entirely because the native
backend bypasses the platform store on that path.

# Command descriptions didn't say what each command DOES vs DOESN'T DO

Reviewer pointed out the relationships between `generate` / `trust` /
`bundle` aren't obvious from `--help`, and they diverge from the
familiar `dotnet dev-certs https` convention in ways that look
arbitrary. Concrete example: `dcdc trust X` looks superficially like
`dotnet dev-certs https --import X --trust`, but the dotnet form
imports the cert into the .NET dev cert store AND adds it to OS trust,
while dcdc's only does the OS trust step.

Fix: rewrote every command's description to say:
  - what it DOES (action)
  - what state it touches (side effects)
  - what it explicitly DOES NOT do, when there's a plausible
    mis-mapping (`dcdc trust` says it's trust-only, not import)
  - rough equivalence to a `dotnet dev-certs https …` invocation
    where one exists

Plus a workflow guide + dotnet-mapping table in the program-level
help (rendered by `dcdc --help`). That's the document users see when
they first run the CLI looking for "what does each of these do."

No behavioral change beyond the new `Cert source:` line on generate.
All 36 CLI tests green; help text renders cleanly.
The dcdc CLI carved an own-the-config surface that conflicts with VS
Code's per-user `hostCertGenerator` setting and offers little value
without IDE-specific extensions. Drop it from this PR and keep:

- The shared workspace extraction and backend abstraction (NativeBackend,
  DotnetBackend, selectBackend) used by the VS Code extension's
  `hostCertGenerator` setting.
- macOS dotnet dev-certs disk-cache PFX compatibility (3DES legacy PBE
  decoder in `pkcs12LegacyPbe.ts`).
- Platform-store hardening (`resolveSafeExecPath`, Windows pwsh re-probe,
  macStore untrust-before-delete).
- The fallback installer + bundle JSON delivered by the devcontainer
  feature for non-VS Code use, now framed as minimally supported rather
  than first-class.

The macOS integration test now also pins the on-disk PBE algorithm OID:
when aspnetcore eventually switches the macOS writer to PBES2, the
assertion fires with an actionable error telling the next maintainer to
remove the legacy 3DES handler.

Removals:
- `src/cli/` workspace (commands, tests, README, esbuild config,
  tsconfigs, package.json).
- CLI build/test/pack steps in `build-extensions.yml`.
- `publish-cli` job and CLI tarball download in `release-feature.yml`.
- `src/cli/package.json` from the bump and validate lists.
- `findSiblingKey` from shared (CLI-only consumer).
- "dcdc" references throughout shared comments and the manual-setup
  example; the example now opens with a "minimally supported" callout
  and walks through the `dotnet dev-certs` host path explicitly.
The previous scope-reduction commit regenerated package-lock.json from
scratch on Linux to drop the deleted `src/cli` workspace entry. npm has
a known bug (npm/cli#4828) where regenerating the lock on one platform
strips the optional native-binding entries for every other platform,
which broke `npm ci` on macOS and Windows runners — vitest's transitive
`rolldown` dep failed to load its native binding.

Restore the previous lock and remove just the three `src/cli` entries
(workspace list, symlink, package definition + nested commander) by
hand. Cross-platform `@rolldown/binding-*` entries are now preserved.
`pkijs.PFX.fromBER` populates the outer schema but not `parsedValue` —
that requires `parseInternalValues`. My inspector skipped that step and
also had an extra `.parsedValue` layer in the walk path, so it always
returned null on CI even though the PFX itself loaded fine. The
diagnostic line in the previous run made this visible:

  observedPbeOid: (unknown), result: {"kind":"ok","thumbprint":"…"}

Mirror `parsePfx`'s walk: call `parseInternalValues` with an empty
password and `checkIntegrity: false` (we're reading headers, not
verifying or decrypting), then iterate `pfx.parsedValue.authenticatedSafe
.safeContents`. Verified locally against `test/fixtures/pkcs12-legacy-
3des.pfx`, which now correctly reports `1.2.840.113549.1.12.1.3`.
@dnegstad dnegstad changed the title Extract shared platform/cert logic and add host CLI Extract shared cert layer; hostCertGenerator setting + macOS PFX support Jun 13, 2026
claude added 3 commits July 27, 2026 04:35
…on-vscode-analysis-CLJg9

# Conflicts:
#	package-lock.json
#	src/devcontainer-feature/src/devcontainer-dev-certs/devcontainer-feature.json
#	src/shared/package.json
#	src/vscode-ui-extension/package.json
#	src/vscode-workspace-extension/package.json
The .1 suffix on 1.4.0-pre.1 existed to allow pre-publishing the CLI
to npm ahead of CI publish. With the CLI dropped from scope, return to
the repo's standard X.Y.Z-pre convention.
resolveSafeExecPath's logic was already unit-tested cross-platform via
its injectable platform/searchPath/pathExt/fileExists options, but the
branch that makes it load-bearing — runProcess returning exit code 127
instead of falling through to a cwd-first spawn when resolution fails —
had no coverage. runProcess deliberately takes no injection options (the
chokepoint shouldn't be steerable by callers), so the new tests stub
process.platform and PATH instead; both stubs behave identically on a
real Windows host.

Covers: 127 + 'command not found on PATH' when the command isn't on
PATH, the same guard when PATH holds only relative (hijack-equivalent)
entries, and the non-Windows fall-through to the OS spawn error to pin
that the guard is Windows-only.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 65 out of 67 changed files in this pull request and generated 9 comments.

Comments suppressed due to low confidence (1)

src/devcontainer-feature/src/devcontainer-dev-certs/scripts/setup-cert.sh:291

  • Root-store PFX parse failures are reported only as [info], so doctor mode can return success while .NET cannot load the trust anchor. Mark an unparseable root PFX as [fail] so the exit status matches the advertised trust-infrastructure health check.
            desc=$(describe_pfx "${pfx}" 2>/dev/null) || desc="(unparseable PFX)"
            info "$(basename "${pfx}"): ${desc}"

Comment thread examples/manual-setup/README.md Outdated
Comment thread src/devcontainer-feature/src/devcontainer-dev-certs/install.sh Outdated
Comment thread src/shared/src/cert/pkcs12LegacyPbe.ts Outdated
Comment thread src/vscode-ui-extension/src/certProvider.ts
Comment thread schema/bundle.schema.json Outdated
Comment thread examples/manual-setup/devcontainer.json
Comment thread README.md Outdated
Comment thread src/devcontainer-feature/src/devcontainer-dev-certs/scripts/setup-cert.sh Outdated
claude added 5 commits July 28, 2026 03:25
Every finding was reproduced or refuted against the actual code before
fixing; one was empirically tested against a real .NET 10 SDK.

Correctness:
- certProvider: after out-of-process provisioning (dotnet backend) or
  external store changes, the CertManager's in-memory cert could go
  stale — exportCert short-circuits on it, pairing OLD key material
  with the NEW thumbprint in the served bundle. ensureDotNetDevCert now
  checks store state BEFORE exporting and invalidates the manager on
  mismatch (new CertManager.loadedThumbprint / invalidateLoadedCert).
  Regression test simulates the stale-manager scenario.
- pkcs12LegacyPbe: the empty-password convention fallback accepted the
  first decrypt whose PKCS#7 padding validated — a wrong key passes
  that with ~1/256 probability, so ~0.4% of dotnet-written cache files
  would garbage-pass and never reach the correct convention. Each
  candidate plaintext must now also parse as a BER SEQUENCE (both
  payload shapes we decrypt are SEQUENCEs) before being committed.
- install.sh: the fallback-installer delivery ignored DEVCERTS_SYSROOT,
  so the hermetic test wrote to the host's real /usr/local/bin (or
  failed unprivileged). Physical destination is now sysroot-prefixed;
  the exported runtime path stays unprefixed. Test asserts both.

Doctor-mode diagnostics (setup-cert.sh):
- Unparseable PFX in CurrentUser/My or Root now emits [fail] (exit 1)
  instead of [info] — Kestrel/.NET cannot load those files, and the
  expiry probe (which reports "not expired" on parse failure) is
  skipped for them.
- A malformed PEM in the trust dir no longer kills the doctor via
  `set -e` on the hash-probe assignment; it routes to the existing
  [fail] branch.

Docs/schema (dotnet flag behavior verified on .NET SDK 10.0.110):
- manual-setup: `--format Pfx --no-password` is rejected by dotnet
  ("Incompatible set of flags") — replaced with `--export-path x.pfx
  --password ""`, which produces a real passwordless key-bearing
  PKCS#12. Also documented the trap that a bare `--export-path x.pfx`
  WITHOUT --password silently exports a cert-only DER file despite the
  extension. (Copilot's companion claim that the PEM key lands as
  `.pem.key` was refuted: .NET 10 writes the stem-based `.key` name
  that bundle.json already references; noted the older-SDK rename.)
- bundle schema: thumbprint pattern tightened to exactly 40 hex chars
  so SHA-256 digests / truncated values fail validation instead of
  breaking store discovery at install time.
- README + manual-setup example: declare extraCertDestinations on the
  feature for the /etc/nginx/certs bundle destination — the fallback
  installer runs as the remote user and can't create root-owned dirs;
  the feature pre-creates and chowns declared destinations during the
  privileged install phase.
A remote review pass observed that trustExternalCertificate's
short-circuit relies on LinuxCertificateStore.isTrusted, which only
checked the OpenSSL trust-dir PEM. Linux trust actually spans three
surfaces (.NET Root store PFX, OpenSSL PEM + rehash, best-effort NSS),
so a Root store cleared while the PEM survived reported "trusted"
forever — repeat container pushes AND CertManager.trust()'s recheck
both skipped the idempotent repair path.

The review's prescribed fix (always re-run platform trust on container
pushes) was rejected: the short-circuit is load-bearing on macOS, where
`security add-trusted-cert` re-touches the trust-settings record and
can re-prompt for the keychain password on every container attach.
Instead, the Linux predicate now requires BOTH authoritative file
surfaces (PEM and Root store PFX — two existsSync calls), so either
one going missing triggers the repair while other platforms keep their
single authoritative OS check.

NSS stays out of the predicate deliberately: certutil is commonly
absent, failures already surface through the reporter toast with
manual guidance, and requiring NSS would flap checkStatus().isTrusted
to permanently-false on hosts without the tooling.

Regression tests: Root PFX missing with PEM intact, and PEM missing
with Root PFX intact, both now report untrusted.
…on-vscode-analysis-CLJg9

# Conflicts:
#	README.md
#	src/devcontainer-feature/src/devcontainer-dev-certs/install.sh
The merge from main re-integrated Manual / non-VS Code documentation
(section, ToC entry, installFallbackTools option row, path-hint env-var
rows) into the restructured README. That worked against #80's explicit
decision to document non-VS Code usage as unsupported — a future PR
will add it back if that support is taken on.

The shipped artifacts (setup-cert.sh delivery, bundle schema,
examples/manual-setup) stay in the tree; the Repository Layout inventory
lists them factually with an "unsupported" note but the README no
longer documents the workflow. The hostCertGenerator setting rows stay
— that's VS Code extension surface.
PR #80 explicitly removed setup-cert.sh documentation from the README
('docs: drop the setup-cert.sh fallback section'); the layout entries
added during the merge reintroduced references to it. The README's only
remaining deltas vs main are the hostCertGenerator setting row and the
layout lines describing this PR's shared-package code moves.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 68 out of 70 changed files in this pull request and generated 5 comments.

Comment thread src/shared/src/cert/manager.ts Outdated
Comment thread examples/manual-setup/README.md Outdated
Comment thread src/devcontainer-feature/src/devcontainer-dev-certs/install.sh
Comment thread src/shared/src/platform/processUtil.ts Outdated
Comment thread src/shared/src/platform/macStore.ts
claude added 4 commits July 28, 2026 05:21
Correctness:
- CertManager.trust(): the reload from the store only ran when NOTHING
  was cached, so with cert A in memory and the store entry externally
  replaced by untrusted cert B, trust() re-trusted the orphaned A while
  B stayed untrusted — and CertProvider's staleness check would then
  export untrusted B. The reload now also fires whenever the cached
  thumbprint differs from the store's, making the store authoritative.
  Regression test drives the A→B replacement sequence.
- resolveSafeExecPath: "has an extension" was derived from PATHEXT
  membership, so a user whose PATHEXT omits .EXE got `certutil.exe`
  probed as `certutil.exe.cmd`/`.bat` and exit 127 despite the binary
  being on PATH. Any filename extension now counts as explicit
  (path.win32.extname); PATHEXT only governs extensionless names.
- macStore.isCertInKeychain: `security find-certificate` has no
  filter-by-hash option — `-Z` is an output modifier taking no
  argument, so the thumbprint was being parsed as a keychain path and
  the check could never work as intended. Now enumerates with
  `find-certificate -a -Z <keychain>` and matches the `SHA-1 hash:`
  lines (modern macOS also prints SHA-256 lines; the SHA-1 line is
  matched specifically). The test mock modeled the same fictional
  filter and was corrected to emit realistic enumeration output.

Robustness:
- install.sh installFallbackTools: added microdnf, yum, and zypper
  branches so RPM/SUSE-family images that lack dnf don't fall through
  to the warn-and-skip path with an unusable installer.

Docs consistency:
- examples/manual-setup README banner now states the non-VS Code path
  is unsupported (reference-only, no support commitments), matching
  #80's stance and the PR description instead of claiming "minimally
  supported".
- install.sh: the podman-container-tools/buildah#6747 /tmp repair ran at the END of
  the script, but the installFallbackTools package-install block runs
  earlier — on a podman layer where a PRIOR feature already broke /tmp
  (0755 root:root), our own `apt-get update` failed under `set -e`
  before the repair could execute. The copy-up/chmod block now runs
  immediately before any package-manager invocation; the overlayfs
  copy-up survives the rest of the RUN, so the committed layer is still
  corrected for subsequent features.

- DotnetBackend Linux NSS: trustInNss ran against (and the reporter was
  handed) the exported PEM inside the caller's outDir — which
  certProvider deletes in a `finally` the moment generate() returns,
  while the NSS-failure toast's "Copy Certificate Path" guidance is
  still pending. The import and the reported path are now anchored on
  the persistent OpenSSL trust-dir PEM
  (aspnetcore-localhost-{thumbprint}.pem); when `dotnet dev-certs
  --trust` didn't write it (older SDKs don't manage the Linux trust
  dir) the backend places a copy there first. Tests updated to pin the
  persistent path and the copy-only-when-missing behavior.

- setup-cert.sh doctor: CA-only bundle entries live ONLY in the Root
  store, so the My-loop expiry check never saw them and an expired
  trust anchor passed doctor with zero errors. The Root loop now runs
  the same expiry probe. Verified empirically with an openssl-ca-minted
  backdated CA-only PFX: doctor reports [fail] and exits 1.
Three settings historically used a hyphenated devcontainer-dev-certs
prefix (host autoProvision; remote autoInject, warnOnStaleDevCerts),
which made them group under a separate "Devcontainer-dev-certs" heading
in the Settings UI while everything else sat under "Devcontainer Dev
Certs". All settings now declare under devcontainerDevCerts.*.

Backward compatibility, no migration required:
- The legacy names stay declared with a markdownDeprecationMessage
  (VS Code hides deprecated-and-unset settings from the UI and shows
  the guidance to users who have them set).
- A shared getRenamedSetting helper reads via inspect(): an explicitly
  set modern value wins, an explicitly set legacy value is honored when
  the modern name is unset, and only then does the default apply — so
  an explicit modern `false` can't be shadowed by a legacy `true`, and
  existing user config keeps working untouched.
- The "Don't Show Again" write for warnOnStaleDevCerts now targets the
  modern name (which the precedence order guarantees wins).

Command IDs keep the hyphenated form — they don't affect settings
grouping and renaming them would break user keybindings.

Docs updated to the canonical names throughout; the configuration
reference now documents the single prefix and the honored legacy names.
Fallback precedence is pinned by new settings.test.ts suites in both
extensions, with inspect() support added to the vscode test mocks.
@dnegstad
dnegstad merged commit 28e2f53 into main Jul 28, 2026
8 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.

3 participants