Skip to content

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

Open
dnegstad wants to merge 42 commits into
mainfrom
claude/devcontainer-non-vscode-analysis-CLJg9
Open

Extract shared cert layer; hostCertGenerator setting + macOS PFX support#74
dnegstad wants to merge 42 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.

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) so it works against both dotnet dev-certs-written and openssl pkcs12 -legacy-written files.
  • 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.
  • 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).

Devcontainer feature install scripts:

  • setup-cert.sh now honors the DEVCONTAINER_DEV_CERTS_*_DIR env vars install.sh exports.
  • Shellcheck cleanups: SC2129 (block redirect), SC2181 (direct-exit), SC2006/SC2086 (backticks → single quotes — backticks were actually command substitution inside double-quoted strings, a latent bug).

Documentation & examples:

  • New examples/manual-setup/ walks through the non-VS Code path end-to-end (host dotnet dev-certs, bundle.json, container-side fallback installer). README opens with an [!IMPORTANT] note that usage outside VS Code is minimally supported rather than a first-class supported scenario.
  • New schema/bundle.schema.json for bundle.json validation / IDE autocomplete.

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 — 252 passed / 11 skipped (skips are the four macOS-only and Windows-only integration tests).
  • npm test -w src/vscode-workspace-extension — 79 passed / 1 skipped.
  • npm run lint — 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).
claude added 3 commits June 12, 2026 19:02
Both pre-existed but were only flagged after the merge surfaced
them in CI:

# SC2181 — direct exit-status check (line 261)

`openssl x509 ... -checkend 0` followed by `[ $? -ne 0 ]` is the
indirect form shellcheck recommends against. Inverted with `!`
inline:

  ! printf '%s' "${pem}" | openssl x509 -noout -checkend 0 &>/dev/null

Same semantics (function returns 0 / true when the cert IS expired),
one fewer subshell, no `$?` dance.

# SC2006 / SC2086 — backticks in error message text (line 320)

The original used Markdown-style backticks around an `openssl
rehash` suggestion: `` `openssl rehash ${TRUST_DIR}` ``. Inside a
double-quoted bash string those are command substitution — bash
WOULD actually invoke `openssl rehash $TRUST_DIR` at message-build
time when `fail()` runs (and the unquoted `${TRUST_DIR}` would
word-split into separate args). Latent bug masquerading as a style
warning.

Swapped to single quotes around the suggestion: clearer formatting
for the user, no command execution, no word-split. The error
message now reads as the user would expect: a literal suggestion in
plain quotes, not a phantom command.
# What

`parsePfx` now accepts PKCS#12 files where the cert bag and/or the
PKCS#8 shrouded key bag are encrypted with
`pbeWithSHA1And3-KeyTripleDES-CBC` (OID 1.2.840.113549.1.12.1.3).
Every other legacy PBE-with-SHA OID stays rejected with the existing
"re-export with PBES2/AES" error message. The relax-and-decrypt logic
lives in a new, narrowly scoped module:
`src/shared/src/cert/pkcs12LegacyPbe.ts`.

# Why

`aspnetcore`'s `MacOSCertificateManager.SaveCertificateCore` calls
`certificate.Export(X509ContentType.Pfx)` with no password to write
the disk cache at `~/.aspnet/dev-certs/https/aspnetcore-localhost-*
.pfx`. dotnet/runtime's managed PKCS#12 writer's default for that
call on Unix is 3DES + SHA-1 + 2000 iterations. The Linux dev-cert
disk cache, in contrast, goes through `OpenSslDirectoryBasedStore
Provider` which calls `ExportPkcs12(PbeParameters(Aes256Cbc, SHA256,
…))` explicitly — PBES2/AES.

Confirmed empirically against .NET 10.0.301 / runtime 10.0.9 in two
environments:
  - GH Actions macos-latest runner (tests/dotnetMacosCache.integration
    .test.ts produced the disk cache and we inspected the OID in the
    rejection message)
  - A maintainer's local Mac (`openssl pkcs12 -info` reported
    `pbeWithSHA1And3-KeyTripleDES-CBC` for both bags)

