Skip to content

fix(byok): harden the Windows DPAPI worker lifecycle - #6282

Closed
open-design-crew[bot] wants to merge 4 commits into
mainfrom
codex/test-windows-dpapi-diagnostic
Closed

fix(byok): harden the Windows DPAPI worker lifecycle#6282
open-design-crew[bot] wants to merge 4 commits into
mainfrom
codex/test-windows-dpapi-diagnostic

Conversation

@open-design-crew

@open-design-crew open-design-crew Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #6285

Why

Use case: The 0.17.0 prerelease Windows packaged smoke repeatedly timed out while creating, listing, and deleting secure BYOK profiles.

Pain: The original Windows backend launched a new encoded powershell.exe process for every DPAPI probe, read, write, and delete. In the packaged environment, cold PowerShell and System.Security startup took about 36–42 seconds while DPAPI itself took milliseconds, so repeated cold starts exceeded the 60-second desktop eval budget. A long-lived worker also needed bounded lifecycle, recovery, shutdown, and authenticated IPC behavior before it was safe to merge.

What users will see

Windows packaged users keep the same CurrentUser DPAPI-encrypted profiles and the same API and UI behavior. The first access may still pay one cold PowerShell startup, but later BYOK operations reuse the warm worker. A fatal worker failure fails the current operation, retires that worker generation, and lets the next independent request recover with a fresh worker; uncertain mutations are never replayed automatically. Daemon shutdown now closes the worker it owns, and tools-pack keeps the exact 60-second Windows desktop eval budget.

There are no visible changes on macOS or Linux, and this PR does not change routes, API contracts, the desktop entry flow, or BYOK storage paths and ciphertext compatibility.

Surface area

  • Web UI
  • Desktop / packaged app
  • Daemon / API
  • CLI / API
  • Default behavior
  • New dependency
  • Environment variable
  • Configuration
  • Internationalization
  • Agent runtime / parser
  • Skill
  • Design system
  • Design template
  • Craft

Screenshots

Not applicable — this is a backend-only Windows runtime change.

Implementation notes

  • Replaces one-PowerShell-per-operation with one lazily started DPAPI worker per daemon, communicating over a local Windows named pipe.
  • Bounds connection, startup, readiness, operation, and shutdown phases so a stuck generation cannot block the daemon indefinitely.
  • Uses generation-based recovery: startup failures do not poison future attempts, fatal operation failures retire the current worker, and nonfatal input or DPAPI errors keep a healthy worker available.
  • Does not automatically replay set or delete after an uncertain failure, avoiding duplicate or misreported side effects.
  • Makes credential-service shutdown idempotent, aborts in-progress startup, closes the pipe gracefully, and terminates the owned child within the shutdown budget. The PowerShell worker also watches its parent PID so abrupt daemon death does not normally leave an orphan.
  • Authenticates each worker generation with a one-shot 256-bit key delivered through child stdin and an HMAC-SHA256 challenge-response before any credential request is sent. The key is not placed in argv, environment variables, or a temporary file.
  • Adds bounded unauthenticated connection handling, request and line-size limits, protocol validation, canonical base64 checks, and structured secret-safe diagnostics containing only phase, failure class, duration, generation, and fatality.
  • Adds permanent lifecycle, recovery, shutdown, orphan-process, peer-authentication, input-boundary, and 60-second tools-pack regression coverage.

Bug fix verification

  • Test paths: apps/daemon/tests/byok/credential-service.test.ts, apps/daemon/tests/byok/credential-service.windows.test.ts, apps/daemon/tests/byok/windows-dpapi-backend-lifecycle.test.ts, apps/daemon/tests/byok/windows-dpapi-worker.test.ts, apps/daemon/tests/byok/windows-dpapi-worker-orphan.windows.test.ts, apps/daemon/tests/server-shutdown.test.ts, and tools/pack/tests/win-lifecycle-timeouts.test.ts.
  • Red on main: the release failure is the production red-spec evidence. The exact non-publishing Windows diagnostic was intentionally temporary and removed after proving the repeated cold-start cause, so the permanent tests focus on the worker lifecycle, security, and timeout contracts.
  • Green on this branch: focused daemon BYOK lifecycle suites passed with 40 tests and 2 Windows-only skips on macOS; the final peer-authentication worker suite passed all 15 tests; the tools-pack 60-second timeout test passed.
  • Current-head CI: all required checks passed in run 30612094723. The Windows tools-pack payload tests job validates payload construction but does not execute packaged DPAPI or the Windows-only orphan and peer-authentication paths.
  • Windows packaged evidence: non-publishing diagnostic run 30557107489 proved the warm-worker approach against packaged product files before the later lifecycle and authentication commits. A new non-publishing Windows run at the final head is still needed for exact-head packaged confidence.

