@bitkyc08/opencodex 2.65.0 → 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.
- package/README.md +14 -0
- package/gui/dist/assets/App-BVjA0T6O.js +51 -0
- package/gui/dist/assets/App-gkoJIEOV.css +1 -0
- package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-BCFtiWwD.js} +1 -1
- package/gui/dist/assets/index-ComVsdSk.css +1 -0
- package/gui/dist/assets/index-vO4dgfiU.js +86 -0
- package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-C4FiNaxk.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -2
- package/src/adapters/anthropic/beta-allowlist.ts +80 -0
- package/src/adapters/anthropic/passthrough.ts +221 -0
- package/src/adapters/anthropic-image-codec.ts +56 -19
- package/src/adapters/anthropic-image-normalize.ts +20 -10
- package/src/adapters/anthropic.ts +55 -24
- package/src/adapters/coding-agent/protocol.ts +29 -19
- package/src/adapters/cursor/live-transport.ts +18 -3
- package/src/adapters/cursor/native-exec-shell.ts +18 -63
- package/src/adapters/cursor/native-exec.ts +5 -2
- package/src/adapters/cursor/native-foreground-shell.ts +44 -0
- package/src/adapters/devin/cloud-direct/chat.ts +7 -1
- package/src/adapters/exec-tool-result-normalize.ts +4 -1
- package/src/adapters/google.ts +12 -0
- package/src/adapters/ollama-native.ts +32 -1
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
- package/src/adapters/openai-chat.ts +6 -4
- package/src/adapters/openai-responses/passthrough.ts +1 -0
- package/src/adapters/openai-responses/reasoning.ts +45 -1
- package/src/bridge/response-json.ts +10 -2
- package/src/chat/outbound.ts +104 -52
- package/src/claude/claude-code-block.ts +16 -0
- package/src/claude/context-windows.ts +52 -4
- package/src/claude/desktop-first-party.ts +25 -8
- package/src/claude/inbound.ts +22 -0
- package/src/claude/intercept/connect-proxy.ts +37 -4
- package/src/claude/intercept/proxy-auth.ts +166 -0
- package/src/claude/intercept/runtime.ts +21 -0
- package/src/claude/intercept/settings.ts +33 -12
- package/src/claude/outbound.ts +45 -27
- package/src/cli/account-extended.ts +47 -15
- package/src/cli/account-target.ts +115 -0
- package/src/cli/account.ts +20 -28
- package/src/cli/agent.ts +10 -1
- package/src/cli/api-protocols.ts +206 -0
- package/src/cli/capabilities.ts +86 -0
- package/src/cli/combo.ts +2 -2
- package/src/cli/connect.ts +127 -3
- package/src/cli/dispatch.ts +8 -0
- package/src/cli/doctor.ts +33 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/link.ts +288 -0
- package/src/cli/registry.ts +23 -0
- package/src/cli/runtime-api.ts +79 -0
- package/src/cli/system-command.ts +21 -2
- package/src/client/connect.ts +125 -23
- package/src/client/hub-relay.ts +12 -8
- package/src/client/link-join.ts +323 -0
- package/src/client/link-relay.ts +241 -0
- package/src/client/link-state.ts +110 -0
- package/src/client/link-teardown.ts +63 -0
- package/src/client/link-tunnel.ts +465 -0
- package/src/client/machine-listener.ts +14 -5
- package/src/client/runtime.ts +91 -42
- package/src/client/state.ts +4 -0
- package/src/codex/catalog/build-entries.ts +4 -0
- package/src/codex/catalog/derive-entry.ts +5 -0
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/catalog/retained-sync.ts +4 -1
- package/src/codex/desktop-switches.ts +88 -8
- package/src/codex/inject/plan.ts +16 -3
- package/src/codex/inject.ts +3 -0
- package/src/codex/internal/catalog-writer.ts +13 -2
- package/src/combos/failover.ts +3 -2
- package/src/combos/index.ts +12 -0
- package/src/combos/jev.ts +646 -0
- package/src/combos/request.ts +5 -0
- package/src/combos/resolve.ts +17 -15
- package/src/combos/types.ts +47 -3
- package/src/config/atomic-write.ts +3 -0
- package/src/config/live-reconcile.ts +20 -4
- package/src/config/persist-unlocked.ts +20 -7
- package/src/config/schema/config-schema.ts +16 -0
- package/src/config/schema/leaf-validators.ts +38 -1
- package/src/generated/compatibility-version.json +416 -128
- package/src/integrations/omp-yaml-source.ts +109 -4
- package/src/lab/automation/orchestrator.ts +60 -4
- package/src/lab/events/limits.ts +1 -1
- package/src/lib/bounded-body.ts +55 -38
- package/src/lib/config-ownership.ts +11 -22
- package/src/lib/local-desktop-snapshot-capability.ts +56 -0
- package/src/lib/local-management-capability.ts +25 -3
- package/src/lib/optional-shutdown-hooks.ts +17 -0
- package/src/lib/state-store-registrations.ts +2 -2
- package/src/lib/windows-elevation.ts +62 -25
- package/src/lib/windows-secret-acl.ts +114 -23
- package/src/link/admission-wait.ts +73 -0
- package/src/link/compensation.ts +100 -0
- package/src/link/fingerprint.ts +21 -0
- package/src/link/paths.ts +19 -0
- package/src/link/ports.ts +12 -0
- package/src/link/routes.ts +32 -0
- package/src/link/ssh-argv.ts +170 -0
- package/src/link/ssh-config.ts +152 -0
- package/src/link/ssh-runner.ts +161 -0
- package/src/link/status-projection.ts +96 -0
- package/src/link/store.ts +139 -0
- package/src/link/supervisor.ts +404 -0
- package/src/link/tunnel-state.ts +91 -0
- package/src/protocols/baseline.ts +88 -0
- package/src/protocols/codecs/chat.ts +17 -0
- package/src/protocols/codecs/messages.ts +18 -0
- package/src/protocols/codecs/responses.ts +17 -0
- package/src/protocols/contract.ts +185 -0
- package/src/protocols/dto.ts +267 -0
- package/src/protocols/encoders/adapter-events.ts +876 -0
- package/src/protocols/encoders/chat.ts +243 -0
- package/src/protocols/encoders/messages.ts +462 -0
- package/src/protocols/envelope.ts +43 -0
- package/src/protocols/features.ts +295 -0
- package/src/protocols/guard.ts +33 -0
- package/src/protocols/opaque-state.ts +132 -0
- package/src/protocols/path.ts +39 -0
- package/src/protocols/plan-snapshot.ts +226 -0
- package/src/protocols/plan.ts +192 -0
- package/src/protocols/provider-summary.ts +101 -0
- package/src/protocols/settings.ts +118 -0
- package/src/protocols/shadow-plan.ts +36 -0
- package/src/protocols/shadow.ts +62 -0
- package/src/protocols/trace.ts +335 -0
- package/src/providers/model-rename-migration.ts +3 -3
- package/src/providers/registry/entries-core.ts +6 -1
- package/src/providers/registry/entries-extended.ts +15 -0
- package/src/providers/registry/model-ids.ts +1 -0
- package/src/providers/registry/types.ts +6 -0
- package/src/providers/registry.ts +1 -1
- package/src/remote-control/workspace-hub.ts +5 -4
- package/src/responses/apply-patch-envelope.ts +6 -1
- package/src/responses/code-mode-shell-input.ts +4 -2
- package/src/responses/freeform-wrapper-scan.ts +51 -24
- package/src/responses/progressive-freeform-input.ts +14 -20
- package/src/responses/spill-store.ts +187 -33
- package/src/responses/state/snapshot-select.ts +49 -0
- package/src/responses/state/spill-inspect.ts +46 -0
- package/src/responses/state/spill-queue.ts +16 -0
- package/src/responses/state.ts +51 -27
- package/src/server/audio-client.ts +8 -3
- package/src/server/audio-upstream.ts +9 -4
- package/src/server/auth-cors.ts +25 -8
- package/src/server/chat-completions.ts +71 -11
- package/src/server/chat-native-eligibility.ts +74 -0
- package/src/server/chat-native.ts +139 -74
- package/src/server/claude-messages.ts +140 -22
- package/src/server/hub-usage.ts +4 -3
- package/src/server/index/link-listener.ts +195 -0
- package/src/server/index/optional-listeners.ts +115 -0
- package/src/server/index/serve-options.ts +44 -10
- package/src/server/index.ts +10 -9
- package/src/server/inference/attempt.ts +51 -0
- package/src/server/inference/client-encoder-delivery.ts +235 -0
- package/src/server/inference/client-wire-log.ts +49 -0
- package/src/server/inference/client-wire.ts +69 -0
- package/src/server/inference/context.ts +15 -0
- package/src/server/inference/final-log.ts +37 -0
- package/src/server/local-desktop-snapshot-auth.ts +57 -0
- package/src/server/management/agent-settings-routes.ts +15 -13
- package/src/server/management/api-access.ts +11 -1
- package/src/server/management/config-routes.ts +3 -6
- package/src/server/management/context.ts +25 -0
- package/src/server/management/link-routes.ts +535 -0
- package/src/server/management/logs-usage-routes.ts +31 -1
- package/src/server/management/native-integration-routes.ts +6 -11
- package/src/server/management/oauth-account-routes.ts +42 -16
- package/src/server/management/protocol-routes.ts +159 -0
- package/src/server/management/protocol-settings-patch.ts +145 -0
- package/src/server/management/provider-patch-transaction.ts +61 -0
- package/src/server/management/provider-routes.ts +39 -18
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sidebar-routes.ts +8 -3
- package/src/server/management/system-routes.ts +18 -1
- package/src/server/management/usage-aggregate-cache.ts +206 -6
- package/src/server/management-api.ts +26 -1
- package/src/server/management-auth.ts +19 -5
- package/src/server/messages-native-eligibility.ts +161 -0
- package/src/server/messages-native-oauth.ts +149 -0
- package/src/server/messages-native.ts +790 -0
- package/src/server/relay.ts +9 -0
- package/src/server/request-log-filter.ts +92 -0
- package/src/server/request-log.ts +31 -70
- package/src/server/responses/adapter-delivery.ts +43 -16
- package/src/server/responses/agent-task-recovery.ts +90 -22
- package/src/server/responses/codex-ws-exchange.ts +4 -0
- package/src/server/responses/codex-ws-wire.ts +10 -1
- package/src/server/responses/core-combo-native.ts +323 -0
- package/src/server/responses/core-combo.ts +224 -13
- package/src/server/responses/core-options.ts +22 -0
- package/src/server/responses/core.ts +14 -26
- package/src/server/responses/request-sidecar-auth.ts +8 -3
- package/src/server/responses/run-turn-execution.ts +257 -63
- package/src/server/responses/sidecar-execution.ts +7 -4
- package/src/server/system-env.ts +7 -0
- package/src/service/diagnostics.ts +7 -1
- package/src/service/launchd.ts +26 -37
- package/src/service/windows-ops.ts +24 -20
- package/src/types/config.ts +58 -1
- package/src/types.ts +2 -0
- package/src/update/transactional-install.d.mts +0 -1
- package/src/update/transactional-install.mjs +17 -17
- package/src/usage/jev-stats.ts +495 -0
- package/src/usage/log.ts +24 -3
- package/src/web-search/run-turn-loop.ts +568 -0
- package/gui/dist/assets/App-8NMiZxT0.css +0 -1
- package/gui/dist/assets/App-DGLte6IR.js +0 -50
- package/gui/dist/assets/index--EWgGQvZ.css +0 -1
- package/gui/dist/assets/index-Bi1K37Wl.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
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
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
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
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
|
|
681
|
-
*
|
|
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 { $
|
|
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
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
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
|
-
"
|
|
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;
|
|
53
|
-
* that attempt was consumed
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
878
|
-
if (
|
|
879
|
-
|
|
880
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|