@bitkyc08/opencodex 2.65.0-preview.20260925 → 2.66.0-preview.20260925

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/README.md +14 -0
  2. package/gui/dist/assets/App-BVjA0T6O.js +51 -0
  3. package/gui/dist/assets/App-gkoJIEOV.css +1 -0
  4. package/gui/dist/assets/{Tray-DUvc_Wul.js → Tray-BCFtiWwD.js} +1 -1
  5. package/gui/dist/assets/index-ComVsdSk.css +1 -0
  6. package/gui/dist/assets/index-vO4dgfiU.js +86 -0
  7. package/gui/dist/assets/{tray-data-f0lTZ4sF.js → tray-data-C4FiNaxk.js} +1 -1
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -2
  10. package/src/adapters/anthropic/beta-allowlist.ts +80 -0
  11. package/src/adapters/anthropic/passthrough.ts +221 -0
  12. package/src/adapters/anthropic-image-codec.ts +56 -19
  13. package/src/adapters/anthropic-image-normalize.ts +20 -10
  14. package/src/adapters/anthropic.ts +55 -24
  15. package/src/adapters/coding-agent/protocol.ts +29 -19
  16. package/src/adapters/cursor/live-transport.ts +18 -3
  17. package/src/adapters/cursor/native-exec-shell.ts +18 -63
  18. package/src/adapters/cursor/native-exec.ts +5 -2
  19. package/src/adapters/cursor/native-foreground-shell.ts +44 -0
  20. package/src/adapters/devin/cloud-direct/chat.ts +7 -1
  21. package/src/adapters/exec-tool-result-normalize.ts +4 -1
  22. package/src/adapters/google.ts +12 -0
  23. package/src/adapters/ollama-native.ts +32 -1
  24. package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
  25. package/src/adapters/openai-chat.ts +6 -4
  26. package/src/adapters/openai-responses/passthrough.ts +1 -0
  27. package/src/adapters/openai-responses/reasoning.ts +45 -1
  28. package/src/bridge/response-json.ts +10 -2
  29. package/src/chat/outbound.ts +104 -52
  30. package/src/claude/claude-code-block.ts +16 -0
  31. package/src/claude/context-windows.ts +52 -4
  32. package/src/claude/desktop-first-party.ts +25 -8
  33. package/src/claude/inbound.ts +22 -0
  34. package/src/claude/intercept/connect-proxy.ts +37 -4
  35. package/src/claude/intercept/proxy-auth.ts +166 -0
  36. package/src/claude/intercept/runtime.ts +21 -0
  37. package/src/claude/intercept/settings.ts +33 -12
  38. package/src/claude/outbound.ts +45 -27
  39. package/src/cli/account-extended.ts +47 -15
  40. package/src/cli/account-target.ts +115 -0
  41. package/src/cli/account.ts +20 -28
  42. package/src/cli/agent.ts +10 -1
  43. package/src/cli/api-protocols.ts +206 -0
  44. package/src/cli/capabilities.ts +86 -0
  45. package/src/cli/combo.ts +2 -2
  46. package/src/cli/connect.ts +127 -3
  47. package/src/cli/dispatch.ts +8 -0
  48. package/src/cli/doctor.ts +33 -0
  49. package/src/cli/help.ts +2 -0
  50. package/src/cli/link.ts +288 -0
  51. package/src/cli/registry.ts +23 -0
  52. package/src/cli/runtime-api.ts +79 -0
  53. package/src/cli/system-command.ts +21 -2
  54. package/src/client/connect.ts +125 -23
  55. package/src/client/hub-relay.ts +12 -8
  56. package/src/client/link-join.ts +323 -0
  57. package/src/client/link-relay.ts +241 -0
  58. package/src/client/link-state.ts +110 -0
  59. package/src/client/link-teardown.ts +63 -0
  60. package/src/client/link-tunnel.ts +465 -0
  61. package/src/client/machine-listener.ts +14 -5
  62. package/src/client/runtime.ts +91 -42
  63. package/src/client/state.ts +4 -0
  64. package/src/codex/catalog/build-entries.ts +4 -0
  65. package/src/codex/catalog/derive-entry.ts +5 -0
  66. package/src/codex/catalog/parsing.ts +4 -0
  67. package/src/codex/catalog/retained-sync.ts +4 -1
  68. package/src/codex/desktop-switches.ts +88 -8
  69. package/src/codex/inject/plan.ts +16 -3
  70. package/src/codex/inject.ts +3 -0
  71. package/src/codex/internal/catalog-writer.ts +13 -2
  72. package/src/combos/failover.ts +3 -2
  73. package/src/combos/index.ts +12 -0
  74. package/src/combos/jev.ts +646 -0
  75. package/src/combos/request.ts +5 -0
  76. package/src/combos/resolve.ts +17 -15
  77. package/src/combos/types.ts +47 -3
  78. package/src/config/atomic-write.ts +3 -0
  79. package/src/config/live-reconcile.ts +20 -4
  80. package/src/config/persist-unlocked.ts +20 -7
  81. package/src/config/schema/config-schema.ts +16 -0
  82. package/src/config/schema/leaf-validators.ts +38 -1
  83. package/src/generated/compatibility-version.json +416 -128
  84. package/src/integrations/omp-yaml-source.ts +109 -4
  85. package/src/lab/automation/orchestrator.ts +60 -4
  86. package/src/lab/events/limits.ts +1 -1
  87. package/src/lib/bounded-body.ts +55 -38
  88. package/src/lib/config-ownership.ts +11 -22
  89. package/src/lib/local-desktop-snapshot-capability.ts +56 -0
  90. package/src/lib/local-management-capability.ts +25 -3
  91. package/src/lib/optional-shutdown-hooks.ts +17 -0
  92. package/src/lib/state-store-registrations.ts +2 -2
  93. package/src/lib/windows-elevation.ts +62 -25
  94. package/src/lib/windows-secret-acl.ts +114 -23
  95. package/src/link/admission-wait.ts +73 -0
  96. package/src/link/compensation.ts +100 -0
  97. package/src/link/fingerprint.ts +21 -0
  98. package/src/link/paths.ts +19 -0
  99. package/src/link/ports.ts +12 -0
  100. package/src/link/routes.ts +32 -0
  101. package/src/link/ssh-argv.ts +170 -0
  102. package/src/link/ssh-config.ts +152 -0
  103. package/src/link/ssh-runner.ts +161 -0
  104. package/src/link/status-projection.ts +96 -0
  105. package/src/link/store.ts +139 -0
  106. package/src/link/supervisor.ts +404 -0
  107. package/src/link/tunnel-state.ts +91 -0
  108. package/src/protocols/baseline.ts +88 -0
  109. package/src/protocols/codecs/chat.ts +17 -0
  110. package/src/protocols/codecs/messages.ts +18 -0
  111. package/src/protocols/codecs/responses.ts +17 -0
  112. package/src/protocols/contract.ts +185 -0
  113. package/src/protocols/dto.ts +267 -0
  114. package/src/protocols/encoders/adapter-events.ts +876 -0
  115. package/src/protocols/encoders/chat.ts +243 -0
  116. package/src/protocols/encoders/messages.ts +462 -0
  117. package/src/protocols/envelope.ts +43 -0
  118. package/src/protocols/features.ts +295 -0
  119. package/src/protocols/guard.ts +33 -0
  120. package/src/protocols/opaque-state.ts +132 -0
  121. package/src/protocols/path.ts +39 -0
  122. package/src/protocols/plan-snapshot.ts +226 -0
  123. package/src/protocols/plan.ts +192 -0
  124. package/src/protocols/provider-summary.ts +101 -0
  125. package/src/protocols/settings.ts +118 -0
  126. package/src/protocols/shadow-plan.ts +36 -0
  127. package/src/protocols/shadow.ts +62 -0
  128. package/src/protocols/trace.ts +335 -0
  129. package/src/providers/model-rename-migration.ts +3 -3
  130. package/src/providers/registry/entries-core.ts +6 -1
  131. package/src/providers/registry/entries-extended.ts +15 -0
  132. package/src/providers/registry/model-ids.ts +1 -0
  133. package/src/providers/registry/types.ts +6 -0
  134. package/src/providers/registry.ts +1 -1
  135. package/src/remote-control/workspace-hub.ts +5 -4
  136. package/src/responses/apply-patch-envelope.ts +6 -1
  137. package/src/responses/code-mode-shell-input.ts +4 -2
  138. package/src/responses/freeform-wrapper-scan.ts +51 -24
  139. package/src/responses/progressive-freeform-input.ts +14 -20
  140. package/src/responses/spill-store.ts +187 -33
  141. package/src/responses/state/snapshot-select.ts +49 -0
  142. package/src/responses/state/spill-inspect.ts +46 -0
  143. package/src/responses/state/spill-queue.ts +16 -0
  144. package/src/responses/state.ts +51 -27
  145. package/src/server/audio-client.ts +8 -3
  146. package/src/server/audio-upstream.ts +9 -4
  147. package/src/server/auth-cors.ts +25 -8
  148. package/src/server/chat-completions.ts +71 -11
  149. package/src/server/chat-native-eligibility.ts +74 -0
  150. package/src/server/chat-native.ts +139 -74
  151. package/src/server/claude-messages.ts +140 -22
  152. package/src/server/hub-usage.ts +4 -3
  153. package/src/server/index/link-listener.ts +195 -0
  154. package/src/server/index/optional-listeners.ts +115 -0
  155. package/src/server/index/serve-options.ts +44 -10
  156. package/src/server/index.ts +10 -9
  157. package/src/server/inference/attempt.ts +51 -0
  158. package/src/server/inference/client-encoder-delivery.ts +235 -0
  159. package/src/server/inference/client-wire-log.ts +49 -0
  160. package/src/server/inference/client-wire.ts +69 -0
  161. package/src/server/inference/context.ts +15 -0
  162. package/src/server/inference/final-log.ts +37 -0
  163. package/src/server/local-desktop-snapshot-auth.ts +57 -0
  164. package/src/server/management/agent-settings-routes.ts +15 -13
  165. package/src/server/management/api-access.ts +11 -1
  166. package/src/server/management/config-routes.ts +3 -6
  167. package/src/server/management/context.ts +25 -0
  168. package/src/server/management/link-routes.ts +535 -0
  169. package/src/server/management/logs-usage-routes.ts +31 -1
  170. package/src/server/management/native-integration-routes.ts +6 -11
  171. package/src/server/management/oauth-account-routes.ts +42 -16
  172. package/src/server/management/protocol-routes.ts +159 -0
  173. package/src/server/management/protocol-settings-patch.ts +145 -0
  174. package/src/server/management/provider-patch-transaction.ts +61 -0
  175. package/src/server/management/provider-routes.ts +39 -18
  176. package/src/server/management/route-registry.ts +12 -0
  177. package/src/server/management/sidebar-routes.ts +8 -3
  178. package/src/server/management/system-routes.ts +18 -1
  179. package/src/server/management/usage-aggregate-cache.ts +206 -6
  180. package/src/server/management-api.ts +26 -1
  181. package/src/server/management-auth.ts +19 -5
  182. package/src/server/messages-native-eligibility.ts +161 -0
  183. package/src/server/messages-native-oauth.ts +149 -0
  184. package/src/server/messages-native.ts +790 -0
  185. package/src/server/relay.ts +9 -0
  186. package/src/server/request-log-filter.ts +92 -0
  187. package/src/server/request-log.ts +31 -70
  188. package/src/server/responses/adapter-delivery.ts +43 -16
  189. package/src/server/responses/agent-task-recovery.ts +90 -22
  190. package/src/server/responses/codex-ws-exchange.ts +4 -0
  191. package/src/server/responses/codex-ws-wire.ts +10 -1
  192. package/src/server/responses/core-combo-native.ts +323 -0
  193. package/src/server/responses/core-combo.ts +224 -13
  194. package/src/server/responses/core-options.ts +22 -0
  195. package/src/server/responses/core.ts +14 -26
  196. package/src/server/responses/request-sidecar-auth.ts +8 -3
  197. package/src/server/responses/run-turn-execution.ts +257 -63
  198. package/src/server/responses/sidecar-execution.ts +7 -4
  199. package/src/server/system-env.ts +7 -0
  200. package/src/service/diagnostics.ts +7 -1
  201. package/src/service/launchd.ts +26 -37
  202. package/src/service/windows-ops.ts +24 -20
  203. package/src/types/config.ts +58 -1
  204. package/src/types.ts +2 -0
  205. package/src/update/transactional-install.d.mts +0 -1
  206. package/src/update/transactional-install.mjs +17 -17
  207. package/src/usage/jev-stats.ts +495 -0
  208. package/src/usage/log.ts +24 -3
  209. package/src/web-search/run-turn-loop.ts +568 -0
  210. package/gui/dist/assets/App-8NMiZxT0.css +0 -1
  211. package/gui/dist/assets/App-CsYvvpr3.js +0 -50
  212. package/gui/dist/assets/index--EWgGQvZ.css +0 -1
  213. package/gui/dist/assets/index-BB0iHG8-.js +0 -86