Validation

  • Focused daemon credential, worker lifecycle, shutdown, recovery, protocol, and authentication suites: passed.
  • apps/daemon typecheck: passed.
  • tools/pack timeout suite and typecheck: passed.
  • Repository pnpm guard: passed.
  • Repository pnpm typecheck: passed.
  • git diff --check: passed.
  • Current PR CI: all required checks passed.

Remaining boundaries

  • The final head still needs a non-publishing Windows packaged run that exercises real DPAPI CRUD, authenticated named-pipe startup, recovery, and orphan cleanup together.
  • Explicit logon-SID pipe ACLs, remote-client rejection, and cross-user or connection-flood hardening remain a separate defense-in-depth decision; the HMAC handshake in this PR closes the first-connector impersonation path.
  • PR fix(web): surface BYOK migration validation errors #6280 (legacy BYOK migration error reporting) and powerformer/vela#961 are related context but independent changes.

@lefarcen
lefarcen requested a review from PerishCode July 30, 2026 11:00
@lefarcen lefarcen added size/M PR changes 100-300 lines risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps type/chore CI / build / config / tooling labels Jul 30, 2026
@open-design-crew

Copy link
Copy Markdown
Contributor Author

Fixes #6285

Why

Use case: While validating the Windows prerelease package, the existing BYOK credential smoke timed out even though the package itself built successfully.

Pain: The Windows backend launched a new encoded powershell.exe for its availability probe and every DPAPI set/get/delete operation. Packaged Windows could spend tens of seconds scanning each cold process, so a complete profile request crossed the 60-second Electron IPC timeout and surfaced as Failed to save BYOK credential (400).

What users will see

On Windows, saving, listing, resolving, and deleting BYOK profiles no longer pays a new PowerShell startup cost for every credential operation. The daemon initializes one DPAPI worker and reuses it. Existing DPAPI-encrypted .bin credentials remain compatible.

The first packaged DPAPI initialization can still take tens of seconds on a cold machine, but subsequent operations complete in milliseconds instead of repeatedly incurring that delay.