Without this change, `DotnetBackend.generate` on macOS — the platform
`--backend auto` PREFERS — fails because `findExistingDevCert` tries
to load the disk cache and `parsePfx` rejects it. The misleading
error is "no dev cert was found in the platform store afterwards"
even though dotnet succeeded. The same blocker hits `dcdc inspect`,
`dcdc bundle`, and `dcdc trust` against any user-supplied
dotnet-on-macOS PFX.

# How

New file `src/shared/src/cert/pkcs12LegacyPbe.ts`:
  - `pkcs12Kdf`: RFC 7292 Appendix B key derivation function. SHA-1
    based diversifier KDF (NOT PBKDF2). About 40 LOC of careful
    Buffer arithmetic with explicit comments at the subtle steps
    (block padding, big-endian I_l + B + 1 carry propagation).
  - `decryptLegacyPbe`: derive 24-byte 3DES key (diversifier 1) and
    8-byte IV (diversifier 2), then `crypto.createDecipheriv(
    'des-ede3-cbc', key, iv)`.
  - `parseLegacyPbeParams`: decode the `pkcs-12PbeParams` SEQUENCE
    { salt OCTET STRING, iterations INTEGER }.
  - `SUPPORTED_LEGACY_PBE_OID` / `isSupportedLegacyPbe`: type-guard
    around the single OID we currently accept.

Empty-password handling: UTF-16BE + trailing null terminator
unconditionally — including for the empty string. The RFC's literal
wording says "empty P for empty password" but every implementation
that matters (OpenSSL, dotnet/runtime's managed PKCS#12 writer,
Bouncy Castle) always appends the terminator. The aspnetcore disk
cache decrypts under that convention; following the RFC literally
would fail to read our own target.

`src/shared/src/cert/pfx.ts` changes are scoped to the two decrypt
points (`decryptSafeContents` and `decryptShroudedKeyBag`):
  - PBES2 path is unchanged — first branch, calls pkijs the same way
    it always did.
  - Legacy 3DES branch is new — extracts the pkcs-12PbeParams +
    ciphertext, hands them to the new module, parses the cleartext
    as `SafeContents` (cert bag) or `PrivateKeyInfo` (key bag).
  - Everything else still throws `unsupportedAlgorithmError`.
The `REJECTED_LEGACY_PBE_NAMES` table no longer lists
1.2.840.113549.1.12.1.3; the comment above it spells out why.

# Removal criteria

The new module's docstring carries a full removal checklist for the
day aspnetcore upstream switches macOS to PBES2 (one-line fix on
their side: pass `PbeParameters` to `ExportPkcs12`). When that
happens AND we've raised our floor SDK to a version that includes
the fix, the steps are:
  1. Delete `src/shared/src/cert/pkcs12LegacyPbe.ts`.
  2. Delete `tests/pkcs12LegacyPbe.test.ts` and
     `test/fixtures/pkcs12-legacy-3des.pfx`.
  3. Re-add 1.2.840.113549.1.12.1.3 to `REJECTED_LEGACY_PBE_NAMES`.
  4. Revert the two `decryptSafeContents` / `decryptShroudedKeyBag`
     branches to "reject anything not PBES2".

# Tests

`tests/pkcs12LegacyPbe.test.ts` (9 tests):
  - `SUPPORTED_LEGACY_PBE_OID` names exactly 1.2.840.113549.1.12.1.3.
  - `isSupportedLegacyPbe` accepts the one OID, rejects the other
    five legacy PBE-with-SHA OIDs and PBES2.
  - `decryptLegacyPbe` defensively refuses unsupported OIDs.
  - `pkcs12Kdf` distinct-key-and-IV / determinism / iteration-
    sensitivity / non-empty-password sanity checks.
  - `parsePfx` end-to-end load of
    `test/fixtures/pkcs12-legacy-3des.pfx` (built via openssl with
    `PBE-SHA1-3DES` for both cert and key bags + empty password,
    same shape aspnetcore produces): cert subjectCN == "localhost",
    key present, thumbprint round-trips.