@@ -253,12 +253,15 @@ export const OCX_ELEVATED_UAC_CANCELLED = 1223;
253
253
  /**
254
254
  * The elevated process could not read a staged payload (#4692).
255
255
  *
256
- * `hardenSecretPath` grants the staging account and strips inheritance, so a split-token
257
- * elevation of the same user reads the file and an elevation answered with a DIFFERENT
258
- * administrator's credentials does not. The elevated side cannot explain that itself: it
259
- * runs hidden, so its stderr goes nowhere and only the exit code survives the boundary.
260
- * Without a code of its own the operator would be told "exit code 1" for a cause that
261
- * names its own remedy — the same undiagnosable failure this change set exists to remove.
256
+ * The staged ACL grants the staging account plus read for SYSTEM and
257
+ * BUILTIN\Administrators (#4779), so both a split-token elevation and an
258
+ * over-the-shoulder one answered with a different administrator's credentials
259
+ * can open it. A residual read failure means the hardening did not take effect
260
+ * or the payload was replaced. The elevated side cannot explain that itself: it
261
+ * runs hidden, so its stderr goes nowhere and only the exit code survives the
262
+ * boundary. Without a code of its own the operator would be told "exit code 1"
263
+ * for a cause that names its own remedy — the same undiagnosable failure this
264
+ * change set exists to remove.
262
265
  *
263
266
  * Deliberately outside OCX_ELEVATED_PROTOCOL_CODES: that list is the create-and-run
264
267
  * transaction's alphabet, and this code belongs to the registration path.
@@ -662,14 +665,17 @@ export function runWindowsElevated(file: string, args: string[]): Promise<number
662
665
  /**
663
666
  * A task definition staged for the elevated process.
664
667
  *
665
- * The bytes live in a freshly created, ACL-hardened private directory, and the digest is
666
- * taken over exactly those bytes by the caller that validated them. The elevated script
667
- * reads the file once, hashes what it read, and refuses unless the digest matches, so a
668
- * pathname is no longer a promise about content — it is a claim the receiver checks.
668
+ * Before elevation, the launcher pins the file and every ancestor with non-reparse
669
+ * handles: payload files deny write/delete sharing, ancestor directories deny delete
670
+ * sharing only (writes inside them stay possible so the stage can be populated).
671
+ * The elevated script additionally bounds and
672
+ * hashes the exact bytes it decodes.
669
673
  */
