@bitkyc08/opencodex 2.58.0 → 2.60.0
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 +28 -10
- package/gui/dist/assets/index-BTuCbqQd.css +1 -0
- package/gui/dist/assets/index-DoBVdPHP.js +134 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/crusoe.svg +1 -0
- package/gui/dist/provider-icons/opper.svg +3 -0
- package/package.json +4 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +4 -1
- package/src/adapters/base.ts +34 -1
- package/src/adapters/coding-agent/turn.ts +22 -2
- package/src/adapters/command-code.ts +50 -3
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/effort-map.ts +16 -2
- package/src/adapters/cursor/envelope-echo.ts +55 -2
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-request.ts +8 -5
- package/src/adapters/cursor/request-builder.ts +21 -4
- package/src/adapters/cursor/thread-continuity.ts +105 -31
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +45 -2
- package/src/adapters/devin/cloud-direct/chat.ts +14 -3
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
- package/src/adapters/devin.ts +125 -26
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +55 -4
- package/src/adapters/google-http.ts +57 -11
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +60 -10
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +10 -11
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +25 -4
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
- package/src/adapters/openai-responses/tool-schema.ts +19 -7
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +28 -35
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +35 -4
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +26 -6
- package/src/cli/account-orca-import.ts +63 -0
- package/src/cli/account.ts +32 -4
- package/src/cli/capabilities.ts +40 -0
- package/src/cli/claude.ts +29 -1
- package/src/cli/codex-cli-update.ts +97 -2
- package/src/cli/dispatch.ts +57 -3
- package/src/cli/doctor.ts +218 -4
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +95 -22
- package/src/cli/models-runtime.ts +33 -4
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +11 -1
- package/src/cli/runtime-api.ts +44 -0
- package/src/cli/start-args.ts +94 -0
- package/src/cli/system-command.ts +2 -0
- package/src/client/machine-api.ts +6 -5
- package/src/client/machine-listener.ts +16 -3
- package/src/client/runtime.ts +26 -2
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +146 -5
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/pool-quota-probe.ts +44 -10
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +16 -12
- package/src/codex/catalog/build-entries.ts +25 -4
- package/src/codex/catalog/derive-entry.ts +8 -1
- package/src/codex/catalog/effort.ts +10 -6
- package/src/codex/catalog/gather-capture.ts +22 -2
- package/src/codex/catalog/model-hints.ts +66 -33
- package/src/codex/catalog/parsing.ts +90 -5
- package/src/codex/catalog/provider-models.ts +19 -2
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +41 -26
- package/src/codex/catalog/routed-gather.ts +61 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +24 -167
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject.ts +3 -2
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +31 -0
- package/src/codex/main-account-hard-lock.ts +2 -1
- package/src/codex/main-account.ts +10 -3
- package/src/codex/main-device-reauth.ts +17 -9
- package/src/codex/model-cache.ts +47 -0
- package/src/codex/model-entitlements.ts +80 -2
- package/src/codex/observed-model-denials.ts +230 -0
- package/src/codex/orca-auth-source.ts +94 -0
- package/src/codex/orca-import.ts +219 -0
- package/src/codex/prompt-text-probe.ts +289 -16
- package/src/codex/quota-401-recovery.ts +12 -0
- package/src/codex/quota-types.ts +65 -0
- package/src/codex/quota.ts +24 -19
- package/src/codex/routing/cooldown-math.ts +8 -47
- package/src/codex/routing/pin-drain.ts +57 -0
- package/src/codex/routing.ts +20 -16
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +114 -2
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +125 -5
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/diagnostics.ts +43 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +33 -0
- package/src/config/pending-teardown.ts +8 -0
- package/src/config/process-state.ts +36 -3
- package/src/config/provider-relative-send-path.ts +16 -0
- package/src/config/proxy-env.ts +31 -7
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +25 -0
- package/src/config/schema/leaf-validators.ts +76 -17
- package/src/config.ts +2 -2
- package/src/generated/compatibility-version.json +410 -258
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/mutation-plan.ts +815 -0
- package/src/integrations/writer.ts +85 -99
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +23 -0
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +82 -15
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-outbound.ts +53 -5
- package/src/lib/proxy-env.ts +70 -3
- package/src/lib/request-execution-budget.ts +11 -3
- package/src/lib/response-body-inactivity.ts +193 -0
- package/src/lib/retry-delay.ts +69 -0
- package/src/lib/socks5-fetch.ts +741 -0
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +332 -35
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +41 -27
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +3 -107
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/oauth/orcarouter.ts +15 -2
- package/src/oauth/store.ts +8 -0
- package/src/providers/codex-capacity.ts +9 -0
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +327 -25
- package/src/providers/model-rename-migration.ts +56 -1
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/quota/vendor-probes-key.ts +1 -1
- package/src/providers/registry/entries-core.ts +39 -17
- package/src/providers/registry/entries-extended.ts +131 -1
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +87 -21
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/responses/apply-patch-envelope.ts +32 -11
- package/src/responses/bridge-search-replay-cache.ts +152 -0
- package/src/responses/code-mode-helper-compat.ts +26 -16
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/hosted-tool-policy.ts +85 -2
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +9 -2
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +91 -115
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +13 -5
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +30 -5
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/serve-options.ts +86 -29
- package/src/server/index/spend-ledger-lifecycle.ts +66 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +24 -25
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/config-routes.ts +48 -7
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/management/route-registry.ts +14 -0
- package/src/server/management/shared.ts +10 -3
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +136 -34
- package/src/server/request-log.ts +80 -3
- package/src/server/request-metrics.ts +236 -0
- package/src/server/responses/adapter-continuation.ts +74 -30
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +63 -30
- package/src/server/responses/compact.ts +89 -18
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +90 -24
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +16 -15
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +68 -2
- package/src/server/responses/passthrough-delivery.ts +41 -15
- package/src/server/responses/passthrough-dispatch.ts +145 -53
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +96 -17
- package/src/server/responses/request-send-budget.ts +89 -8
- package/src/server/responses/request-sidecar-auth.ts +17 -9
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +15 -12
- package/src/server/responses/run-turn-execution.ts +45 -8
- package/src/server/responses/sidecar-execution.ts +19 -2
- package/src/server/responses/ws-upstream.ts +16 -28
- package/src/server/responses-custom-tool-repair.ts +29 -56
- package/src/server/responses-undeclared-tool-guard.ts +31 -1
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +37 -10
- package/src/server/system-env-shell.ts +5 -1
- package/src/server/system-env.ts +7 -1
- package/src/server/workflow-refusal.ts +56 -2
- package/src/service/cli.ts +16 -6
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/tray/windows-tray.ps1 +155 -3
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +111 -6
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +9 -1
- package/src/types/tools.ts +14 -0
- package/src/types/wire.ts +9 -1
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-BbrHOIY0.js +0 -128
- package/gui/dist/assets/index-C5-RdDmD.css +0 -1
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Library, Symbols } from "bun:ffi";
|
|
3
|
+
|
|
4
|
+
const KERNEL_SYMBOLS = {
|
|
5
|
+
GetDriveTypeW: { args: ["ptr"], returns: "u32" },
|
|
6
|
+
GetVolumeNameForVolumeMountPointW: { args: ["ptr", "ptr", "u32"], returns: "i32" },
|
|
7
|
+
CreateFileW: { args: ["ptr", "u32", "u32", "ptr", "u32", "u32", "u64"], returns: "u64" },
|
|
8
|
+
GetFileInformationByHandleEx: { args: ["u64", "i32", "ptr", "u32"], returns: "i32" },
|
|
9
|
+
GetFileType: { args: ["u64"], returns: "u32" },
|
|
10
|
+
ReadFile: { args: ["u64", "ptr", "u32", "ptr", "ptr"], returns: "i32" },
|
|
11
|
+
CloseHandle: { args: ["u64"], returns: "i32" },
|
|
12
|
+
} as const satisfies Symbols;
|
|
13
|
+
const NT_SYMBOLS = {
|
|
14
|
+
NtCreateFile: { args: ["ptr", "u32", "ptr", "ptr", "ptr", "u32", "u32", "u32", "u32", "ptr", "u32"], returns: "i32" },
|
|
15
|
+
} as const satisfies Symbols;
|
|
16
|
+
|
|
17
|
+
export interface WindowsInstallationFileRequest {
|
|
18
|
+
readonly path: string;
|
|
19
|
+
readonly maxBytes: number;
|
|
20
|
+
readonly hashOnly?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface WindowsInstallationFileIdentity {
|
|
23
|
+
readonly volumeSerial: string;
|
|
24
|
+
readonly fileId: string;
|
|
25
|
+
readonly size: number;
|
|
26
|
+
readonly lastWriteTime: string;
|
|
27
|
+
readonly changeTime: string;
|
|
28
|
+
}
|
|
29
|
+
export type WindowsInstallationFilesResult =
|
|
30
|
+
| { kind: "observed"; files: {
|
|
31
|
+
path: string; identity: WindowsInstallationFileIdentity; bytes: Uint8Array; digest: string;
|
|
32
|
+
}[] }
|
|
33
|
+
| { kind: "refused"; reason: "unsupported-platform" | "invalid-request" | "native-api-unavailable"
|
|
34
|
+
| "volume-unavailable" | "open-refused" | "reparse-point" | "not-regular-file"
|
|
35
|
+
| "size-limit" | "read-failed" | "identity-changed" | "inspection-failed" };
|
|
36
|
+
|
|
37
|
+
type Refusal = Extract<WindowsInstallationFilesResult, { kind: "refused" }>["reason"];
|
|
38
|
+
class InspectionRefusal extends Error {
|
|
39
|
+
constructor(readonly reason: Refusal) { super(reason); }
|
|
40
|
+
}
|
|
41
|
+
const MIB = 1024 * 1024;
|
|
42
|
+
const REPARSE = 0x400;
|
|
43
|
+
const DIRECTORY = 0x10;
|
|
44
|
+
const INVALID_HANDLE = 0xffffffffffffffffn;
|
|
45
|
+
let openedForTests: (() => void) | undefined;
|
|
46
|
+
/** Runs after handles are held, for real Windows sharing/rename regression fixtures. */
|
|
47
|
+
export function setWindowsInstallationFilesOpenedForTests(callback?: () => void): void {
|
|
48
|
+
openedForTests = callback;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function components(path: string): { drive: string; names: string[] } | null {
|
|
52
|
+
if (typeof path !== "string" || path.length > 8192 || !/^[A-Za-z]:[\\/]/.test(path)) return null;
|
|
53
|
+
const names = path.slice(3).split(/[\\/]/);
|
|
54
|
+
if (!names.length || names.length > 64 || names.some(name => !name || name === "." || name === ".."
|
|
55
|
+
|| /[\x00-\x1f<>:"|?*]/.test(name) || /[. ]$/.test(name)
|
|
56
|
+
|| /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(name))) return null;
|
|
57
|
+
return { drive: `${path[0]!.toUpperCase()}:\\`, names };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* An explicit, read-only snapshot; never installation authority or a runtime-selection proof.
|
|
62
|
+
* All component opens are relative to held directory handles, not checked-then-reopened paths.
|
|
63
|
+
* Windows x64 only: the UNICODE_STRING/OBJECT_ATTRIBUTES layouts below are the x64 ABI.
|
|
64
|
+
* No candidate runs and no shell/compiler is started. Disk/driver latency is not a hard deadline.
|
|
65
|
+
* API contracts: learn.microsoft.com/windows/win32/api/winternl/nf-winternl-ntcreatefile
|
|
66
|
+
* and /windows/win32/api/fileapi/nf-fileapi-getvolumenameforvolumemountpointw.
|
|
67
|
+
*/
|
|
68
|
+
export async function inspectWindowsInstallationFiles(
|
|
69
|
+
requests: readonly WindowsInstallationFileRequest[],
|
|
70
|
+
): Promise<WindowsInstallationFilesResult> {
|
|
71
|
+
if (!Array.isArray(requests) || requests.length === 0 || requests.length > 12) {
|
|
72
|
+
return { kind: "refused", reason: "invalid-request" };
|
|
73
|
+
}
|
|
74
|
+
let ceiling = 0;
|
|
75
|
+
const parsed = requests.map(request => {
|
|
76
|
+
const parsedPath = request && components(request.path);
|
|
77
|
+
if (!parsedPath || !Number.isSafeInteger(request.maxBytes) || request.maxBytes < 0
|
|
78
|
+
|| (request.hashOnly !== undefined && typeof request.hashOnly !== "boolean")
|
|
79
|
+
|| request.maxBytes > (request.hashOnly ? 256 * MIB : MIB)) return null;
|
|
80
|
+
ceiling += request.maxBytes;
|
|
81
|
+
return parsedPath;
|
|
82
|
+
});
|
|
83
|
+
if (parsed.some(path => path === null) || ceiling > 300 * MIB) {
|
|
84
|
+
return { kind: "refused", reason: "invalid-request" };
|
|
85
|
+
}
|
|
86
|
+
if (process.platform !== "win32" || process.arch !== "x64") {
|
|
87
|
+
return { kind: "refused", reason: "unsupported-platform" };
|
|
88
|
+
}
|
|
89
|
+
// Loading FFI is itself opt-in; importing this module performs no Windows inspection.
|
|
90
|
+
let ffi: typeof import("bun:ffi");
|
|
91
|
+
try { ffi = await import("bun:ffi"); } catch { return { kind: "refused", reason: "native-api-unavailable" }; }
|
|
92
|
+
let kernel: Library<typeof KERNEL_SYMBOLS> | undefined;
|
|
93
|
+
let nt: Library<typeof NT_SYMBOLS> | undefined;
|
|
94
|
+
const handles: bigint[] = [];
|
|
95
|
+
try {
|
|
96
|
+
try {
|
|
97
|
+
kernel = ffi.dlopen("kernel32.dll", KERNEL_SYMBOLS);
|
|
98
|
+
nt = ffi.dlopen("ntdll.dll", NT_SYMBOLS);
|
|
99
|
+
} catch { throw new InspectionRefusal("native-api-unavailable"); }
|
|
100
|
+
const k = kernel.symbols;
|
|
101
|
+
const wide = (text: string): Buffer => Buffer.from(`${text}\0`, "utf16le");
|
|
102
|
+
const keep = (handle: bigint): bigint => {
|
|
103
|
+
if (handle === 0n || handle === INVALID_HANDLE) throw new InspectionRefusal("open-refused");
|
|
104
|
+
handles.push(handle);
|
|
105
|
+
return handle;
|
|
106
|
+
};
|
|
107
|
+
const info = (handle: bigint, infoClass: number, length: number): Buffer => {
|
|
108
|
+
const buffer = Buffer.alloc(length);
|
|
109
|
+
if (!k.GetFileInformationByHandleEx!(handle, infoClass, ffi.ptr(buffer), length)) {
|
|
110
|
+
throw new InspectionRefusal("inspection-failed");
|
|
111
|
+
}
|
|
112
|
+
return buffer;
|
|
113
|
+
};
|
|
114
|
+
const inspect = (handle: bigint, directory: boolean): WindowsInstallationFileIdentity => {
|
|
115
|
+
if (k.GetFileType!(handle) !== 1) throw new InspectionRefusal("not-regular-file"); // FILE_TYPE_DISK
|
|
116
|
+
const basic = info(handle, 0, 40); // FILE_BASIC_INFO: 4 LARGE_INTEGERs, attributes.
|
|
117
|
+
const attributes = basic.readUInt32LE(32);
|
|
118
|
+
if (attributes & REPARSE) throw new InspectionRefusal("reparse-point");
|
|
119
|
+
if (Boolean(attributes & DIRECTORY) !== directory) throw new InspectionRefusal("not-regular-file");
|
|
120
|
+
const id = info(handle, 18, 24); // FILE_ID_INFO: volume serial and 128-bit file ID.
|
|
121
|
+
const standard = info(handle, 1, 24); // FILE_STANDARD_INFO.EndOfFile
|
|
122
|
+
const size = standard.readBigInt64LE(8);
|
|
123
|
+
if (size < 0n || size > BigInt(Number.MAX_SAFE_INTEGER)) throw new InspectionRefusal("size-limit");
|
|
124
|
+
return { volumeSerial: id.readBigUInt64LE(0).toString(16), fileId: id.subarray(8, 24).toString("hex"),
|
|
125
|
+
size: Number(size), lastWriteTime: basic.readBigInt64LE(16).toString(), changeTime: basic.readBigInt64LE(24).toString() };
|
|
126
|
+
};
|
|
127
|
+
const roots = new Map<string, bigint>();
|
|
128
|
+
const root = (drive: string): bigint => {
|
|
129
|
+
const held = roots.get(drive);
|
|
130
|
+
if (held !== undefined) return held;
|
|
131
|
+
const mount = wide(drive);
|
|
132
|
+
const name = Buffer.alloc(100 * 2);
|
|
133
|
+
if (![2, 3, 5, 6].includes(Number(k.GetDriveTypeW!(ffi.ptr(mount))))) {
|
|
134
|
+
throw new InspectionRefusal("volume-unavailable");
|
|
135
|
+
}
|
|
136
|
+
// Root-only volume management query, never a candidate-controlled ancestor traversal.
|
|
137
|
+
// This API does not support SMB; only a strict local volume GUID reaches CreateFileW.
|
|
138
|
+
if (!k.GetVolumeNameForVolumeMountPointW!(ffi.ptr(mount), ffi.ptr(name), 100)) {
|
|
139
|
+
throw new InspectionRefusal("volume-unavailable");
|
|
140
|
+
}
|
|
141
|
+
const guid = name.toString("utf16le").split("\0", 1)[0]!;
|
|
142
|
+
if (!/^\\\\\?\\Volume\{[0-9a-f-]{36}\}\\$/i.test(guid)) throw new InspectionRefusal("volume-unavailable");
|
|
143
|
+
const path = wide(guid);
|
|
144
|
+
const handle = keep(BigInt(k.CreateFileW!(ffi.ptr(path), 0x100081, 1, null, 3, 0x02200000, 0n)));
|
|
145
|
+
inspect(handle, true);
|
|
146
|
+
roots.set(drive, handle);
|
|
147
|
+
return handle;
|
|
148
|
+
};
|
|
149
|
+
const relativeOpen = (parent: bigint, component: string, directory: boolean): bigint => {
|
|
150
|
+
const name = wide(component);
|
|
151
|
+
const unicode = Buffer.alloc(16);
|
|
152
|
+
unicode.writeUInt16LE(name.length - 2, 0);
|
|
153
|
+
unicode.writeUInt16LE(name.length, 2);
|
|
154
|
+
unicode.writeBigUInt64LE(BigInt(ffi.ptr(name)), 8);
|
|
155
|
+
const attributes = Buffer.alloc(48);
|
|
156
|
+
attributes.writeUInt32LE(48, 0);
|
|
157
|
+
attributes.writeBigUInt64LE(parent, 8);
|
|
158
|
+
attributes.writeBigUInt64LE(BigInt(ffi.ptr(unicode)), 16);
|
|
159
|
+
attributes.writeUInt32LE(0x40, 24); // OBJ_CASE_INSENSITIVE; one component only.
|
|
160
|
+
const output = Buffer.alloc(8);
|
|
161
|
+
const status = Buffer.alloc(16);
|
|
162
|
+
// FILE_OPEN; FILE_SYNCHRONOUS_IO_NONALERT; FILE_OPEN_REPARSE_POINT.
|
|
163
|
+
// Share READ only: while held, writes/reparse edits and delete/rename opens are refused.
|
|
164
|
+
const result = nt!.symbols.NtCreateFile!(ffi.ptr(output), 0x100081, ffi.ptr(attributes),
|
|
165
|
+
ffi.ptr(status), null, 0, 1, 1, 0x200020 | (directory ? 1 : 0), null, 0);
|
|
166
|
+
if (result < 0) throw new InspectionRefusal("open-refused");
|
|
167
|
+
const handle = keep(output.readBigUInt64LE(0));
|
|
168
|
+
inspect(handle, directory);
|
|
169
|
+
return handle;
|
|
170
|
+
};
|
|
171
|
+
const directories = new Map<string, bigint>();
|
|
172
|
+
const files = requests.map((request, index) => {
|
|
173
|
+
const { drive, names } = parsed[index]!;
|
|
174
|
+
let parent = root(drive);
|
|
175
|
+
let key = drive;
|
|
176
|
+
for (const component of names.slice(0, -1)) {
|
|
177
|
+
key += `${component}\\`;
|
|
178
|
+
let handle = directories.get(key);
|
|
179
|
+
if (handle === undefined) {
|
|
180
|
+
handle = relativeOpen(parent, component, true);
|
|
181
|
+
directories.set(key, handle);
|
|
182
|
+
}
|
|
183
|
+
parent = handle;
|
|
184
|
+
}
|
|
185
|
+
const handle = relativeOpen(parent, names[names.length - 1]!, false);
|
|
186
|
+
const identity = inspect(handle, false);
|
|
187
|
+
if (identity.size > request.maxBytes) throw new InspectionRefusal("size-limit");
|
|
188
|
+
return { request, handle, identity };
|
|
189
|
+
});
|
|
190
|
+
openedForTests?.();
|
|
191
|
+
const observed = files.map(({ request, handle, identity }) => {
|
|
192
|
+
const hash = createHash("sha256");
|
|
193
|
+
const bytes = request.hashOnly ? new Uint8Array() : new Uint8Array(identity.size);
|
|
194
|
+
const chunk = Buffer.alloc(Math.min(MIB, Math.max(1, identity.size)));
|
|
195
|
+
const read = Buffer.alloc(4);
|
|
196
|
+
let offset = 0;
|
|
197
|
+
while (offset < identity.size) {
|
|
198
|
+
const length = Math.min(chunk.length, identity.size - offset);
|
|
199
|
+
if (!k.ReadFile!(handle, ffi.ptr(chunk), length, ffi.ptr(read), null)) throw new InspectionRefusal("read-failed");
|
|
200
|
+
const count = read.readUInt32LE(0);
|
|
201
|
+
if (!count || count > length) throw new InspectionRefusal("read-failed");
|
|
202
|
+
hash.update(chunk.subarray(0, count));
|
|
203
|
+
if (!request.hashOnly) bytes.set(chunk.subarray(0, count), offset);
|
|
204
|
+
offset += count;
|
|
205
|
+
}
|
|
206
|
+
if (!k.ReadFile!(handle, ffi.ptr(chunk), 1, ffi.ptr(read), null) || read.readUInt32LE(0) !== 0) {
|
|
207
|
+
throw new InspectionRefusal("identity-changed");
|
|
208
|
+
}
|
|
209
|
+
return { path: request.path, identity, bytes, digest: hash.digest("hex") };
|
|
210
|
+
});
|
|
211
|
+
for (const file of files) {
|
|
212
|
+
if (JSON.stringify(inspect(file.handle, false)) !== JSON.stringify(file.identity)) {
|
|
213
|
+
throw new InspectionRefusal("identity-changed");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { kind: "observed", files: observed };
|
|
217
|
+
} catch (error) {
|
|
218
|
+
return { kind: "refused", reason: error instanceof InspectionRefusal ? error.reason : "inspection-failed" };
|
|
219
|
+
} finally {
|
|
220
|
+
for (const handle of handles.reverse()) kernel?.symbols.CloseHandle?.(handle);
|
|
221
|
+
nt?.close();
|
|
222
|
+
kernel?.close();
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/combos/failover.ts
CHANGED
|
@@ -118,23 +118,29 @@ function parseHttpDate(value: string, now: number): number | undefined {
|
|
|
118
118
|
export function parseRetryAfterMs(
|
|
119
119
|
value: string | null | undefined,
|
|
120
120
|
now = Date.now(),
|
|
121
|
-
options?: { preserveImmediate?: boolean },
|
|
121
|
+
options?: { preserveImmediate?: boolean; preserveServerDelay?: boolean },
|
|
122
122
|
): number | undefined {
|
|
123
123
|
const text = value?.trim();
|
|
124
124
|
if (!text) return undefined;
|
|
125
|
+
// A local wait ceiling must not make an explicit upstream reset expire early.
|
|
126
|
+
// Keep legacy bounded parsing for other callers. The opt-in stores a timestamp;
|
|
127
|
+
// the combo picker still independently limits how long a live request waits.
|
|
128
|
+
const maximum = options?.preserveServerDelay === true
|
|
129
|
+
? Number.MAX_SAFE_INTEGER - Math.max(0, now)
|
|
130
|
+
: MAX_COOLDOWN_MS;
|
|
125
131
|
if (/^\d+(?:\.\d+)?$/.test(text)) {
|
|
126
132
|
const seconds = Number(text);
|
|
127
133
|
if (
|
|
128
134
|
Number.isFinite(seconds)
|
|
129
135
|
&& (seconds > 0 || (options?.preserveImmediate && seconds === 0))
|
|
130
136
|
) {
|
|
131
|
-
return Math.min(Math.max(Math.ceil(seconds * 1000), 1),
|
|
137
|
+
return Math.min(Math.max(Math.ceil(seconds * 1000), 1), maximum);
|
|
132
138
|
}
|
|
133
139
|
}
|
|
134
140
|
const timestamp = parseHttpDate(text, now);
|
|
135
141
|
if (timestamp === undefined) return undefined;
|
|
136
142
|
const delay = timestamp - now;
|
|
137
|
-
if (delay > 0) return Math.min(delay,
|
|
143
|
+
if (delay > 0) return Math.min(delay, maximum);
|
|
138
144
|
return options?.preserveImmediate ? 1 : undefined;
|
|
139
145
|
}
|
|
140
146
|
|
|
@@ -215,7 +221,11 @@ export function coolComboTarget(
|
|
|
215
221
|
// A server-provided Retry-After is authoritative, including an immediate `0` directive.
|
|
216
222
|
// A quota reset is the next-most-specific signal (#3256); configured and default cooldowns
|
|
217
223
|
// are only fallbacks when upstream supplied neither usable value.
|
|
218
|
-
const
|
|
224
|
+
const serverDelayMs = parseRetryAfterMs(options?.retryAfter, now, {
|
|
225
|
+
preserveImmediate: true,
|
|
226
|
+
preserveServerDelay: true,
|
|
227
|
+
});
|
|
228
|
+
const cooldownMs = serverDelayMs
|
|
219
229
|
?? parseResetCooldownMs(options?.resetAt, now)
|
|
220
230
|
?? options?.cooldownMs
|
|
221
231
|
?? (isTransientRequestRateLimit({
|
|
@@ -224,7 +234,9 @@ export function coolComboTarget(
|
|
|
224
234
|
message: options?.message,
|
|
225
235
|
}) ? COMBO_REQUEST_RATE_COOLDOWN_MS : DEFAULT_COOLDOWN_MS);
|
|
226
236
|
targetCooldowns.set(cooldownMapKey(comboId, target), {
|
|
227
|
-
|
|
237
|
+
// Only the locally chosen fallback is capped at ten minutes. An explicit
|
|
238
|
+
// server lower bound (including one hour) remains authoritative.
|
|
239
|
+
cooldownUntil: now + (serverDelayMs ?? Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS)),
|
|
228
240
|
});
|
|
229
241
|
sweepExpiredOnWrite(now);
|
|
230
242
|
}
|
|
@@ -387,6 +399,9 @@ function isRequestLocalTargetIncompatibility(status: number, message: string, co
|
|
|
387
399
|
if (errorCode === "unsupported_value"
|
|
388
400
|
&& (e.param === "reasoning.effort" || e.param === "reasoning_effort")
|
|
389
401
|
&& e.message.startsWith("Unsupported value:") && e.message.includes("not supported")) return true;
|
|
402
|
+
if (e.code === null
|
|
403
|
+
&& e.param === "reasoning_effort"
|
|
404
|
+
&& /^Function tools with reasoning_effort are not supported for gpt-6-astra(?:-\d{4}-\d{2}-\d{2})? in \/v1\/chat\/completions\. To use function tools, use \/v1\/responses or set reasoning_effort to 'none'\.$/.test(e.message)) return true;
|
|
390
405
|
return e.param === "input"
|
|
391
406
|
&& (errorCode === "" || errorCode === "invalid_request_error")
|
|
392
407
|
&& /^Model '[^']{1,256}' does not support image inputs\./.test(e.message);
|
|
@@ -394,6 +409,103 @@ function isRequestLocalTargetIncompatibility(status: number, message: string, co
|
|
|
394
409
|
return false;
|
|
395
410
|
}
|
|
396
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Codes a gateway uses when it declines a request field it cannot serve.
|
|
414
|
+
*
|
|
415
|
+
* Wider than the set {@link isRequestLocalTargetIncompatibility} accepts, by exactly one member:
|
|
416
|
+
* Alibaba's gateway reports `invalid_parameter_error`. It is kept in its own set rather than
|
|
417
|
+
* added to the shared one, because that set also governs the `user` parameter and image-input
|
|
418
|
+
* branches and widening it there would admit shapes those branches were reasoned about without.
|
|
419
|
+
*/
|
|
420
|
+
const RESPONSE_FORMAT_REFUSAL_CODES = new Set([
|
|
421
|
+
"",
|
|
422
|
+
"invalid_request_error",
|
|
423
|
+
"invalid_parameter_error",
|
|
424
|
+
"unsupported_parameter",
|
|
425
|
+
"unsupported_value",
|
|
426
|
+
]);
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Does this message say the target cannot PROVIDE `response_format`, rather than that the
|
|
430
|
+
* request's `response_format` was malformed?
|
|
431
|
+
*
|
|
432
|
+
* That distinction is the whole point of #4903 and it is why neither obvious option was taken.
|
|
433
|
+
* Hopping on every 400 would replay a genuinely malformed request against every remaining
|
|
434
|
+
* target. Dropping `response_format` would silently change the output contract the caller
|
|
435
|
+
* asked for, on a path whose entire purpose is a structured result.
|
|
436
|
+
*
|
|
437
|
+
* So both halves are required: the message must name the field, AND it must say the field is
|
|
438
|
+
* unavailable or unsupported. "Invalid schema for response_format" names the field and claims
|
|
439
|
+
* nothing about capability, so it stays terminal.
|
|
440
|
+
*
|
|
441
|
+
* `param` may be absent or explicitly null -- the reported gateway sends `param: null` -- but a
|
|
442
|
+
* param naming a DIFFERENT field contradicts the message and fails closed.
|
|
443
|
+
*/
|
|
444
|
+
function namesResponseFormatIncapability(message: string, param: unknown): boolean {
|
|
445
|
+
if (param !== undefined && param !== null && param !== "response_format") return false;
|
|
446
|
+
const text = message.toLowerCase();
|
|
447
|
+
if (!text.includes("response_format")) return false;
|
|
448
|
+
return /(unavailable|not available|unsupported|not supported|does not support|doesn't support|cannot be used|is not enabled)/u
|
|
449
|
+
.test(text);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* A `response_format` capability gap is target-local: this model cannot produce the requested
|
|
454
|
+
* output shape, which says nothing about the next target in the combo.
|
|
455
|
+
*
|
|
456
|
+
* Reported against a shadow title-generation call, where a combo's first target rejects
|
|
457
|
+
* `response_format` and the chain stops instead of trying the target behind it (#4903).
|
|
458
|
+
*
|
|
459
|
+
* The next target receives the SAME request, `response_format` included, so a target that can
|
|
460
|
+
* honour the contract honours it and one that cannot is skipped in turn. Traversal stays finite
|
|
461
|
+
* because combo excludes each attempted target and policy tries each candidate once.
|
|
462
|
+
*
|
|
463
|
+
* The envelope is bounded exactly like {@link isRequestLocalTargetIncompatibility}: an intact
|
|
464
|
+
* provider JSON object, a depth budget, `type: "invalid_request_error"`, and a code from a
|
|
465
|
+
* closed set. Nothing is inferred from echoed prompt text and no field is removed.
|
|
466
|
+
*/
|
|
467
|
+
function isResponseFormatCapabilityRefusal(
|
|
468
|
+
status: number,
|
|
469
|
+
message: string,
|
|
470
|
+
code?: string | null,
|
|
471
|
+
): boolean {
|
|
472
|
+
if (status !== 400 || message.length > 16_384) return false;
|
|
473
|
+
if (!RESPONSE_FORMAT_REFUSAL_CODES.has(normalizedFailureCode(code))) return false;
|
|
474
|
+
let text = message.trim();
|
|
475
|
+
for (let depth = 0; depth < 3; depth += 1) {
|
|
476
|
+
if (text.startsWith("Provider error 400: ")) {
|
|
477
|
+
text = text.slice("Provider error 400: ".length).trim();
|
|
478
|
+
}
|
|
479
|
+
// A chat gateway can report the refusal inside a single SSE frame, and the combo consumer
|
|
480
|
+
// keeps the raw text when that frame stops the error object from being extracted -- which is
|
|
481
|
+
// why the reported classification text reads `data: {"error":...}` and why the structured
|
|
482
|
+
// code arrives undefined. Exactly one `data:` prefix is removed, and only when the body is
|
|
483
|
+
// one line: this unwraps a single frame rather than parsing a stream, so a multi-event body
|
|
484
|
+
// is left alone and still fails closed.
|
|
485
|
+
if (text.startsWith("data:") && !text.includes("\n")) {
|
|
486
|
+
text = text.slice("data:".length).trim();
|
|
487
|
+
}
|
|
488
|
+
let payload: unknown;
|
|
489
|
+
try { payload = JSON.parse(text); } catch { return false; }
|
|
490
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
491
|
+
const error = (payload as Record<string, unknown>).error;
|
|
492
|
+
if (!error || typeof error !== "object" || Array.isArray(error)) return false;
|
|
493
|
+
const e = error as Record<string, unknown>;
|
|
494
|
+
if (e.code !== undefined && e.code !== null && typeof e.code !== "string") return false;
|
|
495
|
+
if (typeof e.message !== "string") return false;
|
|
496
|
+
// Our own wrapper, re-wrapped by a downstream hop. Peel it and look again, within budget.
|
|
497
|
+
if (e.message.startsWith("Provider error 400: ") && e.param === undefined) {
|
|
498
|
+
text = e.message;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (e.type !== "invalid_request_error") return false;
|
|
502
|
+
const errorCode = normalizedFailureCode(typeof e.code === "string" ? e.code : undefined);
|
|
503
|
+
if (!RESPONSE_FORMAT_REFUSAL_CODES.has(errorCode)) return false;
|
|
504
|
+
return namesResponseFormatIncapability(e.message, e.param);
|
|
505
|
+
}
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
|
|
397
509
|
export function comboFailureCooldownScope(
|
|
398
510
|
status: number,
|
|
399
511
|
message: string,
|
|
@@ -411,6 +523,9 @@ export function comboFailureCooldownScope(
|
|
|
411
523
|
|| isProviderTargetContextOverflow(status, message, options?.code)
|
|
412
524
|
|| isDefiniteContextOverflow(status, message)
|
|
413
525
|
|| isRequestLocalTargetIncompatibility(status, message, options?.code)
|
|
526
|
+
// A capability gap says the target is healthy and the request did not fit it, which is the
|
|
527
|
+
// same reason every other entry here refuses to cool a target.
|
|
528
|
+
|| isResponseFormatCapabilityRefusal(status, message, options?.code)
|
|
414
529
|
) return "none";
|
|
415
530
|
if (isProviderScopedQuotaCap(status, message, options?.code)) return "provider";
|
|
416
531
|
// A rejected or unpaid credential is provider-wide evidence: every target that routes
|
|
@@ -595,6 +710,11 @@ export function comboFailureDecision(
|
|
|
595
710
|
// per-request cap, not provider-wide evidence), so keep its hop verdict explicit here.
|
|
596
711
|
if (failureCode === "free_rate_limited") return "hop";
|
|
597
712
|
if (isRequestLocalTargetIncompatibility(status, message, options?.code)) return "hop";
|
|
713
|
+
// Must precede the generic `invalid_request_error` stop below, which is where this refusal
|
|
714
|
+
// ended the chain: the gateway reports `type: "invalid_request_error"`, so the classifier
|
|
715
|
+
// reaches that list and returns terminal before anything can ask whether the next target
|
|
716
|
+
// could have served the request (#4903).
|
|
717
|
+
if (isResponseFormatCapabilityRefusal(status, message, options?.code)) return "hop";
|
|
598
718
|
if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
|
|
599
719
|
return "stop";
|
|
600
720
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { readConfigAdmissionSnapshot } from "./diagnostics";
|
|
2
|
+
import { getConfigPath } from "./paths";
|
|
3
|
+
import { canonicalPlainData, copyPlainData, isPlainObject, ownDataKeys } from "../lib/plain-data";
|
|
4
|
+
import type { OcxConfig } from "../types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A configuration a roster may be built from, detached from the object the caller holds.
|
|
8
|
+
*
|
|
9
|
+
* The detachment is the point. A gather suspends, and while it is suspended a management route can
|
|
10
|
+
* edit the resident configuration in place; gathering from one state and projecting from another
|
|
11
|
+
* would produce rows that belong to neither. Every authoritative pass uses this copy from
|
|
12
|
+
* beginning to end, so what the resident object does in the meantime becomes a question about
|
|
13
|
+
* whether the result may be retained rather than a question about what the result is.
|
|
14
|
+
*
|
|
15
|
+
* What this deliberately does NOT do is require the resident configuration to equal the file on
|
|
16
|
+
* disk. The proxy routes by the configuration it is holding, so that is the configuration a
|
|
17
|
+
* preview and the mutation it authorizes must both describe. A file the operator has edited and
|
|
18
|
+
* the process has not adopted is a supported state rather than a fault: live reconciliation
|
|
19
|
+
* (src/config/live-reconcile.ts) merges persisted state while deliberately retaining live changes
|
|
20
|
+
* and the active listener binding, and can persist a binding the resident object does not have.
|
|
21
|
+
* Demanding equality would make preview permanently unavailable on exactly those configurations
|
|
22
|
+
* while proving nothing about the rows, which come from the resident object either way.
|
|
23
|
+
*/
|
|
24
|
+
export interface ExportConfigAdmission {
|
|
25
|
+
readonly config: OcxConfig;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What was true when an admission was captured, kept here rather than on the admission object.
|
|
30
|
+
*
|
|
31
|
+
* None of it is data a consumer has any business reading: the file term is a digest of the
|
|
32
|
+
* operator's configuration file and the canonical form contains their credentials. Holding it in a
|
|
33
|
+
* module WeakMap means an admission can be passed around, and even serialized by a careless
|
|
34
|
+
* caller, without carrying any of it.
|
|
35
|
+
*/
|
|
36
|
+
interface AdmissionEvidence {
|
|
37
|
+
readonly path: string;
|
|
38
|
+
readonly file: string;
|
|
39
|
+
readonly data: string;
|
|
40
|
+
readonly executors: ReadonlyMap<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const evidence = new WeakMap<ExportConfigAdmission, AdmissionEvidence>();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Detach the configuration a roster is about to be built from, and record what it was.
|
|
47
|
+
*
|
|
48
|
+
* Two things are recorded because two things can move independently. The resident configuration is
|
|
49
|
+
* what the rows are derived from, so its complete structure is captured: structurally rather than
|
|
50
|
+
* as a list of the fields that seemed to matter, because such a list is only as complete as
|
|
51
|
+
* whoever last thought about it and this one had already missed export-affecting configuration.
|
|
52
|
+
* The configuration file is recorded beside it, so a roster does not outlive an operator editing
|
|
53
|
+
* the configuration under a process that has not adopted it yet.
|
|
54
|
+
*
|
|
55
|
+
* Null when the file cannot be read, when it is there but the loader would have had to salvage it,
|
|
56
|
+
* and when the configuration object cannot be copied as plain data. Every caller fails closed on
|
|
57
|
+
* it: refusing a preview costs an ordinary load, and serving one that describes a configuration
|
|
58
|
+
* nobody has costs a file the operator did not ask for.
|
|
59
|
+
*/
|
|
60
|
+
export function captureExportConfigAdmission(live: OcxConfig): ExportConfigAdmission | null {
|
|
61
|
+
const path = getConfigPath();
|
|
62
|
+
const file = admittedFileTerm();
|
|
63
|
+
if (file === null) return null;
|
|
64
|
+
const resident = detachConfig(live);
|
|
65
|
+
if (resident === null) return null;
|
|
66
|
+
const config = withExecutors(resident);
|
|
67
|
+
if (config === null) return null;
|
|
68
|
+
const admission: ExportConfigAdmission = { config };
|
|
69
|
+
evidence.set(admission, { path, file, data: canonicalPlainData(resident.data), executors: resident.executors });
|
|
70
|
+
return admission;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether an admission still describes the configuration in hand and the file it was taken beside.
|
|
75
|
+
*
|
|
76
|
+
* Three things are checked because three things can move: the file can be rewritten, the resident
|
|
77
|
+
* object can be edited in place, and a consumer of the detached copy can mutate what it was given.
|
|
78
|
+
* The last matters as much as the others, because a pass that edited its own input and then
|
|
79
|
+
* published would be retaining a roster under a state that no longer describes even that input.
|
|
80
|
+
*
|
|
81
|
+
* Passive: it reads the configuration file and nothing else. No credential is resolved, no
|
|
82
|
+
* provider is contacted, no path is hardened and nothing is written.
|
|
83
|
+
*/
|
|
84
|
+
export function isExportConfigAdmissionCurrent(admission: ExportConfigAdmission, live: OcxConfig): boolean {
|
|
85
|
+
const captured = evidence.get(admission);
|
|
86
|
+
if (captured === undefined) return false;
|
|
87
|
+
// A different configuration home is a different question, not a stale answer to this one.
|
|
88
|
+
if (getConfigPath() !== captured.path) return false;
|
|
89
|
+
const file = admittedFileTerm();
|
|
90
|
+
if (file === null || file !== captured.file) return false;
|
|
91
|
+
const resident = detachConfig(live);
|
|
92
|
+
if (resident === null || canonicalPlainData(resident.data) !== captured.data) return false;
|
|
93
|
+
if (!sameExecutors(resident.executors, captured.executors)) return false;
|
|
94
|
+
const working = detachConfig(admission.config);
|
|
95
|
+
return working !== null
|
|
96
|
+
&& canonicalPlainData(working.data) === captured.data
|
|
97
|
+
&& sameExecutors(working.executors, captured.executors);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A plain-data copy of a configuration for a consumer that must not observe later edits, or null.
|
|
102
|
+
*
|
|
103
|
+
* The integration writer is the case this exists for. It freezes every other resolution seam
|
|
104
|
+
* before its first await and then held the configuration by reference, so a plan checked under one
|
|
105
|
+
* configuration could be written from another: the check and the document it authorized were
|
|
106
|
+
* reading the same object at two different moments. One copy taken before the await gives both of
|
|
107
|
+
* them the same configuration.
|
|
108
|
+
*
|
|
109
|
+
* Null rather than the caller's object when the copy cannot be made. Handing back the reference
|
|
110
|
+
* would have been a copy in name only, and the caller would have gone on to describe it as the
|
|
111
|
+
* configuration it checked.
|
|
112
|
+
*/
|
|
113
|
+
export function detachedConfigSnapshot(config: OcxConfig): OcxConfig | null {
|
|
114
|
+
const detached = detachConfig(config);
|
|
115
|
+
return detached === null ? null : withExecutors(detached);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The configuration file as an opaque term: its exact bytes, or the distinguished absence of one.
|
|
120
|
+
*
|
|
121
|
+
* This is a byte observation and nothing more. It says the operator's configuration file has not
|
|
122
|
+
* been rewritten since a roster was built; it is not a claim about whether the configuration the
|
|
123
|
+
* process is holding agrees with that file.
|
|
124
|
+
*
|
|
125
|
+
* Null for a file that cannot be read, because then a later read cannot tell whether it changed.
|
|
126
|
+
* Null too for one that is there and does not load cleanly, which is the existing contract for a
|
|
127
|
+
* derived roster rather than an inference about the resident configuration. Before this, a digest
|
|
128
|
+
* was accepted ahead of any look at what the parse produced.
|
|
129
|
+
*
|
|
130
|
+
* Absence is a configuration rather than the lack of one. No file means defaults, which is an
|
|
131
|
+
* ordinary fresh install and the ordinary state in CI.
|
|
132
|
+
*/
|
|
133
|
+
function admittedFileTerm(): string | null {
|
|
134
|
+
const snapshot = readConfigAdmissionSnapshot();
|
|
135
|
+
const { source, error } = snapshot.diagnostics;
|
|
136
|
+
if (snapshot.kind === "read") return source === "file" && error === null ? snapshot.contentSha256 : null;
|
|
137
|
+
return source === "default" && error === null ? "absent" : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface DetachedConfig {
|
|
141
|
+
readonly data: Record<string, unknown>;
|
|
142
|
+
readonly executors: ReadonlyMap<string, unknown>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A configuration as plain data, with the transport executors kept out of it.
|
|
147
|
+
*
|
|
148
|
+
* The copier refuses everything JSON could not have produced, so the one thing that needs handling
|
|
149
|
+
* here is a provider's fetch executor: a caller owns it and the gather uses it instead of the
|
|
150
|
+
* global transport. It is held by reference for the detached copy and compared by reference
|
|
151
|
+
* afterwards, so replacing it invalidates the binding while it is never serialized.
|
|
152
|
+
*/
|
|
153
|
+
function detachConfig(live: OcxConfig): DetachedConfig | null {
|
|
154
|
+
const executors = new Map<string, unknown>();
|
|
155
|
+
const root = live as unknown;
|
|
156
|
+
if (!isPlainObject(root)) return null;
|
|
157
|
+
const data: Record<string, unknown> = {};
|
|
158
|
+
for (const key of ownDataKeys(root)) {
|
|
159
|
+
if (key === null) return null;
|
|
160
|
+
const value = root[key];
|
|
161
|
+
if (value === undefined) continue;
|
|
162
|
+
if (key !== "providers") {
|
|
163
|
+
const copied = copyPlainData(value);
|
|
164
|
+
if (!copied.ok) return null;
|
|
165
|
+
data[key] = copied.value;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!isPlainObject(value)) return null;
|
|
169
|
+
const providers: Record<string, unknown> = {};
|
|
170
|
+
for (const name of ownDataKeys(value)) {
|
|
171
|
+
if (name === null) return null;
|
|
172
|
+
const provider = value[name];
|
|
173
|
+
if (provider === undefined) continue;
|
|
174
|
+
if (!isPlainObject(provider)) return null;
|
|
175
|
+
const copiedProvider: Record<string, unknown> = {};
|
|
176
|
+
for (const field of ownDataKeys(provider)) {
|
|
177
|
+
if (field === null) return null;
|
|
178
|
+
const fieldValue = provider[field];
|
|
179
|
+
if (fieldValue === undefined) continue;
|
|
180
|
+
// The executor exception applies to an executor. A provider entry keeps unknown
|
|
181
|
+
// configuration keys, so a fetch value that is not a function is something an operator
|
|
182
|
+
// wrote into the file, and it is copied and compared as the data it is. Discovery reads it
|
|
183
|
+
// the same way: the outbound transport takes its built-in path unless the value is
|
|
184
|
+
// callable.
|
|
185
|
+
if (field === "fetch" && typeof fieldValue === "function") {
|
|
186
|
+
executors.set(name, fieldValue);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const copied = copyPlainData(fieldValue);
|
|
190
|
+
if (!copied.ok) return null;
|
|
191
|
+
copiedProvider[field] = copied.value;
|
|
192
|
+
}
|
|
193
|
+
providers[name] = copiedProvider;
|
|
194
|
+
}
|
|
195
|
+
data[key] = providers;
|
|
196
|
+
}
|
|
197
|
+
return { data, executors };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The copy a consumer runs against, with the executors put back by reference. */
|
|
201
|
+
function withExecutors(detached: DetachedConfig): OcxConfig | null {
|
|
202
|
+
// A second copy, so what is compared later is never the object handed to a consumer.
|
|
203
|
+
const copied = copyPlainData(detached.data);
|
|
204
|
+
if (!copied.ok) return null;
|
|
205
|
+
const config = copied.value;
|
|
206
|
+
const providers = config.providers;
|
|
207
|
+
if (isPlainObject(providers)) {
|
|
208
|
+
for (const [name, executor] of detached.executors) {
|
|
209
|
+
const provider = providers[name];
|
|
210
|
+
if (isPlainObject(provider)) provider.fetch = executor;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return config as unknown as OcxConfig;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function sameExecutors(left: ReadonlyMap<string, unknown>, right: ReadonlyMap<string, unknown>): boolean {
|
|
217
|
+
if (left.size !== right.size) return false;
|
|
218
|
+
for (const [name, executor] of left) {
|
|
219
|
+
if (!right.has(name) || right.get(name) !== executor) return false;
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
}
|