Extract shared cert layer; hostCertGenerator setting + macOS PFX support - #74
Merged
Merged
Conversation
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.
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`.
…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.
There was a problem hiding this comment.
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}"
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three threads pulled into one PR:
@devcontainer-dev-certs/sharedworkspace 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.hostCertGeneratorsetting to the host VS Code extension, backed by a new backend abstraction in shared (NativeBackend/DotnetBackend/selectBackend).autoprefersdotnet dev-certson macOS when available (better keychain UX) and falls back to the in-process native generator everywhere else.dotnet dev-certsdisk cache loadable. ASP.NET Core'sMacOSCertificateManager.SaveCertificateCorewrites its PFX using legacypbeWithSHA1And3-KeyTripleDES-CBC(OID1.2.840.113549.1.12.1.3) becausecertificate.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/):windowsStore.ts,macStore.ts,linuxStore.ts, plus thebaseStore.tscandidate-classification layer.generator.ts,manager.ts,exporter.ts,loader.ts,pfx.ts.processUtil.ts(now withresolveSafeExecPathfor Windows cwd-hijack defense),nssTrust.ts,types.ts.Logger/Localizerinterfaces in shared; VS Code-specific wiring inloggerVscode.ts.Backend abstraction (
src/shared/src/backends/):NativeBackendhas two paths:noTrust: truewrites only tooutDirand never touches the platform store;noTrust: falsedrivesCertManagerend-to-end.DotnetBackendshellsdotnet 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 (whichdotnet dev-certs --trustdoesn't touch).selectBackendresolvesauto/native/dotnetand surfaces availability errors with a setting-aware message.certProvider.provisionViaConfiguredBackend()honors thehostCertGeneratorsetting; 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.tsimplements RFC 7292 Appendix B's SHA-1 diversifier KDF and 3DES-CBC decrypt, restricted to OID1.2.840.113549.1.12.1.3(every other legacy PBE OID still falls through to rejection).dotnetMacosCache.integration.test.ts(macOS + dotnet only; self-skips elsewhere) drivesdotnet dev-certs https, loads the resultingaspnetcore-localhost-*.pfxthroughparsePfx, and pins the on-disk PBE algorithm OID. When aspnetcore eventually switches the macOS writer toExportPkcs12(PbeParameters(Aes256Cbc, …))and emits PBES2 instead, the assertion fires with an actionable error pointing the maintainer atpkcs12LegacyPbe.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):
resolveSafeExecPathdefends against the WindowsCreateProcesscwd-first executable lookup behavior —runProcessnow resolves commands through PATH before spawning, and returns exit code 127 instead of falling through to an unsafe spawn when resolution fails.windowsStore.tsonly caches the positive result of probing forpwsh; a negative result re-probes on every call so deferred PATH updates work.macStore.removeCertificatescalls untrust before delete with the proper cert file (was passing the keychain path as a positional and using-dincorrectly).linuxStore.isTrustedrequires 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.shdeliversscripts/setup-cert.shto/usr/local/bin/devcontainer-dev-certs-install(sysroot-aware so the hermetic test never touches the host) and exportsDEVCONTAINER_DEV_CERTS_*path-hint variables.setup-cert.shhonors those env vars, gains a--bundle-jsonmulti-cert form and read-only--doctordiagnostics (unparseable store files fail the doctor rather than passing silently). Shellcheck cleanups throughout.setup-cert.shis too bare-bones to stand alone, and documenting it would promise something the project doesn't back. The script,schema/bundle.schema.json, andexamples/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:
macos-dotnet-dev-certs-cachejob that exercises the new integration test against realdotnet dev-certsoutput onmacos-latest. The login keychain is unlocked first sodotnet dev-certs httpscan write toCurrentUser\My.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