670
674
  export interface StagedWindowsTaskXml {
671
675
  /** Path inside the caller's hardened staging directory. */
672
676
  readonly path: string;
677
+ /** Exact byte length, checked before the elevated process allocates or reads. */
678
+ readonly byteLength: number;
673
679
  /** Lowercase hex SHA-256 of the staged bytes (UTF-16LE, no BOM). */
674
680
  readonly sha256: string;
675
681
  }
@@ -677,16 +683,18 @@ export interface StagedWindowsTaskXml {
677
683
  /**
678
684
  * Read a staged payload, prove it is the one that was validated, and decode it.
679
685
  *
680
- * One read: the bytes that are hashed are the same array that is decoded and registered.
681
- * Hashing a path and then reopening it would reintroduce the swap window this check
682
- * exists to close.
686
+ * One bounded read: the bytes that are hashed are the same array that is decoded and
687
+ * registered. The unelevated launcher keeps the namespace and files pinned throughout.
683
688
  */
684
- const READ_STAGED_TASK_XML = "function Read-OcxStagedTaskXml([string]$path, [string]$expectedHash) {"
689
+ const READ_STAGED_TASK_XML = "function Read-OcxStagedTaskXml([string]$path, [long]$expectedLength, [string]$expectedHash) {"
685
690
  // An unreadable payload is a diagnosable condition, not a generic throw: a hidden
686
691
  // elevated process has nowhere to print, so the cause has to ride the exit code.
687
- + " try { $bytes = [IO.File]::ReadAllBytes($path) }"
692
+ + " try { $stream = [IO.File]::Open($path, 'Open', 'Read', 'Read');"
693
+ + " if ($stream.Length -ne $expectedLength) { throw 'Task Scheduler staged payload has an invalid length.' };"
694
+ + " $bytes = [byte[]]::new($expectedLength); $offset = 0;"
695
+ + " while ($offset -lt $bytes.Length) { $read = $stream.Read($bytes, $offset, $bytes.Length - $offset); if ($read -eq 0) { throw 'Task Scheduler staged payload ended early.' }; $offset += $read } }"
688
696
  + " catch [System.UnauthorizedAccessException] { exit " + OCX_ELEVATED_STAGING_UNREADABLE + " }"
689
- + " catch [System.Security.SecurityException] { exit " + OCX_ELEVATED_STAGING_UNREADABLE + " };"
697
+ + " catch [System.Security.SecurityException] { exit " + OCX_ELEVATED_STAGING_UNREADABLE + " } finally { if ($null -ne $stream) { $stream.Dispose() } };"
690
698
  + " $sha = [Security.Cryptography.SHA256]::Create();"
691
699
  + " try { $actual = [BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() };"
692
700
  + " if ($actual -cne $expectedHash) { throw 'Task Scheduler staged payload failed its integrity check.' };"
@@ -706,12 +714,12 @@ const READ_STAGED_TASK_XML = "function Read-OcxStagedTaskXml([string]$path, [str
706
714
  * The command now carries two paths and two 64-character digests, so its length no
707
715
  * longer depends on the size of the XML at all.
708
716
  *
709
- * The original design goal was "immutable bytes, never a caller-writable pathname".
710
- * That goal is kept by different means rather than abandoned: the staging directory is
711
- * private and ACL-hardened, the files are created exclusively so nothing can be waiting
712
- * at the path, and the digest makes a same-account swap during the UAC prompt fail
713
- * closed instead of registering something else. An ACL alone could not do that last
714
- * part, because a process running as the same user has the same SID.
717
+ * The unelevated launcher opens every ancestor and payload with OPEN_REPARSE_POINT,
718
+ * validates its type, and holds them until the elevated process exits: ancestors deny
719
+ * delete sharing (read/write stay shared, so sibling files can still be staged) and
720
+ * each payload denies write/delete sharing.
721
+ * Thus the privileged open cannot be redirected during UAC; the length and digest are
722
+ * defense in depth for the bytes read from the pinned regular file.
715
723
  *
716
724
  * The replacement precondition is unchanged: the elevated process still re-queries the
717
725
  * live registration and compares it to the captured predecessor before passing -Force.
@@ -725,18 +733,40 @@ export function runWindowsElevatedScheduledTaskRegistration(
725
733
  if (replace && !expectedExisting) {
726
734
  throw new Error("Elevated Task Scheduler replacement requires a captured existing definition.");
727
735
  }
736
+ for (const payload of [xml, expectedExisting].filter((value): value is StagedWindowsTaskXml => value !== undefined)) {
737
+ if (!Number.isSafeInteger(payload.byteLength) || payload.byteLength < 0 || !/^[0-9a-f]{64}$/.test(payload.sha256)) {
738
+ throw new Error("Elevated Task Scheduler staging metadata is invalid.");
739
+ }
740
+ }
728
741
  const powerShellPath = windowsPowerShell();
729
742
  const powerShellDirectory = powerShellPath.replace(/[\\/][^\\/]+$/, "");
730
743
  const scheduledTasksModule = `${powerShellDirectory}\\Modules\\ScheduledTasks\\ScheduledTasks.psd1`;
744
+ const parentDirectory = (path: string) => path.replace(/[\\/][^\\/]+$/, "");
745
+ const stageDirectory = parentDirectory(xml.path);
746
+ // The pin only covers the staging directory itself: a payload nested deeper
747
+ // would sit inside a folder nobody locked, so each file must name the staging
748
+ // directory as its immediate parent rather than merely carrying its prefix.
749
+ // Traversal segments are rejected on either separator before the parent is
750
+ // compared, and a bare filename has no parent directory at all.
751
+ const stagedDirectlyInside = (path: string) =>
752
+ !path.split(/[\\/]+/).includes("..")
753
+ && parentDirectory(path) === stageDirectory
754
+ && parentDirectory(path) !== path;
755
+ if (
756
+ !stagedDirectlyInside(xml.path)
757
+ || (expectedExisting && !stagedDirectlyInside(expectedExisting.path))
758
+ ) {
759
+ throw new Error("Elevated Task Scheduler payloads must share one staging directory.");
760
+ }
731
761
  const inner = [
732
762
  `$taskName = ${psSingleQuote(taskName)}`,
733
763
  READ_STAGED_TASK_XML,
734
- `$xml = Read-OcxStagedTaskXml ${psSingleQuote(xml.path)} ${psSingleQuote(xml.sha256)}`,
764
+ `$xml = Read-OcxStagedTaskXml ${psSingleQuote(xml.path)} ${xml.byteLength} ${psSingleQuote(xml.sha256)}`,
735
765
  `$module = Microsoft.PowerShell.Core\\Import-Module -Name ${psSingleQuote(scheduledTasksModule)} -PassThru -Force -ErrorAction Stop`,
736
766
  "$registerTask = $module.ExportedCommands['Register-ScheduledTask']",
737
767
  "if ($null -eq $registerTask) { throw 'Trusted ScheduledTasks module does not export Register-ScheduledTask.' }",
738
768
  ...(replace ? [
739
- `$expectedXml = Read-OcxStagedTaskXml ${psSingleQuote(expectedExisting!.path)} ${psSingleQuote(expectedExisting!.sha256)}`,
769
+ `$expectedXml = Read-OcxStagedTaskXml ${psSingleQuote(expectedExisting!.path)} ${expectedExisting!.byteLength} ${psSingleQuote(expectedExisting!.sha256)}`,
740
770
  `$schtasks = ${psSingleQuote(resolveTrustedWindowsSchtasksExe())}`,
741
771
  "$currentXml = & $schtasks /query /tn $taskName /xml 2>$null | Out-String",
742
772
  "if ($LASTEXITCODE -ne 0) { throw 'Task Scheduler replacement precondition could not be read.' }",
@@ -747,6 +777,13 @@ export function runWindowsElevatedScheduledTaskRegistration(
747
777
  ].join("; ");
748
778
  const encodedCommand = Buffer.from(inner, "utf16le").toString("base64");
749
779
  const script = [
780
+ "Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class OcxStageLock { [StructLayout(LayoutKind.Sequential)] public struct TagInfo { public uint Attributes; public uint Tag; } [DllImport(\"kernel32.dll\", CharSet=CharSet.Unicode, SetLastError=true)] public static extern Microsoft.Win32.SafeHandles.SafeFileHandle CreateFile(string p, uint a, uint s, IntPtr q, uint c, uint f, IntPtr t); [DllImport(\"kernel32.dll\", SetLastError=true)] public static extern bool GetFileInformationByHandleEx(Microsoft.Win32.SafeHandles.SafeFileHandle h, int c, out TagInfo i, uint n); }';",
781
+ "$locks = @();",
782
+ "function Lock-OcxStage([string]$path, [bool]$directory) { $flags = 0x00200000; $share = 1; if ($directory) { $flags = $flags -bor 0x02000000; $share = 3 }; $h = [OcxStageLock]::CreateFile($path, 0x80000000, $share, [IntPtr]::Zero, 3, $flags, [IntPtr]::Zero); if ($h.IsInvalid) { throw 'Task Scheduler staging lock failed.' }; $info = [OcxStageLock+TagInfo]::new(); if (![OcxStageLock]::GetFileInformationByHandleEx($h, 9, [ref]$info, 8) -or (($info.Attributes -band 0x400) -ne 0) -or $directory -ne (($info.Attributes -band 0x10) -ne 0)) { $h.Dispose(); throw 'Task Scheduler staging path is redirected or has the wrong type.' }; $script:locks += $h };",
783
+ `$dir = [IO.DirectoryInfo]::new(${psSingleQuote(stageDirectory)}); $dirs = @(); while ($null -ne $dir) { $dirs += $dir.FullName; $dir = $dir.Parent }; [array]::Reverse($dirs); $dirs | ForEach-Object { Lock-OcxStage $_ $true };`,
784
+ `Lock-OcxStage ${psSingleQuote(xml.path)} $false;`,
785
+ ...(expectedExisting ? [`Lock-OcxStage ${psSingleQuote(expectedExisting.path)} $false;`] : []),
786
+ "try {",
750
787
  `$p = Start-Process -FilePath ${psSingleQuote(powerShellPath)}`,
751
788
  ` -ArgumentList ${psSingleQuote(buildWindowsElevatedArgumentList([
752
789
  "-NoProfile",
@@ -760,7 +797,7 @@ export function runWindowsElevatedScheduledTaskRegistration(
760
797
  `if ($null -eq $p) { exit ${OCX_ELEVATED_UAC_CANCELLED} }`,
761
798
  "$null = $p.Handle;",
762
799
  `if ($null -eq $p.ExitCode) { exit ${OCX_ELEVATED_PROTOCOL_FAILED} }`,
763
- "exit $p.ExitCode",
800
+ "$code = $p.ExitCode } finally { $locks | ForEach-Object { $_.Dispose() } }; exit $code",
764
801
  ].join("");
765
802
 
766
803
  return startPowerShellCommand(script).completion.then(result => result.exitCode);
@@ -27,6 +27,13 @@
27
27
  * HardenOptions.timeoutMemoKey — optional destination-path key for the
28
28
  * timeout memo (atomic writers mint unique temps; never a parent directory).
29
29
  * hardenSecretDir — same contract for directories.
30
+ * hardenElevatedStagePath / hardenElevatedStageDir — a second, deliberately
31
+ * separate ACL shape for NON-secret payloads an elevated process must read
32
+ * (issue #4779). It keeps the owner grant and the broad-SID strip but also
33
+ * grants read to SYSTEM and BUILTIN\Administrators. It is a distinct export
34
+ * — not an option on the secret functions — so a secret call site cannot
35
+ * reach the wider shape by accident, and its success memos live in their
36
+ * own caches so one shape never satisfies the other's lookup.
30
37
  */
31
38
 
32
39
  import { existsSync, statSync } from "node:fs";
@@ -47,12 +54,32 @@ import {
47
54
 
48
55
  const hardenedDirectories = new Map<string, HardenedIdentity>();
49
56
  const hardenedPaths = new Map<string, HardenedIdentity>();
57
+ /** Elevated-stage memos are kept apart from the secret ones: the ACL shapes differ. */
58
+ const hardenedStageDirectories = new Map<string, HardenedIdentity>();
59
+ const hardenedStagePaths = new Map<string, HardenedIdentity>();
50
60
  /**
51
61
  * Paths whose harden TIMED OUT this process: do not re-stall every loadConfig on them.
52
- * `false` means one explicitly authorized recovery attempt remains; `true` means
53
- * that attempt was consumed. Ordinary callers never consume it.
62
+ * `consumed: false` means one explicitly authorized recovery attempt remains;
63
+ * `consumed: true` means that attempt was consumed at `consumedAt`.
64
+ *
65
+ * A consumed memo re-arms once per `TIMEOUT_MEMO_REARM_MS` window, and only for a
66
+ * caller carrying `retryTimedOutOnce` (issue #3522). The destination-keyed memo for a
67
+ * STABLE path — the response-spill directory — used to refuse every later harden for
68
+ * the life of the process: one transient icacls outage consumed the single recovery
69
+ * and durable publication never succeeded again, while the process reported healthy.
70
+ * The window keeps anti-restall bounded (at most one real attempt per window) while a
71
+ * recovered runner can clear the memo and resume publication without a restart.
72
+ */
73
+ const timedOutPaths = new Map<string, { consumed: boolean; consumedAt: number }>();
74
+
75
+ /**
76
+ * Quiet period before a consumed timeout memo admits one caller-owned recovery
77
+ * attempt. Far above the per-harden deadline cap (60s) so a still-stalled icacls
78
+ * produces at most one bounded probe per window and every other refusal stays
79
+ * instant; short enough that a recovered icacls resumes same-process hardening
80
+ * without waiting for a restart.
54
81
  */
55
- const timedOutPaths = new Map<string, boolean>();
82
+ export const TIMEOUT_MEMO_REARM_MS = 5 * 60_000;
56
83
  /** Compatibility slack before the outer belt releases a caller whose killed child has not reaped. */
57
84
  const ASYNC_ICACLS_BELT_MARGIN_MS = 250;
58
85
  const pendingAsyncIcaclsReaps = new Map<string, Set<Promise<void>>>();
@@ -252,7 +279,8 @@ export interface HardenOptions {
252
279
  /**
253
280
  * Consume the one recovery attempt for a previously timed-out memo key.
254
281
  * Only a caller that owns its own single-flight and bounded retry policy should
255
- * set this. It never clears or bypasses an already-consumed timeout memo.
282
+ * set this. It never clears or bypasses an already-consumed timeout memo before
283
+ * its re-arm window (`TIMEOUT_MEMO_REARM_MS`) has elapsed.
256
284
  */
257
285
  retryTimedOutOnce?: boolean;
258
286
  }
@@ -499,6 +527,8 @@ export function setNowForTests(fn: (() => number) | null): void {
499
527
  export function resetHardenedStateForTests(): void {
500
528
  hardenedDirectories.clear();
501
529
  hardenedPaths.clear();
530
+ hardenedStageDirectories.clear();
531
+ hardenedStagePaths.clear();
502
532
  timedOutPaths.clear();
503
533
  }
504
534
 
@@ -568,6 +598,7 @@ export function reattributeHardenedSecretPath(targetPath: string): boolean {
568
598
  */
569
599
  export function forgetEphemeralSecretPath(tempPath: string): void {
570
600
  hardenedPaths.delete(tempPath);
601
+ hardenedStagePaths.delete(tempPath);
571
602
  timedOutPaths.delete(`required:${tempPath}`);
572
603
  timedOutPaths.delete(`optional:${tempPath}`);
573
604
  }
@@ -575,6 +606,7 @@ export function forgetEphemeralSecretPath(tempPath: string): void {
575
606
  /** Directory counterpart for a proven-absent ephemeral staging root. */
576
607
  export function forgetEphemeralSecretDir(tempPath: string): void {
577
608
  hardenedDirectories.delete(tempPath);
609
+ hardenedStageDirectories.delete(tempPath);
578
610
  timedOutPaths.delete(`required:${tempPath}`);
579
611
  timedOutPaths.delete(`optional:${tempPath}`);
580
612
  }
@@ -650,10 +682,33 @@ async function currentWindowsPrincipalAsync(deadline: number): Promise<string> {
650
682
  */
651
683
  const BROAD_SIDS = ["*S-1-1-0", "*S-1-5-11", "*S-1-5-32-545"] as const;
652
684
 
685
+ /**
686
+ * Well-known SIDs an elevated process may run as (#4779).
687
+ *
688
+ * An over-the-shoulder UAC prompt answered with a DIFFERENT administrator's
689
+ * credentials produces an elevated token whose subject is that admin — not the
690
+ * account that staged the payload. BUILTIN\Administrators covers that token;
691
+ * SYSTEM covers an elevation launched from a service context. These SIDs are
692
+ * deliberately absent from BROAD_SIDS: the staged payload must stay readable
693
+ * by them after /remove:g runs.
694
+ */
695
+ const ELEVATED_STAGE_READ_SIDS = ["*S-1-5-18", "*S-1-5-32-544"] as const;
696
+
653
697
  function grantAce(user: string, directory: boolean): string {
654
698
  return directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
655
699
  }
656
700
 
701
+ /**
702
+ * Read-only ACEs for the elevated-stage shape: read on a file, read+traverse
703
+ * inherited by children on a directory. Read only — tamper-evidence comes from
704
+ * the pinned handles and the SHA-256 the elevated script verifies, not from the
705
+ * DACL, so no elevated principal needs write.
706
+ */
707
+ function elevatedStageReadAces(directory: boolean): string[] {
708
+ const rights = directory ? "(OI)(CI)(RX)" : "(R)";
709
+ return ELEVATED_STAGE_READ_SIDS.map(sid => `${sid}:${rights}`);
710
+ }
711
+
657
712
  function existingAclIsCompliant(
658
713
  targetPath: string,
659
714
  directory: boolean,
@@ -722,7 +777,7 @@ async function existingAclAlreadyCompliantAsync(
722
777
  }
723
778
  }
724
779
 
725
- function runIcacls(targetPath: string, directory: boolean, deadline: number): void {
780
+ function runIcacls(targetPath: string, directory: boolean, deadline: number, extraReadAces: readonly string[]): void {
726
781
  const principal = currentWindowsPrincipal(deadline);
727
782
 
728
783
  // The deadline is owned by hardenEntry (total budget incl. retry + verification).
@@ -740,7 +795,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo
740
795
 
741
796
  // Step 1: grant current user full control BEFORE any destructive ACL change.
742
797
  // If this fails, inheritance is untouched and the writer keeps inherited access.
743
- runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory)]);
798
+ runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory), ...extraReadAces]);
744
799
 
745
800
  // Step 2: disable inheritance and remove inherited ACEs. The explicit owner ACE
746
801
  // from step 1 survives this transition, so a later failure still leaves cleanup access.
@@ -768,7 +823,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo
768
823
  }
769
824
 
770
825
  /** Async counterpart of runIcacls — same step order and timeout/error classification (#612). */
771
- async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: number): Promise<void> {
826
+ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: number, extraReadAces: readonly string[]): Promise<void> {
772
827
  const principal = await currentWindowsPrincipalAsync(deadline);
773
828
 
774
829
  const run = async (step: string, args: string[]): Promise<IcaclsResult> => {
@@ -783,7 +838,7 @@ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline:
783
838
  if (!result.success) throw icaclsError(step, result);
784
839
  };
785
840
 
786
- await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory)]);
841
+ await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory), ...extraReadAces]);
787
842
  await runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]);
788
843
 
789
844
  const removal = await run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]);
@@ -869,22 +924,30 @@ function previousTimeoutError(retryConsumed: boolean): TimeoutMemoRefusalError {
869
924
  ), { aclFailureOrigin: "timeout_memo_refusal" as const });
870
925
  }
871
926
 
872
- /** Consume, but never reset, the single explicit recovery attempt for this key. */
927
+ /**
928
+ * Consume the single explicit recovery attempt for this key. A consumed key re-arms
929
+ * only after `TIMEOUT_MEMO_REARM_MS` of quiet, and only for `retryTimedOutOnce` —
930
+ * flagless callers always get an instant refusal.
931
+ */
873
932
  function timeoutMemoErrorIfBlocked(
874
933
  memoKey: string,
875
934
  opts: HardenOptions,
876
935
  ): NodeJS.ErrnoException | null {
877
- const retryConsumed = timedOutPaths.get(memoKey);
878
- if (retryConsumed === undefined) return null;
879
- if (opts.retryTimedOutOnce && retryConsumed === false) {
880
- timedOutPaths.set(memoKey, true);
936
+ const entry = timedOutPaths.get(memoKey);
937
+ if (entry === undefined) return null;
938
+ // A re-armed memo reports the plain previous-timeout refusal to flagless callers
939
+ // (so a retry-owning caller that saw it comes back carrying the flag) but admits
940
+ // one fresh attempt only to `retryTimedOutOnce`.
941
+ const rearmed = entry.consumed && nowFn() - entry.consumedAt >= TIMEOUT_MEMO_REARM_MS;
942
+ if (opts.retryTimedOutOnce && (!entry.consumed || rearmed)) {
943
+ timedOutPaths.set(memoKey, { consumed: true, consumedAt: nowFn() });
881
944
  return null;
882
945
  }
883
- return previousTimeoutError(retryConsumed);
946
+ return previousTimeoutError(entry.consumed && !rearmed);
884
947
  }
885
948
 
886
949
  function recordTimeout(memoKey: string): void {
887
- if (!timedOutPaths.has(memoKey)) timedOutPaths.set(memoKey, false);
950
+ if (!timedOutPaths.has(memoKey)) timedOutPaths.set(memoKey, { consumed: false, consumedAt: 0 });
888
951
  }
889
952
 
890
953
  /**
@@ -944,6 +1007,7 @@ function hardenEntry(
944
1007
  directory: boolean,
945
1008
  opts: HardenOptions,
946
1009
  cache: Map<string, HardenedIdentity>,
1010
+ extraReadAces: readonly string[],
947
1011
  ): HardenResult {
948
1012
  // Observed absence retires the memo. Leaving it would let a later file at this
949
1013
  // path satisfy the cache if the filesystem ever hands back a matching identity.
@@ -951,7 +1015,8 @@ function hardenEntry(
951
1015
  if (effectivePlatform() !== "win32") return { ok: true };
952
1016
  if (memoSatisfied(cache, targetPath)) return { ok: true };
953
1017
  const deadline = nowFn() + resolveHardenDeadlineMs(opts.deadlineMs);
954
- if (existingAclAlreadyCompliant(targetPath, directory, deadline)) return { ok: true };
1018
+ // The owner-only compliance probe cannot vouch for a shape that also needs read ACEs.
1019
+ if (extraReadAces.length === 0 && existingAclAlreadyCompliant(targetPath, directory, deadline)) return { ok: true };
955
1020
  const memoKey = timeoutMemoKey(targetPath, opts);
956
1021
  const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
957
1022
  if (timeoutMemoError) {
@@ -965,7 +1030,7 @@ function hardenEntry(
965
1030
  try {
966
1031
  // Captured BEFORE the sequence: this is the file we are about to harden.
967
1032
  const before = observe(targetPath);
968
- runIcacls(targetPath, directory, deadline);
1033
+ runIcacls(targetPath, directory, deadline, extraReadAces);
969
1034
  if (!recordHarden(cache, targetPath, before)) {
970
1035
  if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC);
971
1036
  return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC };
@@ -999,12 +1064,13 @@ async function hardenEntryAsync(
999
1064
  directory: boolean,
1000
1065
  opts: HardenOptions,
1001
1066
  cache: Map<string, HardenedIdentity>,
1067
+ extraReadAces: readonly string[],
1002
1068
  ): Promise<HardenResult> {
1003
1069
  if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
1004
1070
  if (effectivePlatform() !== "win32") return { ok: true };
1005
1071
  if (memoSatisfied(cache, targetPath)) return { ok: true };
1006
1072
  const deadline = nowFn() + resolveHardenDeadlineMs(opts.deadlineMs);
1007
- if (await existingAclAlreadyCompliantAsync(targetPath, directory, deadline)) return { ok: true };
1073
+ if (extraReadAces.length === 0 && await existingAclAlreadyCompliantAsync(targetPath, directory, deadline)) return { ok: true };
1008
1074
  const memoKey = timeoutMemoKey(targetPath, opts);
1009
1075
  const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
1010
1076
  if (timeoutMemoError) {
@@ -1017,7 +1083,7 @@ async function hardenEntryAsync(
1017
1083
  if (attempt > 0 && deadline - nowFn() <= 0) break;
1018
1084
  try {
1019
1085
  const before = observe(targetPath);
1020
- await runIcaclsAsync(targetPath, directory, deadline);
1086
+ await runIcaclsAsync(targetPath, directory, deadline, extraReadAces);
1021
1087
  if (!recordHarden(cache, targetPath, before)) {
1022
1088
  if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC);
1023
1089
  return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC };
@@ -1052,7 +1118,7 @@ async function hardenEntryAsync(
1052
1118
  * @param opts { required: boolean } — required:true throws on failure.
1053
1119
  */
1054
1120
  export function hardenSecretPath(targetPath: string, opts: HardenOptions): HardenResult {
1055
- return hardenEntry(targetPath, false, opts, hardenedPaths);
1121
+ return hardenEntry(targetPath, false, opts, hardenedPaths, []);
1056
1122
  }
1057
1123
 
1058
1124
  /**
@@ -1060,7 +1126,7 @@ export function hardenSecretPath(targetPath: string, opts: HardenOptions): Harde
1060
1126
  * Same success/timeout/error policy as hardenSecretPath.
1061
1127
  */
1062
1128
  export function hardenSecretPathAsync(targetPath: string, opts: HardenOptions): Promise<HardenResult> {
1063
- return hardenEntryAsync(targetPath, false, opts, hardenedPaths);
1129
+ return hardenEntryAsync(targetPath, false, opts, hardenedPaths, []);
1064
1130
  }
1065
1131
 
1066
1132
  /**
@@ -1071,12 +1137,37 @@ export function hardenSecretPathAsync(targetPath: string, opts: HardenOptions):
1071
1137
  * @param opts { required: boolean } — required:true throws on failure.
1072
1138
  */
1073
1139
  export function hardenSecretDir(targetPath: string, opts: HardenOptions): HardenResult {
1074
- return hardenEntry(targetPath, true, opts, hardenedDirectories);
1140
+ return hardenEntry(targetPath, true, opts, hardenedDirectories, []);
1075
1141
  }
1076
1142
 
1077
1143
  /**
1078
1144
  * Async directory harden (#612). Same policy as hardenSecretDir.
1079
1145
  */
1080
1146
  export function hardenSecretDirAsync(targetPath: string, opts: HardenOptions): Promise<HardenResult> {
1081
- return hardenEntryAsync(targetPath, true, opts, hardenedDirectories);
1147
+ return hardenEntryAsync(targetPath, true, opts, hardenedDirectories, []);
1148
+ }
1149
+
1150
+ /**
1151
+ * Harden a NON-secret staged payload that an elevated process must read (#4779).
1152
+ *
1153
+ * Same isolation discipline as {@link hardenSecretPath} — owner full control
1154
+ * granted before inheritance is stripped, broad SIDs removed — plus a read
1155
+ * grant for SYSTEM and BUILTIN\Administrators. An owner-only shape is correct
1156
+ * for secrets and wrong here: a split-token elevation of the staging account
1157
+ * reads either way, but an over-the-shoulder UAC elevation answered with a
1158
+ * different administrator's credentials runs as that admin and could not open
1159
+ * an owner-only payload.
1160
+ *
1161
+ * This is a separate export, not an option on the secret functions, so a
1162
+ * secret call site cannot reach the wider shape by accident. Use it only for
1163
+ * payloads that carry no credentials; their tamper-evidence must come from a
1164
+ * digest verified by the elevated reader, not from the DACL.
1165
+ */
1166
+ export function hardenElevatedStagePath(targetPath: string, opts: HardenOptions): HardenResult {
1167
+ return hardenEntry(targetPath, false, opts, hardenedStagePaths, elevatedStageReadAces(false));
1168
+ }
1169
+
1170
+ /** Directory counterpart of {@link hardenElevatedStagePath}. */
1171
+ export function hardenElevatedStageDir(targetPath: string, opts: HardenOptions): HardenResult {
1172
+ return hardenEntry(targetPath, true, opts, hardenedStageDirectories, elevatedStageReadAces(true));
1082
1173
  }
@@ -0,0 +1,73 @@
1
+ export type AuthenticatedCatalogListener = (apiKeyId: string) => void;
2
+ export type OnAuthenticatedCatalog = (listener: AuthenticatedCatalogListener) => () => void;
3
+
4
+ export interface AdmissionWaitClock {
5
+ setTimeout: (callback: () => void, ms: number) => unknown;
6
+ clearTimeout: (timer: unknown) => void;
7
+ }
8
+
9
+ export class AdmissionWaitError extends Error {
10
+ readonly code = "admission_timeout";
11
+
12
+ constructor(apiKeyId: string, timeoutMs: number) {
13
+ super(`link key ${apiKeyId} was not admitted within ${timeoutMs}ms`);
14
+ this.name = "AdmissionWaitError";
15
+ }
16
+ }
17
+
18
+ const realClock: AdmissionWaitClock = {
19
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
20
+ clearTimeout: timer => clearTimeout(timer as ReturnType<typeof setTimeout>),
21
+ };
22
+
23
+ export function awaitFirstAdmission(
24
+ apiKeyId: string,
25
+ timeoutMs: number,
26
+ onAuthenticatedCatalog: OnAuthenticatedCatalog,
27
+ clock?: AdmissionWaitClock,
28
+ ): Promise<void>;
29
+ export function awaitFirstAdmission(
30
+ onAuthenticatedCatalog: OnAuthenticatedCatalog,
31
+ apiKeyId: string,
32
+ timeoutMs: number,
33
+ clock?: AdmissionWaitClock,
34
+ ): Promise<void>;
35
+ export function awaitFirstAdmission(
36
+ first: string | OnAuthenticatedCatalog,
37
+ second: number | string,
38
+ third: OnAuthenticatedCatalog | number,
39
+ fourth?: AdmissionWaitClock,
40
+ ): Promise<void> {
41
+ const apiKeyId = typeof first === "string" ? first : second as string;
42
+ const timeoutMs = typeof first === "string" ? second as number : third as number;
43
+ const subscribe = typeof first === "string" ? third as OnAuthenticatedCatalog : first;
44
+ const clock = fourth ?? realClock;
45
+ if (!apiKeyId || !Number.isFinite(timeoutMs) || timeoutMs < 0) {
46
+ return Promise.reject(new Error("invalid admission wait arguments"));
47
+ }
48
+ return new Promise<void>((resolve, reject) => {
49
+ let settled = false;
50
+ let timer: unknown;
51
+ let unsubscribe: (() => void) | undefined;
52
+ const finish = (error?: Error): void => {
53
+ if (settled) return;
54
+ settled = true;
55
+ if (timer !== undefined) clock.clearTimeout(timer);
56
+ unsubscribe?.();
57
+ if (error) reject(error);
58
+ else resolve();
59
+ };
60
+ try {
61
+ timer = clock.setTimeout(() => finish(new AdmissionWaitError(apiKeyId, timeoutMs)), timeoutMs);
62
+ unsubscribe = subscribe(observedKeyId => {
63
+ if (observedKeyId === apiKeyId) finish();
64
+ });
65
+ if (settled) {
66
+ unsubscribe();
67
+ unsubscribe = undefined;
68
+ }
69
+ } catch (error) {
70
+ finish(error instanceof Error ? error : new Error(String(error)));
71
+ }
72
+ });
73
+ }
@@ -0,0 +1,100 @@
1
+ import { chmodSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { atomicWriteFile, isMissingPathError } from "../config/atomic-write";
4
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
5
+ import { hardenSecretDir } from "../lib/windows-secret-acl";
6
+ import { linkDir } from "./paths";
7
+
8
+ export interface CompensationEntry {
9
+ reason: "compensation_failed";
10
+ since: string;
11
+ }
12
+
13
+ export interface CompensationStore {
14
+ version: 1;
15
+ entries: Record<string, CompensationEntry>;
16
+ }
17
+
18
+ export class CompensationStoreError extends Error {
19
+ constructor(message: string) {
20
+ super(message);
21
+ this.name = "CompensationStoreError";
22
+ }
23
+ }
24
+
25
+ const LINK_ID = /^lnk_[0-9a-f]{16}$/;
26
+
27
+ export function compensationPath(configDir?: string): string {
28
+ return join(linkDir(configDir), "compensation.json");
29
+ }
30
+
31
+ function cloneEmpty(): CompensationStore {
32
+ return { version: 1, entries: {} };
33
+ }
34
+
35
+ function assertOnlyKeys(value: Record<string, unknown>, keys: readonly string[], where: string): void {
36
+ for (const key of Object.keys(value)) {
37
+ if (!keys.includes(key)) throw new CompensationStoreError(`${where} has an unknown field ${JSON.stringify(key)}`);
38
+ }
39
+ }
40
+
41
+ export function parseCompensation(text: string): CompensationStore {
42
+ let raw: unknown;
43
+ try { raw = JSON.parse(text); } catch { throw new CompensationStoreError("compensation.json is not valid JSON"); }
44
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new CompensationStoreError("compensation.json is not an object");
45
+ const body = raw as Record<string, unknown>;
46
+ assertOnlyKeys(body, ["version", "entries"], "compensation.json");
47
+ if (body.version !== 1 || !body.entries || typeof body.entries !== "object" || Array.isArray(body.entries)) {
48
+ throw new CompensationStoreError("compensation.json has an invalid shape");
49
+ }
50
+ const entries: Record<string, CompensationEntry> = {};
51
+ for (const [linkId, value] of Object.entries(body.entries)) {
52
+ if (!LINK_ID.test(linkId) || !value || typeof value !== "object" || Array.isArray(value)) {
53
+ throw new CompensationStoreError("compensation.json has an invalid entry");
54
+ }
55
+ const entry = value as Record<string, unknown>;
56
+ assertOnlyKeys(entry, ["reason", "since"], `compensation.json.entries.${linkId}`);
57
+ if (entry.reason !== "compensation_failed" || typeof entry.since !== "string" || Number.isNaN(Date.parse(entry.since))) {
58
+ throw new CompensationStoreError("compensation.json has an invalid entry");
59
+ }
60
+ entries[linkId] = { reason: "compensation_failed", since: entry.since };
61
+ }
62
+ return { version: 1, entries };
63
+ }
64
+
65
+ /** Damaged compensation state is display-only evidence and therefore fails closed to empty. */
66
+ export function readCompensation(path: string = compensationPath()): CompensationStore {
67
+ let text: string;
68
+ try { text = readFileSync(path, "utf8"); } catch (error) {
69
+ if (isMissingPathError(error)) return cloneEmpty();
70
+ return cloneEmpty();
71
+ }
72
+ try { return parseCompensation(text); } catch { return cloneEmpty(); }
73
+ }
74
+
75
+ export function writeCompensation(path: string, store: CompensationStore): void {
76
+ const normalized = parseCompensation(JSON.stringify(store));
77
+ const dir = dirname(path);
78
+ assertNotRealHomeUnderTest(dirname(dir));
79
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
80
+ if (process.platform === "win32") hardenSecretDir(dir, { required: true });
81
+ else chmodSync(dir, 0o700);
82
+ atomicWriteFile(path, `${JSON.stringify(normalized, null, 2)}\n`);
83
+ if (process.platform !== "win32") chmodSync(path, 0o600);
84
+ }
85
+
86
+ export function markCompensationFailed(linkId: string, since: string, path: string = compensationPath()): void {
87
+ const current = readCompensation(path);
88
+ writeCompensation(path, {
89
+ version: 1,
90
+ entries: { ...current.entries, [linkId]: { reason: "compensation_failed", since } },
91
+ });
92
+ }
93
+
94
+ export function clearCompensationFailed(linkId: string, path: string = compensationPath()): void {
95
+ const current = readCompensation(path);
96
+ if (!current.entries[linkId]) return;
97
+ const entries = { ...current.entries };
98
+ delete entries[linkId];
99
+ writeCompensation(path, { version: 1, entries });
100
+ }
@@ -0,0 +1,21 @@
1
+ export interface ParsedFingerprint {
2
+ bits: number;
3
+ fingerprint: string;
4
+ alias: string;
5
+ keyType: string;
6
+ }
7
+
8
+ /** Parse exactly one `ssh-keygen -l` fingerprint line. */
9
+ export function parseFingerprintLine(stdout: string): ParsedFingerprint {
10
+ if (!stdout || /[\r\n]/.test(stdout)) throw new Error("fingerprint output must contain exactly one line");
11
+ const match = /^(\d+)\s+(SHA256:[A-Za-z0-9+/=]+)\s+(\S+)\s+\(([^()\s]+)\)$/.exec(stdout);
12
+ if (!match) throw new Error("fingerprint output has an invalid format");
13
+ const bits = Number(match[1]);
14
+ if (!Number.isSafeInteger(bits) || bits < 1) throw new Error("fingerprint bit count is invalid");
15
+ return {
16
+ bits,
17
+ fingerprint: match[2]!,
18
+ alias: match[3]!,
19
+ keyType: match[4]!,
20
+ };
21
+ }