The fixture is a one-shot ~2KB file committed under
`test/fixtures/`; the commands to regenerate it are in the test
file's docstring.

# Sweep

250 UI tests (+9), 36 CLI tests, 79 workspace tests — all green.
Lint + type-check clean. Existing PBES2 round-trips unaffected.
The CI macOS test on this branch should now PASS (it asserts
`loadResult.kind === "ok"` against the aspnetcore disk cache; with
this commit, the cache becomes loadable).
CI macOS run on `09115d8` got past the OID-rejection layer but
failed inside the cipher: `error:1C800064:Provider routines::bad
decrypt`. PKCS#7 padding check failure means the derived 3DES key
didn't match what aspnetcore used to encrypt.

The only variable: empty-password encoding. Our local fixture
(openssl-generated) decrypts cleanly because openssl encodes
empty-password as the UTF-16BE null terminator (`\x00\x00`).
dotnet/runtime's managed PKCS#12 writer evidently uses the
RFC-literal interpretation — empty password → empty bytes. Both
interpretations are valid; the spec is famously ambiguous on this
point and implementations have disagreed for two decades.

Fix is contained in the existing legacy module:

  - `pkcs12Kdf` gains an `includeNullTerminator` parameter
    (default `true`). For non-empty passwords every implementation
    agrees, so the flag is only meaningful for the empty case.
  - `decryptLegacyPbe` iterates the two conventions for empty
    passwords: openssl-style first (matches our test fixture and
    most legacy PFXes in the wild), then RFC-literal (matches
    aspnetcore's macOS disk cache). Non-empty passwords still use
    the single canonical encoding.
  - PKCS#7 padding failure under both conventions propagates the
    last error outward — the password's wrong, the file's corrupt,
    or aspnetcore changed its export convention again.

Two new tests pin the flag's behavior:
  - empty-password output differs between the two conventions
    (otherwise the fallback in `decryptLegacyPbe` would be a no-op)
  - non-empty-password output also differs between the two —
    confirms we're not silently always-including the terminator

Local fixture round-trip still passes; this commit doesn't change
the OpenSSL path's behavior, only adds the .NET path as a fallback.

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

This PR reorganizes the certificate and platform-trust implementation into a shared workspace package and adds a host-side CLI (dcdc) that reuses the same logic as the VS Code extensions, while updating the feature installer, docs, tests, and release workflows to accommodate the new packaging.

Changes:

  • Extracted platform store + cert management primitives into src/shared (logger/localizer, platform stores, backends, PKCS#12 parsing updates).
  • Added src/cli (dcdc) with generate/inspect/trust/bundle/doctor commands plus schema-aware bundle.json writer and tests.
  • Updated VS Code extensions, devcontainer feature installer, docs/examples, and GitHub Actions workflows to integrate the shared layer + CLI artifacts.

Reviewed changes

Copilot reviewed 90 out of 92 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/vscode-workspace-extension/tests/containerCertPush.test.ts Updates logger init import to VS Code-specific shared helper.
src/vscode-workspace-extension/src/extension.ts Switches logger init to shared VS Code helper while keeping shared logging API.
src/vscode-ui-extension/tests/windowsStore.test.ts Retargets mocks/imports to shared internal platform modules after extraction.
src/vscode-ui-extension/tests/selectBestDevCert.test.ts Updates logger init import to shared VS Code helper.
src/vscode-ui-extension/tests/resolveSafeExecPath.test.ts Adds cross-platform tests for Windows-safe executable resolution helper.
src/vscode-ui-extension/tests/pkcs12LegacyPbe.test.ts Adds tests around legacy PKCS#12 PBE support and KDF behavior.
src/vscode-ui-extension/tests/nssTrust.test.ts Retargets NSS trust mocks/imports to shared internal modules.
src/vscode-ui-extension/tests/nativeBackend.test.ts Adds tests for NativeBackend --no-trust behavior (no platform store writes).
src/vscode-ui-extension/tests/manager.test.ts Retargets createPlatformStore mocking to shared platform/types module.
src/vscode-ui-extension/tests/macStore.test.ts Retargets runProcess mocking to shared and adds removeCertificates tests.
src/vscode-ui-extension/tests/linuxStore.test.ts Retargets mocks to shared internal modules and overrides shared paths for isolation.
src/vscode-ui-extension/tests/dotnetMacosCache.integration.test.ts Adds macOS integration test validating dotnet disk-cache PFX is parseable.
src/vscode-ui-extension/tests/containerCertAccept.test.ts Updates logger init import to shared VS Code helper.
src/vscode-ui-extension/tests/classifyCandidate.test.ts Updates logger init import to shared VS Code helper.
src/vscode-ui-extension/tests/_helpers.ts Adds shared test helper for stubbing process.platform.
src/vscode-ui-extension/src/platform/types.ts Replaces implementation with re-export shim to shared platform/types.
src/vscode-ui-extension/src/platform/processUtil.ts Replaces implementation with re-export shim to shared process utilities.
src/vscode-ui-extension/src/platform/nssTrust.ts Replaces implementation with re-export shim to shared NSS trust helper.
src/vscode-ui-extension/src/extension.ts Wires shared localizer + shared Linux NSS reporter through CertManager/CertProvider.
src/vscode-ui-extension/src/certProvider.ts Adds backend-based provisioning path and forwards Linux NSS reporter to dotnet backend.
src/vscode-ui-extension/src/cert/manager.ts Replaces implementation with re-export shim to shared CertManager.
src/vscode-ui-extension/src/cert/generator.ts Replaces implementation with re-export shim to shared generator/validation helpers.
src/vscode-ui-extension/src/cert/exporter.ts Replaces implementation with re-export shim to shared exporter helpers.
src/vscode-ui-extension/package.json Adds devcontainerDevCerts.hostCertGenerator setting to control host backend selection.
src/shared/src/platform/types.ts Introduces shared platform store types and createPlatformStore with localizer + NSS reporter options.
src/shared/src/platform/processUtil.ts Adds resolveSafeExecPath and updates runProcess to defend Windows cwd-first hijacks.
src/shared/src/platform/nssTrust.ts Moves NSS browser trust implementation into shared layer.
src/shared/src/loggerVscode.ts Adds VS Code-specific logger initializer that plugs OutputChannel into shared sink.
src/shared/src/logger.ts Refactors logger to a pluggable LogSink interface (no VS Code dependency).
src/shared/src/localizer.ts Adds Localizer interface and identity implementation compatible with vscode.l10n.t.
src/shared/src/index.ts Expands shared barrel exports to include backends, platform layer, cert manager/exporter/generator, and utilities.
src/shared/src/cert/pfx.ts Adds legacy 3DES PKCS#12 decrypt path gated to a single OID; improves error guidance.
src/shared/src/cert/manager.ts Adds shared CertManager (moved from UI extension) with localizer + NSS reporter wiring.
src/shared/src/cert/loader.ts Adds findSiblingKey helper for .key vs .pem.key conventions.
src/shared/src/cert/generator.ts Moves certificate generation into shared layer.
src/shared/src/cert/exporter.ts Moves cert export helpers into shared layer with consistent file modes.
src/shared/src/backends/types.ts Defines backend abstraction shared by CLI and VS Code host extension.
src/shared/src/backends/select.ts Implements backend selection logic (auto prefers dotnet on macOS when available).
src/shared/src/backends/native.ts Implements native backend (file-only vs generate+trust paths).
src/shared/src/backends/dotnet.ts Implements dotnet backend invoking dotnet dev-certs then exporting from platform store (+ Linux NSS supplement).
src/devcontainer-feature/src/devcontainer-dev-certs/install.sh Adds fallback installer delivery + optional prerequisite installation; exports path hints.
src/devcontainer-feature/src/devcontainer-dev-certs/devcontainer-feature.json Adds installFallbackTools feature option.
src/cli/vitest.setup.ts Adds test setup to import reflect-metadata.
src/cli/vitest.config.ts Adds Vitest configuration for CLI workspace tests.
src/cli/tsconfig.lint.json Adds CLI lint TS config including tests and setup file.
src/cli/tsconfig.json Adds CLI TS config targeting Node18, Node16 module resolution.
src/cli/tests/writer.test.ts Adds tests for bundle writer schema + path containerization behavior.
src/cli/tests/select.test.ts Adds tests for backend selection and auto-resolution behavior.
src/cli/tests/generate.test.ts Adds tests for dcdc generate no-trust propagation and NSS reporter forwarding.
src/cli/tests/bundle.test.ts Adds tests for out-of-dir bundle warnings and sibling-file discovery.
src/cli/tests/_helpers.ts Adds CLI test helper for stubbing process.platform.
src/cli/src/nssReporter.ts Adds stderr NSS trust reporter for CLI runs.
src/cli/src/logger.ts Adds CLI logger installer wiring shared log sink to stderr under --verbose.
src/cli/src/index.ts Adds CLI entrypoint wiring commander commands (generate/inspect/bundle/trust/doctor).
src/cli/src/defaults.ts Adds centralized CLI defaults for out-dir and container mount.
src/cli/src/commands/trust.ts Adds host trust command using shared platform store trust path (with NSS reporter).
src/cli/src/commands/inspect.ts Adds inspection command for PFX/PEM with SAN validation and JSON output.
src/cli/src/commands/generate.ts Adds generate command invoking backend and writing bundle.json.
src/cli/src/commands/doctor.ts Adds read-only diagnostics including backend availability and tool presence.
src/cli/src/commands/bundle.ts Adds bundle command with sibling discovery and “out of out-dir” warnings.
src/cli/src/bundle/writer.ts Adds bundle.json writer with schema URL and host→container path rewriting.
src/cli/README.md Documents CLI installation, commands, semantics, and limitations.
src/cli/package.json Adds CLI package metadata, scripts, and dev dependencies.
src/cli/LICENSE Adds MIT license file for the CLI package.
src/cli/esbuild.mjs Adds esbuild bundling script (CJS, Node18 target, vscode external).
schema/bundle.schema.json Adds JSON schema for bundle.json validation/autocomplete.
RELEASING.md Documents coordinated release process across feature/extensions/CLI with new CLI publish steps.
package.json Adds src/cli to workspace list.
package-lock.json Updates lockfile for new workspace and CLI dependencies.
examples/manual-setup/README.md Adds end-to-end non–VS Code workflow example leveraging dcdc + fallback installer.
examples/manual-setup/devcontainer.json Adds example devcontainer config using installFallbackTools and bundle installer.
examples/manual-setup/bundle.json Adds example bundle.json referencing published schema and sample entries.
eslint.config.mjs Adds CLI TS config to ESLint project list.
.github/workflows/release-feature.yml Updates release workflow to validate CLI version and publish CLI tarball to npm.
.github/workflows/ci.yml Updates CI to use new upload-artifacts input name.
.github/workflows/bump-version.yml Updates version bump workflow to include CLI package.json.
.github/workflows/build-extensions.yml Builds/tests CLI, uploads tarball artifact, and adds macOS dotnet cache compatibility job.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/shared/src/cert/pkcs12LegacyPbe.ts
Comment thread src/vscode-ui-extension/tests/pkcs12LegacyPbe.test.ts
Comment thread src/vscode-ui-extension/tests/linuxStore.test.ts
Comment thread src/cli/package.json Outdated
claude added 2 commits June 12, 2026 23:13
# 1. pkcs12Kdf: includeNullTerminator now strictly empty-only

Reviewer caught a contract violation. The docstring said the flag had
"no observable effect for non-empty passwords," but the implementation
WAS threading it through the non-empty branch (`utf16BeWithNul` vs
`utf16Be`). Either the contract was wrong or the code was — the code
was wrong. Real-world PKCS#12 implementations all append the null
terminator for non-empty passwords; the ambiguity lives only at the
empty case.

Fix: non-empty passwords always go through `utf16BeWithNul`; the flag
is only consulted for `password.length === 0`. The now-unused
`utf16Be` helper is deleted. Docstring clarified to spell out that
non-empty passwords IGNORE the flag.

# 2. pkcs12Kdf test: invert the non-empty assertion

Same reviewer thread. The test "ignores includeNullTerminator for
non-empty passwords" asserted the two outputs differ — which directly
contradicted the test's stated contract. Under the corrected
implementation, both calls produce identical bytes for non-empty
passwords; the test now asserts that, and the comment explains the
test is a regression guard against accidentally threading the flag
through the non-empty branch.

# 3. linuxStore.test.ts: correct importOriginal type parameter

`vi.mock("@devcontainer-dev-certs/shared/src/paths", …)` was typed as
`importOriginal<typeof Shared>()` — `Shared` is the package barrel,
not the `paths` submodule that's actually being mocked. The types
lied about what the original module exports, masking any future
mistake where the `paths` module's shape changes.

Replaced with `importOriginal<typeof SharedPaths>()` and added a
`type * as SharedPaths from "@devcontainer-dev-certs/shared/src/paths"`
import. The `Shared` import is no longer used in this file and is
dropped.

The same pattern in three vscode-workspace-extension test files is
NOT affected — those mock the package barrel and correctly type
against `typeof Shared`. Only linuxStore had the mismatch.

# 4. CLI engines.node: bump to >=20

Reviewer flagged the inconsistency between `engines.node: >=18` and
commander@^14's dependency tree. Independent of commander's specific
requirements: Node 18 reached end-of-life in April 2025; advertising
support for an EOL runtime in a CLI shipped in 2026 is misleading.
Node 20 LTS is the lowest currently-supported version.

Updated:
  - `src/cli/package.json` engines.node to `>=20`
  - `src/cli/esbuild.mjs` target to `node20` (matches the engines
    declaration so any Node 20+ syntax esbuild emits is honest)
  - `src/cli/README.md` and `examples/manual-setup/README.md` Node
    version requirements (calls out Node 18's EOL date in the CLI
    README so the version bump is grounded)

Local CLI tarball still 1.78MB unpacked, no observable change in
bundle size from the esbuild target bump.

# Sweep

252 UI tests (+1 — non-empty test now asserts equality after the
contract correction), 36 CLI tests, 79 workspace tests. Lint +
type-check clean. CLI builds with the new node20 target.
Previous bump in 29d3204 traded one EOL runtime for another — Node 20
reached end-of-life two months ago. Node 22 is the lowest LTS still
receiving security updates, and it's also the runtime VS Code
currently bundles (22.22.1 as of VS Code 1.121.0), which makes it a
natural floor for everything in this repo.

Updated:
  - `src/cli/package.json` engines.node: ">=22"
  - `src/cli/esbuild.mjs` target: "node22"
  - `src/cli/README.md` calls out Node 20's EOL date AND the VS Code
    runtime alignment, so the floor is grounded in two reasons rather
    than one
  - `examples/manual-setup/README.md` updated to match

No code changes; bundle size unchanged at 1.78 MB unpacked.

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 90 out of 92 changed files in this pull request and generated 1 comment.

Comment thread src/cli/package.json Outdated
claude added 2 commits June 12, 2026 23:30
Main's `bump-version.yml` ran for the 1.3.1 release and pushed every
manifest to 1.3.2-pre — but the CLI package didn't exist on main yet,
so it was skipped. This branch added `src/cli/package.json` to the
bump list going forward, but the catch-up for the existing skew has
to happen by hand.

If a release tag landed before this fix, `validate-release` in
release-feature.yml (which this branch wired to also check
`src/cli/package.json`) would fail with a clear "version 1.3.1-pre
does not match release tag 1.3.x" — caught at the gate, but only
after kicking off a release.

Bump applied via `npm version 1.3.2-pre --no-git-tag-version` so the
shape matches what the bump workflow produces; package-lock.json
re-synced.

No code changes.
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
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