Surface area

  • UI — new page / dialog / panel / menu item / setting / empty state in apps/web or apps/desktop (including Electron menu bar)
  • Keyboard shortcut — new or changed
  • CLI / env var — new od subcommand or flag, new tools-dev / tools-pack flag, or new OD_* env var
  • API / contract — new /api/* endpoint, new SSE event, or changed shape in packages/contracts
  • Extension point — new entry under skills/, design-systems/, design-templates/, or craft/, or change to the skills protocol
  • i18n keys — added new translation keys (see TRANSLATIONS.md for the locale workflow)
  • New top-level dependency — adding any new entry to the root package.json (dependencies or devDependencies); workspace-package package.json files are out of scope. Include a paragraph on what we get vs. what bytes we ship (see CONTRIBUTING.md → Code style)
  • Default behavior change — changes what existing users experience without opting in (default model, default setting, file/SQLite schema, auto-network on startup, auto-install)
  • None — internal refactor, docs, tests, or translation update only

Screenshots

Not applicable. This changes the daemon's Windows credential backend only.

Bug fix verification

  • Permanent regression test: apps/daemon/tests/byok/credential-service.test.ts asserts that availability, set, get, and delete all reuse one Windows DPAPI worker.
  • Existing native Windows encryption smoke: apps/daemon/tests/byok/credential-service.windows.test.ts.
  • The failure is coupled to packaged Windows cold-start/security-scanning behavior, so a deterministic permanent time-threshold red spec would be flaky. The isolated diagnostic reproduced the original process-per-operation path on windows-latest, then validated the fixed packaged Electron/daemon path.
  • Red: prerelease run https://github.com/nexu-io/open-design/actions/runs/30527658367
  • Green non-publishing packaged validation: https://github.com/nexu-io/open-design/actions/runs/30546694911 (Windows job https://github.com/nexu-io/open-design/actions/runs/30546694911/job/90884697597)
  • Fixed packaged timings: initial list 41742 ms, create 113 ms, list 5 ms, delete 8 ms, final list 3 ms.
  • The isolated workflow code, diagnostic test, temporary timeout change, and diagnostic branch-only artifacts were removed before the final PR diff.

Validation

  • mise exec -- pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts tests/byok/credential-service.test.ts — 11 passed
  • mise exec -- pnpm --filter @open-design/daemon typecheck
  • mise exec -- pnpm guard
  • mise exec -- pnpm typecheck
  • git diff --check
  • Non-publishing real Windows packaged Electron/daemon CRUD validation above

Security and compatibility

  • DPAPI remains CurrentUser; the encrypted .bin format and API response shapes are unchanged.
  • Plaintext credentials do not enter argv, environment variables, temporary files, API responses, or diagnostics. Requests use a random process-local Windows named pipe to one long-lived worker.
  • No release workflow, installer identity, version, R2 object, updater metadata, packaged app entrypoint, or Vela code is changed.
  • PR fix(web): surface BYOK migration validation errors #6280 only changes web error/telemetry classification and has no direct effect on this DPAPI backend fix.

Cheems added 2 commits July 31, 2026 09:29
Keep one PowerShell DPAPI worker alive for the daemon lifetime so packaged Windows pays the expensive script and assembly startup cost once, then reuses a local named pipe for credential operations.

Preserve the existing DPAPI CurrentUser storage format and cover worker reuse across availability, set, get, and delete operations.
Match the packaged Windows smoke budget so the first DPAPI worker initialization can complete while retaining the existing timeout boundary for stalled desktop IPC calls.
@open-design-crew
open-design-crew Bot force-pushed the codex/test-windows-dpapi-diagnostic branch from a9d7e6a to 193e63d Compare July 31, 2026 01:33
@open-design-crew open-design-crew Bot changed the title test(ci): isolate Windows DPAPI stage diagnostics fix(byok): reuse the Windows DPAPI worker Jul 31, 2026

@PerishCode PerishCode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The long-lived worker removes repeated PowerShell startup cost, but its current lifecycle makes a transient worker failure permanent for the daemon and permits worker stalls to leave credential requests pending indefinitely. Both paths need bounded failure and recovery before this high-risk backend change is safe to merge.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

}

private getWorker(): Promise<WindowsDpapiWorkerClient> {
this.worker ??= this.options.createWorker?.() ?? WindowsDpapiWorker.create();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reset and recreate the cached worker after a worker-level failure. getWorker() stores one promise forever, while WindowsDpapiWorker.fail() permanently closes that instance; therefore a PowerShell crash, pipe disconnect, malformed response, or failed startup makes every later set/get/delete fail until the daemon is restarted. The previous per-operation implementation recovered naturally on the next call, so this is a production availability regression in the changed lifecycle. Wrap worker use so a failure clears this exact cached promise/instance (without racing a newer replacement), terminates the failed worker, and lets the next operation create a fresh worker; add a test that fails the first worker and proves a subsequent operation succeeds through a second worker.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

? {}
: { secret: Buffer.from(secret, 'utf8').toString('base64') }),
};
const response = await new Promise<WindowsDpapiWorkerResponse>((resolve, reject) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bound worker startup and each request with explicit deadlines. The connection race only rejects if PowerShell exits, readyPromise only rejects if the pipe/process emits a failure, and this response promise only settles when data or an error arrives. A live but wedged PowerShell process can therefore leave availability or BYOK CRUD pending forever; the new 60-second desktop eval timeout merely abandons the caller while the daemon operation and serialized queue remain stuck. Add startup/readiness and per-operation timers that call fail(), reject the pending work, kill the worker, and clean up the pipe server, with fake-clock tests for a worker that never connects, never sends ready, and never answers an operation.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen lefarcen added the backport release/v0.17.0 Backport this fix to release/v0.17.0 label Jul 31, 2026
Bound startup, handshake, operation, and shutdown phases, recycle failed worker generations without replaying uncertain mutations, and close owned PowerShell processes during daemon shutdown.

Add secret-safe diagnostics, protocol and input limits, plus regression coverage for crash recovery, parent-death cleanup, and the 60-second packaged eval contract.

Validated with focused BYOK lifecycle tests, daemon and tools-pack typechecks, pnpm guard, and the workspace typecheck.
@open-design-crew

Copy link
Copy Markdown
Contributor Author

Implemented the lifecycle/recovery follow-up in e95a5d56c.

Both blocking bot comments were valid and were fixed:

  1. Fatal worker failures now retire only the matching cached generation, close it, and let the next independent request create a replacement. The failed set/delete is not replayed because its side effect may already have committed before the response was lost.
  2. Connect, creation, ready, operation, and shutdown phases now have explicit deadlines. A timeout fails and disposes the stalled generation so the serialized queue cannot remain stuck indefinitely.

The same commit also adds daemon-owned shutdown, parent-PID orphan protection for force-terminated daemons, secret-safe structured diagnostics, API/protocol size limits, Windows native worker cleanup, and permanent regression tests. No bot suggestion was dismissed.

Local validation: 39 focused daemon tests passed with 2 Windows-only tests skipped on macOS; the exact 60-second tools-pack contract test passed; daemon/tools-pack package typechecks, pnpm guard, workspace pnpm typecheck, and git diff --check passed.

Remaining Windows evidence/security boundaries are explicit: the new native CRUD and parent-death tests still need exact-head Windows execution, and named-pipe ACL/peer hardening remains a separate security decision. Earlier non-publishing packaged evidence proves worker reuse and warm-path performance, but it does not substitute for validating this new lifecycle commit on Windows.

@PerishCode PerishCode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The worker recovery, bounded deadlines, and daemon shutdown ownership address the earlier lifecycle blockers. One security boundary remains: the long-lived plaintext credential channel does not authenticate the process that connects, so named-pipe hardening must land with this change rather than remain a follow-up.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

// the random per-process local pipe below, keeping plaintext credentials
// out of argv, environment variables, and temporary files.
child.stdin.end();
const connection = waitForConnection(server);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Authenticate the connecting pipe client before treating it as the DPAPI worker. This code races waitForConnection(server) against child failure and accepts whichever process connects first; the random pipe name is the only proof of identity. After line 360, every set request sends the API key in plaintext over that accepted socket, while get returns the decrypted key on it. A local process that discovers or guesses the pipe name can therefore win the race, send a fake ready frame, and read or forge credential operations. Restrict the pipe to the daemon user and verify the spawned worker (for example, validate the named-pipe client PID against child.pid, or require a per-worker challenge-response secret before ready), rejecting and closing any unverified connection. Add a Windows test where an impostor connects before the spawned child and prove it cannot receive a request or satisfy readiness.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@open-design-crew

Copy link
Copy Markdown
Contributor Author

Addressed the remaining PerishCode named-pipe peer-authentication blocker in d439809ad6d2dd55243a5f953b5360cf41fc939e.

Decision: the review suggestion was valid; no suggestion was dismissed.

  • Each worker generation now receives an independent 256-bit authentication key once through child stdin. The capability is not placed in argv, inherited environment variables, or temporary files, and both Node and PowerShell clear their mutable key buffers after the handshake.
  • The daemon sends a fresh 256-bit challenge for each candidate connection and accepts only a canonical HMAC-SHA256 proof before constructing the worker or accepting ready. A connection that sends a fake ready, malformed proof, oversized frame, or no proof is closed while the listener continues waiting for the real child.
  • No BYOK request or decrypted response can cross the pipe before authentication. Candidate authentication has its own 5-second deadline and bounded concurrency; the existing 50-second worker-connect deadline remains the outer availability bound.
  • The regression test deliberately connects an impostor first, sends a fake ready, proves that connection is closed, then authenticates the real worker and verifies the credential operation is delivered only there. The same test helper uses an actual Windows named-pipe address on Windows; the existing Windows-only real-PowerShell test also exercises the production handshake.

The HMAC gate closes the first-connector identity/confidentiality issue without adding a native dependency or changing the worker/daemon lifetime. Node's named-pipe server still uses the OS default descriptor; an unauthenticated connector can receive only a one-time random challenge and cannot reach readiness or credential traffic. A custom logon-SID DACL or explicit remote-client rejection would be an additional OS-level defense-in-depth layer, not the identity proof for this protocol.

Validation:

  • focused daemon lifecycle/security suite: 40 passed, 2 Windows-only skipped on macOS;
  • focused worker protocol suite: 15 passed;
  • tools-pack 60-second timeout contract: 1 passed;
  • daemon package typecheck, repository pnpm guard, repository pnpm typecheck, and git diff --check: passed.

The exact-head native Windows/non-publishing validation is still required before merge because local macOS cannot execute the real DPAPI and Windows orphan cases.

@PerishCode PerishCode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@app/open-design-crew The current head addresses the worker-lifecycle and named-pipe security concerns: failed generations are retired without replaying side effects, startup/readiness/operations/shutdown are bounded, daemon shutdown owns worker cleanup, and the HMAC challenge gates all credential traffic. The focused recovery, timeout, shutdown, orphan, native-Windows, and impostor-connection tests cover the changed behavior well. Thanks for the thorough follow-up and for turning each earlier blocker into explicit regression coverage.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen

Copy link
Copy Markdown
Contributor

Heads-up: PR #6308 is also open against the same Windows BYOK/DPAPI area. Both PRs touch apps/daemon/src/byok/credential-service.ts, apps/daemon/tests/byok/credential-service.test.ts, and Windows-specific coverage, but they take opposite approaches: #6282 keeps the backend via worker reuse, while #6308 withdraws it and restores the fail-closed path. Linking them here so maintainers can compare the two directions side by side.

@open-design-crew open-design-crew Bot changed the title fix(byok): reuse the Windows DPAPI worker fix(byok): harden the Windows DPAPI worker lifecycle Jul 31, 2026
@lefarcen

lefarcen commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Closing as superseded by #6308. That PR already merged the chosen fix direction for the same Windows BYOK/DPAPI surface, and this PR now conflicts with main while overlapping the same files and review area. If maintainers want to revisit the worker-reuse approach later, it can come back as a fresh PR against the current base.

@lefarcen lefarcen closed this Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport release/v0.17.0 Backport this fix to release/v0.17.0 risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps size/M PR changes 100-300 lines type/chore CI / build / config / tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Windows packaged BYOK CRUD times out during repeated DPAPI startup

2 participants