@bitkyc08/opencodex 2.57.0 → 2.59.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-C5IebErG.js +136 -0
- package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
- 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 +2 -2
- package/src/adapters/base.ts +11 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
- package/src/adapters/command-code.ts +13 -4
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +65 -1
- 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 +5 -1
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-events.ts +110 -11
- package/src/adapters/cursor/protobuf-request.ts +27 -6
- package/src/adapters/cursor/request-builder.ts +14 -3
- package/src/adapters/cursor/text-toolcall.ts +230 -0
- package/src/adapters/cursor/thread-continuity.ts +141 -0
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/types.ts +5 -0
- package/src/adapters/cursor.ts +97 -6
- package/src/adapters/devin/cloud-direct/chat.ts +11 -2
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
- package/src/adapters/devin.ts +75 -13
- package/src/adapters/google-antigravity-wire.ts +29 -2
- package/src/adapters/google-http.ts +45 -13
- package/src/adapters/google.ts +23 -4
- package/src/adapters/mimo-free.ts +32 -17
- package/src/adapters/ollama-native.ts +42 -8
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat.ts +5 -10
- package/src/adapters/openai-responses/passthrough.ts +40 -5
- package/src/adapters/openai-responses/request-strips.ts +43 -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/physical-send.ts +50 -0
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +2 -2
- package/src/bridge/sse.ts +166 -25
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +46 -5
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +22 -2
- 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/config-command.ts +35 -18
- package/src/cli/dispatch.ts +71 -4
- package/src/cli/doctor.ts +197 -2
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +132 -22
- package/src/cli/models-runtime.ts +33 -4
- 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 +72 -1
- package/src/cli/uninstall-client-state.ts +12 -0
- package/src/client/machine-api.ts +4 -3
- package/src/client/machine-listener.ts +14 -1
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +81 -5
- package/src/codex/auth-api/pool-quota-probe.ts +14 -3
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +58 -20
- 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 +1 -0
- package/src/codex/catalog/model-hints.ts +37 -5
- package/src/codex/catalog/parsing.ts +83 -5
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +19 -0
- package/src/codex/catalog/routed-gather.ts +42 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desktop-switches.ts +145 -0
- package/src/codex/history-job.ts +5 -1
- package/src/codex/history-provider.ts +37 -5
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/history-worker.ts +14 -1
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject/remove.ts +145 -7
- package/src/codex/inject/restore.ts +204 -32
- package/src/codex/inject.ts +6 -9
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +40 -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-entitlements.ts +60 -1
- package/src/codex/native-profile-startup.ts +64 -20
- package/src/codex/observed-model-denials.ts +137 -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 +282 -12
- 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 +13 -15
- package/src/codex/subagent-model-fallback.ts +94 -0
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +122 -5
- package/src/config/atomic-write.ts +83 -8
- package/src/config/diagnostics.ts +21 -0
- package/src/config/load-degrade.ts +15 -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 +23 -5
- package/src/config/schema/config-schema.ts +23 -0
- package/src/config/schema/leaf-validators.ts +65 -17
- package/src/generated/compatibility-version.json +337 -201
- package/src/generated/model-metadata.ts +1 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/bounded-subprocess.ts +62 -10
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +3 -15
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/provider-outbound.ts +3 -3
- 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 +631 -0
- package/src/lib/spend-reservation-ledger.ts +115 -9
- package/src/lib/windows-secret-acl.ts +151 -15
- package/src/lib/windows-user-principal.ts +5 -1
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/generic-account-failover.ts +40 -27
- 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 +6 -0
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +261 -7
- package/src/providers/model-discovery.ts +19 -7
- package/src/providers/model-rename-migration.ts +1 -0
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/registry/entries-core.ts +11 -0
- package/src/providers/registry/entries-extended.ts +146 -28
- package/src/providers/registry/model-seeds.ts +136 -29
- package/src/providers/registry/types.ts +9 -0
- package/src/responses/apply-patch-envelope.ts +44 -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/hosted-tool-policy.ts +85 -2
- package/src/responses/schema.ts +9 -2
- package/src/responses/spill-store.ts +17 -0
- package/src/responses/state/body-policy.ts +25 -0
- package/src/responses/state/spill-queue.ts +8 -6
- package/src/responses/state.ts +3 -22
- package/src/router.ts +4 -0
- package/src/server/auth-cors.ts +27 -0
- package/src/server/chat-completions.ts +9 -4
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +24 -2
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/websocket-handler.ts +48 -1
- package/src/server/index.ts +15 -19
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/config-routes.ts +57 -10
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/models-capabilities.ts +24 -3
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/relay.ts +17 -24
- package/src/server/request-log.ts +25 -1
- package/src/server/responses/adapter-continuation.ts +71 -27
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +52 -24
- package/src/server/responses/codex-ws-exchange.ts +65 -4
- package/src/server/responses/combo-stream-preflight.ts +68 -5
- package/src/server/responses/compact.ts +60 -11
- package/src/server/responses/core-codex-account.ts +83 -22
- package/src/server/responses/core-combo.ts +26 -0
- package/src/server/responses/core-normalize.ts +12 -5
- package/src/server/responses/core-options.ts +3 -0
- package/src/server/responses/fetch-helpers.ts +72 -3
- package/src/server/responses/native-injection-protocol.ts +42 -0
- package/src/server/responses/native-injection-replay.ts +105 -0
- package/src/server/responses/native-injection.ts +242 -0
- package/src/server/responses/native-response-control.ts +56 -0
- package/src/server/responses/native-response-json.ts +14 -0
- package/src/server/responses/native-response-output.ts +37 -0
- package/src/server/responses/native-steering-log.ts +44 -0
- package/src/server/responses/native-steering-policy.ts +49 -0
- package/src/server/responses/native-steering-replay.ts +126 -0
- package/src/server/responses/native-steering-settings.ts +76 -0
- package/src/server/responses/native-steering.ts +400 -0
- package/src/server/responses/native-tool-results.ts +130 -0
- package/src/server/responses/passthrough-delivery.ts +21 -1
- package/src/server/responses/passthrough-dispatch.ts +146 -49
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/request-prepare.ts +70 -0
- package/src/server/responses/request-send-budget.ts +84 -7
- package/src/server/responses/request-sidecar-auth.ts +16 -8
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +13 -10
- package/src/server/responses/run-turn-execution.ts +20 -5
- package/src/server/responses/sidecar-execution.ts +2 -0
- package/src/server/responses/ws-upstream.ts +23 -2
- package/src/server/responses-custom-tool-repair.ts +2 -2
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +36 -9
- package/src/server/stop-teardown.ts +8 -1
- 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/server/ws-bridge.ts +16 -1
- package/src/service/cli.ts +29 -7
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +104 -3
- package/src/types/provider.ts +32 -0
- package/src/types/request.ts +7 -1
- package/src/types/wire.ts +9 -1
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-Cz7CLdif.js +0 -128
|
@@ -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
|
}
|
|
@@ -394,6 +406,103 @@ function isRequestLocalTargetIncompatibility(status: number, message: string, co
|
|
|
394
406
|
return false;
|
|
395
407
|
}
|
|
396
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Codes a gateway uses when it declines a request field it cannot serve.
|
|
411
|
+
*
|
|
412
|
+
* Wider than the set {@link isRequestLocalTargetIncompatibility} accepts, by exactly one member:
|
|
413
|
+
* Alibaba's gateway reports `invalid_parameter_error`. It is kept in its own set rather than
|
|
414
|
+
* added to the shared one, because that set also governs the `user` parameter and image-input
|
|
415
|
+
* branches and widening it there would admit shapes those branches were reasoned about without.
|
|
416
|
+
*/
|
|
417
|
+
const RESPONSE_FORMAT_REFUSAL_CODES = new Set([
|
|
418
|
+
"",
|
|
419
|
+
"invalid_request_error",
|
|
420
|
+
"invalid_parameter_error",
|
|
421
|
+
"unsupported_parameter",
|
|
422
|
+
"unsupported_value",
|
|
423
|
+
]);
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Does this message say the target cannot PROVIDE `response_format`, rather than that the
|
|
427
|
+
* request's `response_format` was malformed?
|
|
428
|
+
*
|
|
429
|
+
* That distinction is the whole point of #4903 and it is why neither obvious option was taken.
|
|
430
|
+
* Hopping on every 400 would replay a genuinely malformed request against every remaining
|
|
431
|
+
* target. Dropping `response_format` would silently change the output contract the caller
|
|
432
|
+
* asked for, on a path whose entire purpose is a structured result.
|
|
433
|
+
*
|
|
434
|
+
* So both halves are required: the message must name the field, AND it must say the field is
|
|
435
|
+
* unavailable or unsupported. "Invalid schema for response_format" names the field and claims
|
|
436
|
+
* nothing about capability, so it stays terminal.
|
|
437
|
+
*
|
|
438
|
+
* `param` may be absent or explicitly null -- the reported gateway sends `param: null` -- but a
|
|
439
|
+
* param naming a DIFFERENT field contradicts the message and fails closed.
|
|
440
|
+
*/
|
|
441
|
+
function namesResponseFormatIncapability(message: string, param: unknown): boolean {
|
|
442
|
+
if (param !== undefined && param !== null && param !== "response_format") return false;
|
|
443
|
+
const text = message.toLowerCase();
|
|
444
|
+
if (!text.includes("response_format")) return false;
|
|
445
|
+
return /(unavailable|not available|unsupported|not supported|does not support|doesn't support|cannot be used|is not enabled)/u
|
|
446
|
+
.test(text);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* A `response_format` capability gap is target-local: this model cannot produce the requested
|
|
451
|
+
* output shape, which says nothing about the next target in the combo.
|
|
452
|
+
*
|
|
453
|
+
* Reported against a shadow title-generation call, where a combo's first target rejects
|
|
454
|
+
* `response_format` and the chain stops instead of trying the target behind it (#4903).
|
|
455
|
+
*
|
|
456
|
+
* The next target receives the SAME request, `response_format` included, so a target that can
|
|
457
|
+
* honour the contract honours it and one that cannot is skipped in turn. Traversal stays finite
|
|
458
|
+
* because combo excludes each attempted target and policy tries each candidate once.
|
|
459
|
+
*
|
|
460
|
+
* The envelope is bounded exactly like {@link isRequestLocalTargetIncompatibility}: an intact
|
|
461
|
+
* provider JSON object, a depth budget, `type: "invalid_request_error"`, and a code from a
|
|
462
|
+
* closed set. Nothing is inferred from echoed prompt text and no field is removed.
|
|
463
|
+
*/
|
|
464
|
+
function isResponseFormatCapabilityRefusal(
|
|
465
|
+
status: number,
|
|
466
|
+
message: string,
|
|
467
|
+
code?: string | null,
|
|
468
|
+
): boolean {
|
|
469
|
+
if (status !== 400 || message.length > 16_384) return false;
|
|
470
|
+
if (!RESPONSE_FORMAT_REFUSAL_CODES.has(normalizedFailureCode(code))) return false;
|
|
471
|
+
let text = message.trim();
|
|
472
|
+
for (let depth = 0; depth < 3; depth += 1) {
|
|
473
|
+
if (text.startsWith("Provider error 400: ")) {
|
|
474
|
+
text = text.slice("Provider error 400: ".length).trim();
|
|
475
|
+
}
|
|
476
|
+
// A chat gateway can report the refusal inside a single SSE frame, and the combo consumer
|
|
477
|
+
// keeps the raw text when that frame stops the error object from being extracted -- which is
|
|
478
|
+
// why the reported classification text reads `data: {"error":...}` and why the structured
|
|
479
|
+
// code arrives undefined. Exactly one `data:` prefix is removed, and only when the body is
|
|
480
|
+
// one line: this unwraps a single frame rather than parsing a stream, so a multi-event body
|
|
481
|
+
// is left alone and still fails closed.
|
|
482
|
+
if (text.startsWith("data:") && !text.includes("\n")) {
|
|
483
|
+
text = text.slice("data:".length).trim();
|
|
484
|
+
}
|
|
485
|
+
let payload: unknown;
|
|
486
|
+
try { payload = JSON.parse(text); } catch { return false; }
|
|
487
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
488
|
+
const error = (payload as Record<string, unknown>).error;
|
|
489
|
+
if (!error || typeof error !== "object" || Array.isArray(error)) return false;
|
|
490
|
+
const e = error as Record<string, unknown>;
|
|
491
|
+
if (e.code !== undefined && e.code !== null && typeof e.code !== "string") return false;
|
|
492
|
+
if (typeof e.message !== "string") return false;
|
|
493
|
+
// Our own wrapper, re-wrapped by a downstream hop. Peel it and look again, within budget.
|
|
494
|
+
if (e.message.startsWith("Provider error 400: ") && e.param === undefined) {
|
|
495
|
+
text = e.message;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (e.type !== "invalid_request_error") return false;
|
|
499
|
+
const errorCode = normalizedFailureCode(typeof e.code === "string" ? e.code : undefined);
|
|
500
|
+
if (!RESPONSE_FORMAT_REFUSAL_CODES.has(errorCode)) return false;
|
|
501
|
+
return namesResponseFormatIncapability(e.message, e.param);
|
|
502
|
+
}
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
|
|
397
506
|
export function comboFailureCooldownScope(
|
|
398
507
|
status: number,
|
|
399
508
|
message: string,
|
|
@@ -411,6 +520,9 @@ export function comboFailureCooldownScope(
|
|
|
411
520
|
|| isProviderTargetContextOverflow(status, message, options?.code)
|
|
412
521
|
|| isDefiniteContextOverflow(status, message)
|
|
413
522
|
|| isRequestLocalTargetIncompatibility(status, message, options?.code)
|
|
523
|
+
// A capability gap says the target is healthy and the request did not fit it, which is the
|
|
524
|
+
// same reason every other entry here refuses to cool a target.
|
|
525
|
+
|| isResponseFormatCapabilityRefusal(status, message, options?.code)
|
|
414
526
|
) return "none";
|
|
415
527
|
if (isProviderScopedQuotaCap(status, message, options?.code)) return "provider";
|
|
416
528
|
// A rejected or unpaid credential is provider-wide evidence: every target that routes
|
|
@@ -595,6 +707,11 @@ export function comboFailureDecision(
|
|
|
595
707
|
// per-request cap, not provider-wide evidence), so keep its hop verdict explicit here.
|
|
596
708
|
if (failureCode === "free_rate_limited") return "hop";
|
|
597
709
|
if (isRequestLocalTargetIncompatibility(status, message, options?.code)) return "hop";
|
|
710
|
+
// Must precede the generic `invalid_request_error` stop below, which is where this refusal
|
|
711
|
+
// ended the chain: the gateway reports `type: "invalid_request_error"`, so the classifier
|
|
712
|
+
// reaches that list and returns terminal before anything can ask whether the next target
|
|
713
|
+
// could have served the request (#4903).
|
|
714
|
+
if (isResponseFormatCapabilityRefusal(status, message, options?.code)) return "hop";
|
|
598
715
|
if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
|
|
599
716
|
return "stop";
|
|
600
717
|
}
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
forgetEphemeralSecretPath,
|
|
18
18
|
hardenSecretPath,
|
|
19
19
|
hardenSecretPathAsync,
|
|
20
|
+
reattributeHardenedSecretPath,
|
|
21
|
+
windowsSecretAclReapPendingForPath,
|
|
20
22
|
} from "../lib/windows-secret-acl";
|
|
21
23
|
import {
|
|
22
24
|
renameAtomicFile,
|
|
@@ -26,6 +28,26 @@ import { getConfigDir } from "./paths";
|
|
|
26
28
|
|
|
27
29
|
let atomicSequence = 0;
|
|
28
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Whether this writer's Windows ACL branch applies.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately a local override rather than the ACL module's
|
|
35
|
+
* `windowsSecretAclApplies()`. That one is flipped by many unrelated suites
|
|
36
|
+
* through `setPlatformForTests("win32")`, and honouring it here would make every
|
|
37
|
+
* such suite start running icacls against config writes on a POSIX runner. The
|
|
38
|
+
* gate a cost fix needs is one only the cost tests can open.
|
|
39
|
+
*/
|
|
40
|
+
let windowsHardeningOverride: boolean | null = null;
|
|
41
|
+
|
|
42
|
+
function windowsHardeningApplies(): boolean {
|
|
43
|
+
return windowsHardeningOverride ?? process.platform === "win32";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Test seam: drive the Windows hardening branch on a POSIX runner. Null restores. */
|
|
47
|
+
export function setWindowsHardeningForTests(enabled: boolean | null): void {
|
|
48
|
+
windowsHardeningOverride = enabled;
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
/** Shared process-wide suffix source for config-owned atomic sibling files. */
|
|
30
52
|
export function nextAtomicTempSequence(): number {
|
|
31
53
|
return ++atomicSequence;
|
|
@@ -114,6 +136,32 @@ function assertPrivateTempDescriptor(path: string, descriptor: number): void {
|
|
|
114
136
|
}
|
|
115
137
|
}
|
|
116
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Carry the harden applied to the empty temp across the content write.
|
|
141
|
+
*
|
|
142
|
+
* The temp is hardened before it holds a byte, and then `atomicWriteFile` hardens
|
|
143
|
+
* it again before the rename. The second call used to be a full icacls sequence
|
|
144
|
+
* rather than a memo hit, because the ACL memo's freshness component is
|
|
145
|
+
* `ctimeNs` and libuv reports that from the NTFS ChangeTime, which a data write
|
|
146
|
+
* moves. So every secret write on Windows applied the same three-step ACL twice:
|
|
147
|
+
* `/grant:r`, `/inheritance:r`, `/remove:g`, plus up to three `/findsid` probes,
|
|
148
|
+
* all of it to arrive at the ACL the file already had.
|
|
149
|
+
*
|
|
150
|
+
* It runs after the descriptor closes, not before, because Windows may not have
|
|
151
|
+
* published the new ChangeTime to a path query while the handle is still open;
|
|
152
|
+
* refreshing to a time the next reader will not see would leave the memo missing
|
|
153
|
+
* and change nothing. What licenses the shortcut is the identity assertion the
|
|
154
|
+
* caller makes immediately before the close, which proves this path resolves to
|
|
155
|
+
* the object the ACL was applied to, plus the object comparison inside
|
|
156
|
+
* `reattributeHardenedSecretPath`, which refuses to move the memo to a different
|
|
157
|
+
* object. Nothing is skipped on the strength of the pathname alone, and a
|
|
158
|
+
* refusal costs only the second full harden this is trying to avoid.
|
|
159
|
+
*/
|
|
160
|
+
function carryHardenAcrossContentWrite(path: string): void {
|
|
161
|
+
if (!windowsHardeningApplies()) return;
|
|
162
|
+
reattributeHardenedSecretPath(path);
|
|
163
|
+
}
|
|
164
|
+
|
|
117
165
|
function writePrivateTempFile(
|
|
118
166
|
path: string,
|
|
119
167
|
content: string,
|
|
@@ -123,16 +171,24 @@ function writePrivateTempFile(
|
|
|
123
171
|
const descriptor = openSync(path, "wx", 0o600);
|
|
124
172
|
onCreated();
|
|
125
173
|
try {
|
|
126
|
-
if (
|
|
174
|
+
if (windowsHardeningApplies()) {
|
|
127
175
|
hardenSecretPath(path, { required: true, timeoutMemoKey });
|
|
128
|
-
}
|
|
176
|
+
}
|
|
177
|
+
// Keyed on the REAL platform, not the override: on a POSIX host the mode is
|
|
178
|
+
// the boundary and `assertPrivateTempDescriptor` demands 0o600, which an
|
|
179
|
+
// ambient umask can otherwise take away from the open above.
|
|
180
|
+
if (process.platform !== "win32") {
|
|
129
181
|
fchmodSync(descriptor, 0o600);
|
|
130
182
|
}
|
|
131
183
|
assertPrivateTempDescriptor(path, descriptor);
|
|
132
184
|
writeFileSync(descriptor, content, { encoding: "utf-8" });
|
|
185
|
+
// Second assertion, after the content write: the object this path resolves
|
|
186
|
+
// to is still the object the ACL was applied to and the one we just wrote.
|
|
187
|
+
assertPrivateTempDescriptor(path, descriptor);
|
|
133
188
|
} finally {
|
|
134
189
|
closeSync(descriptor);
|
|
135
190
|
}
|
|
191
|
+
carryHardenAcrossContentWrite(path);
|
|
136
192
|
}
|
|
137
193
|
|
|
138
194
|
async function writePrivateTempFileAsync(
|
|
@@ -144,16 +200,19 @@ async function writePrivateTempFileAsync(
|
|
|
144
200
|
const descriptor = openSync(path, "wx", 0o600);
|
|
145
201
|
onCreated();
|
|
146
202
|
try {
|
|
147
|
-
if (
|
|
203
|
+
if (windowsHardeningApplies()) {
|
|
148
204
|
await hardenSecretPathAsync(path, { required: true, timeoutMemoKey });
|
|
149
|
-
}
|
|
205
|
+
}
|
|
206
|
+
if (process.platform !== "win32") {
|
|
150
207
|
fchmodSync(descriptor, 0o600);
|
|
151
208
|
}
|
|
152
209
|
assertPrivateTempDescriptor(path, descriptor);
|
|
153
210
|
writeFileSync(descriptor, content, { encoding: "utf-8" });
|
|
211
|
+
assertPrivateTempDescriptor(path, descriptor);
|
|
154
212
|
} finally {
|
|
155
213
|
closeSync(descriptor);
|
|
156
214
|
}
|
|
215
|
+
carryHardenAcrossContentWrite(path);
|
|
157
216
|
}
|
|
158
217
|
|
|
159
218
|
export function atomicWriteFile(
|
|
@@ -171,10 +230,15 @@ export function atomicWriteFile(
|
|
|
171
230
|
const effective: AtomicWriteIO = io ?? {
|
|
172
231
|
write: (tempPath, value) => writePrivateTempFile(tempPath, value, path, () => { ownsTemp = true; }),
|
|
173
232
|
harden: tempPath => {
|
|
174
|
-
|
|
175
|
-
|
|
233
|
+
// No chmod on the Windows branch: there it sets the read-only ATTRIBUTE,
|
|
234
|
+
// which is not the secret boundary and is not what protects this file, and
|
|
235
|
+
// its ChangeTime bump is what used to invalidate the harden memo one line
|
|
236
|
+
// later. On POSIX the mode IS the boundary, so it stays.
|
|
237
|
+
if (windowsHardeningApplies()) {
|
|
176
238
|
hardenSecretPath(tempPath, { required: true, timeoutMemoKey: path });
|
|
239
|
+
return;
|
|
177
240
|
}
|
|
241
|
+
try { chmodSync(tempPath, 0o600); } catch { /* platform may ignore chmod */ }
|
|
178
242
|
},
|
|
179
243
|
rename: renameAtomicFile,
|
|
180
244
|
truncate: tempPath => truncateSync(tempPath, 0),
|
|
@@ -245,10 +309,12 @@ export async function atomicWriteFileAsync(
|
|
|
245
309
|
const effective: AtomicWriteAsyncIO = io ?? {
|
|
246
310
|
write: (tempPath, value) => writePrivateTempFileAsync(tempPath, value, path, () => { ownsTemp = true; }),
|
|
247
311
|
harden: async tempPath => {
|
|
248
|
-
|
|
249
|
-
if (
|
|
312
|
+
// Same reasoning as the synchronous writer above.
|
|
313
|
+
if (windowsHardeningApplies()) {
|
|
250
314
|
await hardenSecretPathAsync(tempPath, { required: true, timeoutMemoKey: path });
|
|
315
|
+
return;
|
|
251
316
|
}
|
|
317
|
+
try { chmodSync(tempPath, 0o600); } catch { /* platform may ignore chmod */ }
|
|
252
318
|
},
|
|
253
319
|
rename: renameAtomicFileAsync,
|
|
254
320
|
truncate: target => truncateSync(target, 0),
|
|
@@ -258,9 +324,11 @@ export async function atomicWriteFileAsync(
|
|
|
258
324
|
assertResolvedTargetAllowed(path, target);
|
|
259
325
|
const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
|
|
260
326
|
let hardened = false;
|
|
327
|
+
let tempWasHardenedBeforeContent = false;
|
|
261
328
|
try {
|
|
262
329
|
if (io) ownsTemp = true;
|
|
263
330
|
await effective.write(tmp, content);
|
|
331
|
+
tempWasHardenedBeforeContent = io === undefined && windowsHardeningApplies();
|
|
264
332
|
await testSeam?.afterTempWrite?.(tmp);
|
|
265
333
|
await effective.harden(tmp);
|
|
266
334
|
hardened = true;
|
|
@@ -268,6 +336,13 @@ export async function atomicWriteFileAsync(
|
|
|
268
336
|
forgetEphemeralSecretPath(tmp);
|
|
269
337
|
} catch (cause) {
|
|
270
338
|
if (!ownsTemp) throw cause;
|
|
339
|
+
// The async ACL belt bounds the writer, but it is not evidence that icacls released this
|
|
340
|
+
// path. Leave the temp in the existing residual state instead of racing an unlink against a
|
|
341
|
+
// live Windows handle. The default Windows writer hardens before writing secret bytes; a
|
|
342
|
+
// failure inside that initial harden leaves its still-empty temp behind.
|
|
343
|
+
if (windowsSecretAclReapPendingForPath(tmp)) {
|
|
344
|
+
throw new AtomicWriteResidualTempError(tmp, tempWasHardenedBeforeContent, { cause });
|
|
345
|
+
}
|
|
271
346
|
let scrubbed = false;
|
|
272
347
|
try {
|
|
273
348
|
await effective.truncate(tmp);
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
malformedQuotaResetNotifyWarning,
|
|
35
35
|
malformedCatalogAutoRefreshWarning,
|
|
36
36
|
malformedCodexPoolWarning,
|
|
37
|
+
malformedSpendWarning,
|
|
37
38
|
rawConfigRecord,
|
|
38
39
|
malformedNativeSubagentFields,
|
|
39
40
|
malformedNativeSubagentFieldWarning,
|
|
@@ -58,6 +59,7 @@ import {
|
|
|
58
59
|
quotaResetNotifySchema,
|
|
59
60
|
remoteGuiConfigSchema,
|
|
60
61
|
runtimeRoleSchema,
|
|
62
|
+
spendSchema,
|
|
61
63
|
} from "./schema/leaf-validators";
|
|
62
64
|
|
|
63
65
|
export type ConfigDiagnostics = {
|
|
@@ -122,6 +124,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
|
|
|
122
124
|
if (catalogRefreshWarning) warnings.push(catalogRefreshWarning);
|
|
123
125
|
const codexPoolWarning = malformedCodexPoolWarning(rawParsed);
|
|
124
126
|
if (codexPoolWarning) warnings.push(codexPoolWarning);
|
|
127
|
+
const spendWarning = malformedSpendWarning(rawParsed);
|
|
128
|
+
if (spendWarning) warnings.push(spendWarning);
|
|
125
129
|
const plaintextWarning = malformedPlaintextV2AgentMessagesWarning(rawParsed);
|
|
126
130
|
if (plaintextWarning) warnings.push(plaintextWarning);
|
|
127
131
|
if (syncDisabledReason) {
|
|
@@ -295,6 +299,22 @@ function catalogAutoRefreshError(value: unknown): string | null {
|
|
|
295
299
|
return `schema_invalid: catalogAutoRefresh${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
|
|
296
300
|
}
|
|
297
301
|
|
|
302
|
+
/**
|
|
303
|
+
* The read path degrades a malformed spend section to undefined, which means no ceiling is
|
|
304
|
+
* enforced. Reject it on write so `ocx config set` cannot store a budget that reads back as
|
|
305
|
+
* configured and refuses nothing -- a ceiling that is not enforced looks identical to one
|
|
306
|
+
* nothing has reached.
|
|
307
|
+
*/
|
|
308
|
+
function spendError(value: unknown): string | null {
|
|
309
|
+
const raw = rawConfigRecord(value);
|
|
310
|
+
if (!raw || !Object.hasOwn(raw, "spend") || raw.spend === undefined) return null;
|
|
311
|
+
const result = spendSchema.safeParse(raw.spend);
|
|
312
|
+
if (result.success) return null;
|
|
313
|
+
const issue = result.error.issues[0];
|
|
314
|
+
const field = issue?.path.join(".");
|
|
315
|
+
return `schema_invalid: spend${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
298
318
|
/**
|
|
299
319
|
* The read path degrades a malformed pool policy to undefined, which for an exclusion policy means
|
|
300
320
|
* the excluded accounts quietly keep serving traffic. Reject it on write so `ocx config set` cannot
|
|
@@ -551,6 +571,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
|
|
|
551
571
|
?? agentTaskRecoveryError(value)
|
|
552
572
|
?? quotaResetNotifyError(value)
|
|
553
573
|
?? catalogAutoRefreshError(value)
|
|
574
|
+
?? spendError(value)
|
|
554
575
|
?? codexPoolError(value)
|
|
555
576
|
?? googleAntigravityStaticCatalogVersionError(value)
|
|
556
577
|
?? codexAccountPrioritiesError(value)
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
remoteGuiConfigSchema,
|
|
34
34
|
retryOn429PolicySchema,
|
|
35
35
|
runtimeRoleSchema,
|
|
36
|
+
spendSchema,
|
|
36
37
|
} from "./schema/leaf-validators";
|
|
37
38
|
import { hasWarnedInheritedFastWireConflict, markWarnedInheritedFastWireConflict } from "./warn-memo";
|
|
38
39
|
|
|
@@ -636,6 +637,20 @@ export function malformedCatalogAutoRefreshWarning(rawParsed: unknown): string |
|
|
|
636
637
|
return `catalogAutoRefresh${field ? `.${field}` : ""} ignored: invalid catalog auto-refresh configuration`;
|
|
637
638
|
}
|
|
638
639
|
|
|
640
|
+
/**
|
|
641
|
+
* The same silent-in-the-wrong-direction failure, and the most expensive instance of it here:
|
|
642
|
+
* a dropped spend section means the ceilings are not enforced, and an unenforced ceiling is
|
|
643
|
+
* indistinguishable from one nothing has reached. The operator finds out from the bill.
|
|
644
|
+
*/
|
|
645
|
+
export function malformedSpendWarning(rawParsed: unknown): string | null {
|
|
646
|
+
const raw = rawConfigRecord(rawParsed);
|
|
647
|
+
if (!raw || !Object.hasOwn(raw, "spend")) return null;
|
|
648
|
+
const result = spendSchema.safeParse(raw.spend);
|
|
649
|
+
if (result.success) return null;
|
|
650
|
+
const field = result.error.issues[0]?.path.join(".");
|
|
651
|
+
return `spend${field ? `.${field}` : ""} ignored: invalid spend ceiling configuration, so no token ceiling is enforced`;
|
|
652
|
+
}
|
|
653
|
+
|
|
639
654
|
/**
|
|
640
655
|
* Same silent-in-the-wrong-direction failure as the notification block: a dropped pool policy means
|
|
641
656
|
* the accounts the operator meant to exclude keep taking traffic, and the only visible symptom is
|