@bitkyc08/opencodex 2.63.0 → 2.64.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/AGENTS_INSTALL.md +4 -3
- package/README.md +46 -33
- package/assets/download-linux.svg +10 -0
- package/assets/download-macos.svg +10 -0
- package/assets/download-windows.svg +10 -0
- package/bin/ocx.mjs +12 -4
- package/gui/dist/assets/App-CpuDF3ci.js +50 -0
- package/gui/dist/assets/App-I5AnaSLh.css +1 -0
- package/gui/dist/assets/{Tray-CncKDBTp.js → Tray-B4uEIa1O.js} +1 -1
- package/gui/dist/assets/index-DiBRuK-d.css +1 -0
- package/gui/dist/assets/index-SggB6t3z.js +86 -0
- package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +1 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/src/protocol.rs +5 -0
- package/package.json +4 -1
- package/src/adapters/anthropic.ts +66 -7
- package/src/adapters/codebuddy/adapter.ts +112 -16
- package/src/adapters/codebuddy/mcp-server.ts +180 -0
- package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
- package/src/adapters/codebuddy/tool-bridge.ts +597 -0
- package/src/adapters/coding-agent/protocol.ts +123 -10
- package/src/adapters/coding-agent/turn.ts +324 -2
- package/src/adapters/command-code-restored-schema.ts +112 -0
- package/src/adapters/command-code-tool-text.ts +589 -0
- package/src/adapters/command-code.ts +80 -6
- package/src/adapters/cursor/catalog.ts +12 -0
- package/src/adapters/cursor/current-request.ts +46 -0
- package/src/adapters/cursor/discovery.ts +1 -1
- package/src/adapters/cursor/effort-map.ts +4 -0
- package/src/adapters/cursor/native-exec.ts +15 -0
- package/src/adapters/cursor/protobuf-events.ts +40 -12
- package/src/adapters/cursor/protobuf-request.ts +83 -18
- package/src/adapters/cursor/request-builder.ts +5 -4
- package/src/adapters/cursor/tool-guidance.ts +1 -1
- package/src/adapters/devin/live-models.ts +7 -0
- package/src/adapters/inline-think-tags.ts +251 -0
- package/src/adapters/kiro/adapter.ts +1 -0
- package/src/adapters/kiro/stream.ts +5 -3
- package/src/adapters/kiro/usage.ts +4 -3
- package/src/adapters/mimo-free.ts +37 -18
- package/src/adapters/openai-chat/messages.ts +5 -5
- package/src/adapters/openai-chat/tool-schema.ts +124 -2
- package/src/adapters/openai-chat.ts +13 -3
- package/src/adapters/openai-responses/passthrough.ts +15 -2
- package/src/adapters/openai-responses/reasoning.ts +14 -1
- package/src/adapters/openai-responses/request-strips.ts +29 -3
- package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
- package/src/adapters/qoder/adapter.ts +15 -12
- package/src/adapters/qoder/scaffold-guard.ts +45 -13
- package/src/bridge/internal.ts +20 -0
- package/src/bridge/sse.ts +14 -5
- package/src/claude/agents-inject.ts +2 -1
- package/src/claude/desktop-applied-marker.ts +44 -0
- package/src/claude/desktop-profile.ts +22 -12
- package/src/claude/inbound.ts +8 -3
- package/src/cli/access.ts +22 -5
- package/src/cli/account-api.ts +6 -0
- package/src/cli/account-auth.ts +5 -2
- package/src/cli/account.ts +5 -6
- package/src/cli/aside-profiles.ts +61 -1
- package/src/cli/claude-desktop.ts +3 -2
- package/src/cli/claude.ts +3 -1
- package/src/cli/codex-cli-update.ts +2 -2
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/dispatch.ts +12 -4
- package/src/cli/index.ts +98 -10
- package/src/cli/registry.ts +1 -1
- package/src/cli/resolve.ts +113 -1
- package/src/cli/root.ts +5 -0
- package/src/cli/stop-approval.ts +186 -0
- package/src/cli/stop-report.ts +1 -1
- package/src/cli/system-restart-client.ts +19 -4
- package/src/client/hub-client.ts +25 -18
- package/src/clients/config-export.ts +12 -4
- package/src/codex/account-auto-switch.ts +59 -0
- package/src/codex/account-lifecycle.ts +7 -0
- package/src/codex/account-priority.ts +10 -0
- package/src/codex/account-usability.ts +9 -0
- package/src/codex/auth-api/account-list.ts +5 -0
- package/src/codex/auth-api/login-flow.ts +15 -5
- package/src/codex/auth-api/login-state.ts +5 -16
- package/src/codex/auth-api/pool-mode-gate.ts +30 -7
- package/src/codex/auth-api/routes.ts +39 -3
- package/src/codex/auth-context.ts +145 -17
- package/src/codex/catalog/build-entries.ts +2 -1
- package/src/codex/catalog/effort.ts +8 -2
- package/src/codex/catalog/metadata.ts +33 -7
- package/src/codex/catalog/native-models.ts +102 -5
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-models.ts +19 -6
- package/src/codex/cli-installation-targets.ts +44 -40
- package/src/codex/codex-write-lock.ts +32 -2
- package/src/codex/inject/multi-agent-v2.ts +90 -0
- package/src/codex/inject/plan.ts +365 -0
- package/src/codex/inject.ts +237 -362
- package/src/codex/log-guard/maintenance.ts +30 -13
- package/src/codex/project-config-warnings.ts +47 -7
- package/src/codex/prompt-layers/encoding.ts +41 -0
- package/src/codex/prompt-layers/toml-read.ts +1 -1
- package/src/codex/prompt-layers.ts +17 -13
- package/src/codex/prompt-text-probe.ts +20 -10
- package/src/codex/quota-observation-freshness.ts +34 -0
- package/src/codex/quota-rejection.ts +8 -4
- package/src/codex/quota.ts +4 -2
- package/src/codex/routing/selection.ts +32 -9
- package/src/codex/routing.ts +53 -11
- package/src/codex/subagent-model-fallback.ts +4 -3
- package/src/codex/sync.ts +11 -0
- package/src/codex/windows-installation-files.ts +33 -11
- package/src/codex/write-coordination.ts +4 -0
- package/src/combos/request.ts +6 -2
- package/src/companion/settings.ts +6 -1
- package/src/config/atomic-write.ts +12 -1
- package/src/config/derived-registries.ts +29 -0
- package/src/config/diagnostics.ts +17 -0
- package/src/config/live-reconcile.ts +11 -2
- package/src/config/load-degrade.ts +9 -2
- package/src/config/persist-unlocked.ts +4 -3
- package/src/config/persisted-mutation.ts +94 -0
- package/src/config/provider-validation.ts +1 -1
- package/src/config/proxy-env.ts +62 -12
- package/src/config/rebase-provenance.ts +80 -1
- package/src/config/schema/config-schema.ts +5 -0
- package/src/config/schema/leaf-validators.ts +50 -1
- package/src/config/subagent-models.ts +41 -15
- package/src/config.ts +18 -95
- package/src/generated/compatibility-version.json +330 -218
- package/src/generated/model-metadata.ts +8 -5
- package/src/github/star-state.ts +46 -6
- package/src/grok/inject.ts +74 -40
- package/src/grok/status.ts +11 -4
- package/src/integrations/cursor-effort-table.ts +50 -13
- package/src/integrations/raycast-detect.ts +19 -4
- package/src/integrations/serialize.ts +11 -1
- package/src/lib/bounded-body.ts +30 -0
- package/src/lib/crash-guard.ts +52 -4
- package/src/lib/local-aside-sync-contract.ts +41 -0
- package/src/lib/package-tree-integrity.ts +181 -9
- package/src/lib/proxy-env.ts +18 -1
- package/src/lib/request-failure-attribution.ts +1 -0
- package/src/lib/request-failure-model.ts +3 -0
- package/src/lib/service-secrets.ts +99 -2
- package/src/lib/socks5-fetch.ts +43 -14
- package/src/lib/token-estimate.ts +17 -2
- package/src/oauth/command-code.ts +3 -2
- package/src/oauth/generic-account-failover.ts +29 -1
- package/src/oauth/index.ts +24 -16
- package/src/oauth/login-flow-state.ts +5 -5
- package/src/oauth/meta-muse-device.ts +49 -7
- package/src/oauth/store.ts +74 -9
- package/src/providers/alibaba-region-backup.ts +16 -1
- package/src/providers/anthropic-fast.ts +89 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
- package/src/providers/anthropic-reset-grants.ts +329 -0
- package/src/providers/claude-cli-identity.ts +12 -0
- package/src/providers/command-code-efforts.ts +184 -102
- package/src/providers/derive.ts +11 -3
- package/src/providers/fastwire.ts +16 -3
- package/src/providers/label.ts +8 -3
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/quota/vendor-probes-oauth.ts +2 -1
- package/src/providers/reasoning-metadata.ts +38 -15
- package/src/providers/registry/entries-core.ts +95 -7
- package/src/providers/registry/entries-extended.ts +21 -10
- package/src/providers/registry/model-ids.ts +1 -0
- package/src/providers/registry/model-seeds.ts +31 -3
- package/src/providers/registry/types.ts +3 -1
- package/src/providers/resolved-model-policy-merge.ts +38 -8
- package/src/providers/resolved-model-policy.ts +4 -2
- package/src/providers/service-tier.ts +3 -1
- package/src/reasoning-effort.ts +6 -5
- package/src/responses/code-mode-helper-compat.ts +14 -4
- package/src/responses/code-mode-shell-input.ts +54 -0
- package/src/responses/custom-tool-compat.ts +173 -5
- package/src/responses/parser.ts +38 -2
- package/src/responses/reasoning-replay-cache.ts +26 -0
- package/src/router.ts +11 -1
- package/src/routing/history/indexer.ts +7 -2
- package/src/routing/history/schema.ts +3 -1
- package/src/server/adapter-resolve.ts +2 -2
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-completions.ts +14 -1
- package/src/server/chat-native.ts +17 -0
- package/src/server/claude-messages.ts +4 -3
- package/src/server/direct-local-http.ts +45 -19
- package/src/server/gui-session.ts +6 -15
- package/src/server/index/package-tree-guard.ts +53 -0
- package/src/server/index/serve-options.ts +17 -3
- package/src/server/index/startup-warnings.ts +17 -0
- package/src/server/index.ts +15 -17
- package/src/server/lifecycle.ts +8 -0
- package/src/server/live.ts +54 -4
- package/src/server/management/agent-settings-routes.ts +44 -8
- package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
- package/src/server/management/codex-prompt-routes.ts +5 -1
- package/src/server/management/config-routes.ts +14 -4
- package/src/server/management/oauth-account-routes.ts +17 -1
- package/src/server/management/provider-overwrite-carry.ts +164 -0
- package/src/server/management/provider-routes.ts +36 -6
- package/src/server/management/remote-workspace-routes.ts +2 -2
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/management/shadow-call-validation.ts +39 -0
- package/src/server/management/system-restart.ts +72 -6
- package/src/server/management-api.ts +16 -2
- package/src/server/management-auth.ts +52 -8
- package/src/server/proxy-liveness.ts +110 -4
- package/src/server/request-log.ts +26 -9
- package/src/server/request-metrics.ts +5 -0
- package/src/server/responses/adapter-continuation.ts +6 -3
- package/src/server/responses/adapter-dispatch.ts +65 -1
- package/src/server/responses/compact.ts +31 -1
- package/src/server/responses/compaction-routing.ts +28 -3
- package/src/server/responses/core-codex-account.ts +85 -26
- package/src/server/responses/core-combo-failure.ts +16 -15
- package/src/server/responses/core-normalize.ts +5 -1
- package/src/server/responses/core-opaque-recovery.ts +50 -2
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core-replay.ts +7 -0
- package/src/server/responses/core.ts +1 -1
- package/src/server/responses/encrypted-payload.ts +23 -9
- package/src/server/responses/passthrough-delivery.ts +55 -35
- package/src/server/responses/passthrough-dispatch.ts +12 -13
- package/src/server/responses/policy-fallback.ts +12 -1
- package/src/server/responses/request-prepare.ts +46 -7
- package/src/server/responses/request-spend.ts +31 -15
- package/src/server/responses/run-turn-execution.ts +6 -5
- package/src/server/responses/shadow-target-availability.ts +61 -0
- package/src/server/responses-custom-tool-repair.ts +2 -0
- package/src/service/claim.ts +185 -0
- package/src/service/cli.ts +10 -1
- package/src/service/guarded-manager-target.ts +151 -0
- package/src/service/guards.ts +47 -48
- package/src/service/managing-cli.ts +170 -0
- package/src/service/orchestration.ts +3 -1
- package/src/service/state.ts +4 -0
- package/src/service/systemd.ts +16 -1
- package/src/service.ts +1 -1
- package/src/types/config.ts +8 -0
- package/src/types/provider.ts +16 -0
- package/src/types/request.ts +10 -0
- package/src/types/tools.ts +9 -1
- package/src/types/wire.ts +68 -4
- package/src/types.ts +1 -0
- package/src/update/job.ts +10 -16
- package/src/update/npm-invocation.mjs +17 -16
- package/src/update/transactional-install.d.mts +22 -1
- package/src/update/transactional-install.mjs +176 -19
- package/src/update/update-failure-guidance.d.mts +8 -0
- package/src/update/update-failure-guidance.mjs +47 -0
- package/src/usage/expected-prices.ts +54 -12
- package/src/usage/log.ts +117 -3
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/usage/timeline.ts +34 -6
- package/src/usage/user-cost-overlay-reconciler.ts +3 -3
- package/src/vision/reasoning.ts +2 -4
- package/src/web-search/xai-executor.ts +14 -4
- package/gui/dist/assets/App-BqrsSrIR.js +0 -50
- package/gui/dist/assets/index-C6SJrh0N.js +0 -86
- package/gui/dist/assets/index-DdDunwDb.css +0 -1
- package/gui/dist/assets/usage-companion-chart-CzAAAB1o.js +0 -1
- package/src/adapters/kiro-thinking.ts +0 -112
|
@@ -44,10 +44,19 @@ export interface RaycastDetectDeps {
|
|
|
44
44
|
*/
|
|
45
45
|
const RAYCAST_DEFAULTS_DOMAIN = "com.raycast.macos.v1";
|
|
46
46
|
const RAYCAST_SUBSCRIPTION_KEY = "subscriptions_active";
|
|
47
|
+
const DEFAULTS_PATH = "/usr/bin/defaults";
|
|
48
|
+
const DEFAULTS_TIMEOUT_MS = 2_000;
|
|
47
49
|
|
|
48
|
-
|
|
50
|
+
interface RealRaycastDetectRuntime {
|
|
51
|
+
platform?: string;
|
|
52
|
+
spawnSync?: typeof Bun.spawnSync;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function realRaycastDetectDeps(runtime: RealRaycastDetectRuntime = {}): RaycastDetectDeps {
|
|
56
|
+
const platform = runtime.platform ?? process.platform;
|
|
57
|
+
const spawnSync = runtime.spawnSync ?? Bun.spawnSync;
|
|
49
58
|
return {
|
|
50
|
-
platform
|
|
59
|
+
platform,
|
|
51
60
|
homedir: homedir(),
|
|
52
61
|
env: process.env,
|
|
53
62
|
exists: path => {
|
|
@@ -59,9 +68,15 @@ export function realRaycastDetectDeps(): RaycastDetectDeps {
|
|
|
59
68
|
},
|
|
60
69
|
readDefault: (domain, key) => {
|
|
61
70
|
// `defaults` is macOS-only; elsewhere the plan is simply unknown.
|
|
62
|
-
if (
|
|
71
|
+
if (platform !== "darwin") return null;
|
|
63
72
|
try {
|
|
64
|
-
const result =
|
|
73
|
+
const result = spawnSync([DEFAULTS_PATH, "read", domain, key], {
|
|
74
|
+
stdout: "pipe",
|
|
75
|
+
stderr: "pipe",
|
|
76
|
+
timeout: DEFAULTS_TIMEOUT_MS,
|
|
77
|
+
});
|
|
78
|
+
// A timed-out or signal-killed probe reports exitCode === null; that
|
|
79
|
+
// and any non-zero exit mean the preference was not read.
|
|
65
80
|
if (result.exitCode !== 0) return null;
|
|
66
81
|
return result.stdout.toString().trim();
|
|
67
82
|
} catch {
|
|
@@ -177,7 +177,17 @@ export function quoteTomlKey(key: string): string {
|
|
|
177
177
|
function tomlScalar(value: unknown): string {
|
|
178
178
|
if (typeof value === "string") return tomlString(value);
|
|
179
179
|
if (typeof value === "boolean") return value ? "true" : "false";
|
|
180
|
-
if (typeof value === "number" && Number.isFinite(value))
|
|
180
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
181
|
+
// Bun.TOML.parse returns TOML integers as JavaScript numbers. Values outside
|
|
182
|
+
// the safe range may already have been rounded, so writing them back would
|
|
183
|
+
// silently alter a user-owned config rather than merely reformatting it.
|
|
184
|
+
if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
|
|
185
|
+
throw new UnserializableValueError(
|
|
186
|
+
"TOML cannot safely rewrite an integer outside JavaScript's safe range",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return String(value);
|
|
190
|
+
}
|
|
181
191
|
/*
|
|
182
192
|
* Arrays of ANY scalar, not just strings. The string-only check was written
|
|
183
193
|
* against our own builder output; a user's config legitimately holds
|
package/src/lib/bounded-body.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface BoundedBodyOptions {
|
|
|
15
15
|
* Reader cancellation and lock release still run. Defaults to false.
|
|
16
16
|
*/
|
|
17
17
|
fatalUtf8?: boolean;
|
|
18
|
+
/** Report UTF-8 validity without rejecting malformed bodies. */
|
|
19
|
+
reportUtf8Validity?: boolean;
|
|
18
20
|
/**
|
|
19
21
|
* Byte ceiling for retained body data. Defaults to BOUNDED_BODY_MAX_BYTES (64 KiB),
|
|
20
22
|
* which suits error bodies; callers materializing whole success payloads (e.g. a
|
|
@@ -44,6 +46,8 @@ export interface BoundedBodyResult {
|
|
|
44
46
|
oversized: boolean;
|
|
45
47
|
/** False means callers should use a status-only fallback, not `text`. */
|
|
46
48
|
displaySafe: boolean;
|
|
49
|
+
/** Present when reportUtf8Validity was requested and the retained body reached EOF. */
|
|
50
|
+
utf8Valid?: boolean;
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
export interface BoundedBytesOptions {
|
|
@@ -238,6 +242,14 @@ function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean, timedOut = fa
|
|
|
238
242
|
}
|
|
239
243
|
}
|
|
240
244
|
|
|
245
|
+
function decodeUtf8WithValidity(bytes: Uint8Array): { text: string; utf8Valid: boolean } {
|
|
246
|
+
try {
|
|
247
|
+
return { text: decodeUtf8([bytes], true), utf8Valid: true };
|
|
248
|
+
} catch {
|
|
249
|
+
return { text: decodeUtf8([bytes], false), utf8Valid: false };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
241
253
|
/**
|
|
242
254
|
* Consume the original response body under strict memory and time bounds.
|
|
243
255
|
*
|
|
@@ -326,6 +338,24 @@ export async function readBoundedResponseBody(
|
|
|
326
338
|
|
|
327
339
|
const { value, done } = outcome as ReadableStreamReadResult<Uint8Array>;
|
|
328
340
|
if (done) {
|
|
341
|
+
if (options.reportUtf8Validity) {
|
|
342
|
+
const bytes = retained.subarray(0, retainedBytes);
|
|
343
|
+
// A fatal decode that returned already proved the bytes valid; still
|
|
344
|
+
// honour the reporting contract instead of dropping utf8Valid.
|
|
345
|
+
const decoded = options.fatalUtf8 === true
|
|
346
|
+
? { text: decodeUtf8([bytes], true), utf8Valid: true }
|
|
347
|
+
: decodeUtf8WithValidity(bytes);
|
|
348
|
+
return {
|
|
349
|
+
text: decoded.text,
|
|
350
|
+
truncated: false,
|
|
351
|
+
timedOut: false,
|
|
352
|
+
totalTimedOut: false,
|
|
353
|
+
inactivityTimedOut: false,
|
|
354
|
+
oversized: false,
|
|
355
|
+
displaySafe: true,
|
|
356
|
+
utf8Valid: decoded.utf8Valid,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
329
359
|
return {
|
|
330
360
|
text: decodeUtf8([retained.subarray(0, retainedBytes)], options.fatalUtf8 === true),
|
|
331
361
|
truncated: false,
|
package/src/lib/crash-guard.ts
CHANGED
|
@@ -150,7 +150,9 @@ function safeStringify(value: unknown): string {
|
|
|
150
150
|
|
|
151
151
|
let benignSuppressed = 0;
|
|
152
152
|
let benignLastLoggedAt = 0;
|
|
153
|
+
let benignLastOrigin: string | undefined;
|
|
153
154
|
const BENIGN_LOG_INTERVAL_MS = 5 * 60_000;
|
|
155
|
+
const MAX_BENIGN_ORIGIN_BYTES = 1024;
|
|
154
156
|
|
|
155
157
|
/**
|
|
156
158
|
* Bun raises an off-path `unhandledRejection: TypeError: null is not an object` (native-only stack)
|
|
@@ -176,17 +178,53 @@ export function isBenignAbortTeardown(err: unknown): boolean {
|
|
|
176
178
|
const lockedStreamTeardown = err.message === "Invalid state: ReadableStream is locked"
|
|
177
179
|
&& (err as { code?: unknown }).code === "ERR_INVALID_STATE";
|
|
178
180
|
if (!bareNullTeardown && !lockedStreamTeardown) return false;
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
|
|
181
|
+
// Native-only: no JS source frame, parenthesized or not. Hidden JSC source fields
|
|
182
|
+
// (sourceURL/line/column) do not decide this: Bun can attach them to errors raised from
|
|
183
|
+
// its own builtin frames, and the benign summary still records them through diagnose().
|
|
184
|
+
return !hasJsSourceFrame(err.stack ?? "");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* True when a stack line is an `at …` frame ending in `line:col` (optionally inside
|
|
189
|
+
* parentheses) whose location is not a Bun builtin (`native:`). Covers `at fn (/abs/x.ts:1:2)`,
|
|
190
|
+
* `at /abs/x.ts:1:2`, `at async fn (file:///x.ts:1:2)` and Windows drive paths.
|
|
191
|
+
*/
|
|
192
|
+
function hasJsSourceFrame(stack: string): boolean {
|
|
193
|
+
return stack.split(/\r?\n/).some(raw => {
|
|
194
|
+
const frame = raw.trim();
|
|
195
|
+
return frame.startsWith("at ")
|
|
196
|
+
&& /:\d+:\d+\)?$/.test(frame)
|
|
197
|
+
&& !/[(\s]native:\d+:\d+\)?$/.test(frame);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The JSC hidden throw site (`sourceURL:line:col`), when the error carries one. Best-effort:
|
|
203
|
+
* this runs inside the process crash handler, so an accessor that throws yields no site.
|
|
204
|
+
*/
|
|
205
|
+
function hiddenThrowSite(err: unknown): string | undefined {
|
|
206
|
+
try {
|
|
207
|
+
if (!err || typeof err !== "object") return undefined;
|
|
208
|
+
const e = err as Record<string, unknown>;
|
|
209
|
+
if (typeof e.sourceURL !== "string" || !e.sourceURL) return undefined;
|
|
210
|
+
const site = `${e.sourceURL}:${String(e.line ?? e.originalLine ?? "")}:${String(e.column ?? e.originalColumn ?? "")}`;
|
|
211
|
+
return truncateRetainedUtf8(site, MAX_BENIGN_ORIGIN_BYTES);
|
|
212
|
+
} catch {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
182
215
|
}
|
|
183
216
|
|
|
184
217
|
function record(kind: string, err: unknown, promise?: unknown): void {
|
|
185
218
|
if (kind === "unhandledRejection" && isBenignAbortTeardown(err)) {
|
|
186
219
|
benignSuppressed++;
|
|
187
220
|
const now = Date.now();
|
|
188
|
-
|
|
221
|
+
// A throw site JSC recorded on hidden fields is new information when it differs from
|
|
222
|
+
// the last one logged, so it is written even inside the fold window; repeats still fold.
|
|
223
|
+
const origin = hiddenThrowSite(err);
|
|
224
|
+
const novelOrigin = origin !== undefined && origin !== benignLastOrigin;
|
|
225
|
+
if (!novelOrigin && now - benignLastLoggedAt < BENIGN_LOG_INTERVAL_MS) return; // fold repeats silently
|
|
189
226
|
benignLastLoggedAt = now;
|
|
227
|
+
if (origin !== undefined) benignLastOrigin = origin;
|
|
190
228
|
const summary = `\n[${new Date(now).toISOString()}] benign-abort-teardown x${benignSuppressed}`
|
|
191
229
|
+ ` (Bun fetch-body abort; proxy unaffected)${diagnose(err)}${diagnosePromise(promise)}${breadcrumb()}\n`;
|
|
192
230
|
benignSuppressed = 0;
|
|
@@ -306,6 +344,16 @@ export function resetCrashRingForTests(): void {
|
|
|
306
344
|
fetchRingBytes = 0;
|
|
307
345
|
}
|
|
308
346
|
|
|
347
|
+
export function recordCrashForTests(kind: string, err: unknown): void {
|
|
348
|
+
record(kind, err);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function resetBenignFoldForTests(): void {
|
|
352
|
+
benignSuppressed = 0;
|
|
353
|
+
benignLastLoggedAt = 0;
|
|
354
|
+
benignLastOrigin = undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
309
357
|
/** Render the recent fetch ring (pending first) for the crash breadcrumb. */
|
|
310
358
|
function recentFetches(): string {
|
|
311
359
|
try {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { isLocalAttestationSecret } from "./local-management-attestation";
|
|
3
|
+
|
|
4
|
+
export const LOCAL_ASIDE_SYNC_METHOD = "POST";
|
|
5
|
+
export const LOCAL_ASIDE_SYNC_PATH = "/api/client-integrations/aside/sync";
|
|
6
|
+
export const LOCAL_ASIDE_SYNC_CAPABILITY_VERSION = "v1";
|
|
7
|
+
export const LOCAL_ASIDE_SYNC_EXPECTED_PID_HEADER = "x-opencodex-aside-sync-expected-pid";
|
|
8
|
+
export const LOCAL_ASIDE_SYNC_NONCE_HEADER = "x-opencodex-aside-sync-nonce";
|
|
9
|
+
export const LOCAL_ASIDE_SYNC_EXPIRES_AT_HEADER = "x-opencodex-aside-sync-expires-at";
|
|
10
|
+
export const LOCAL_ASIDE_SYNC_CAPABILITY_HEADER = "x-opencodex-aside-sync-capability";
|
|
11
|
+
export const LOCAL_ASIDE_SYNC_CAPABILITY_TTL_MS = 10_000;
|
|
12
|
+
|
|
13
|
+
const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/;
|
|
14
|
+
|
|
15
|
+
export function parseExpectedLocalAsideSyncPid(value: string | null): number | null {
|
|
16
|
+
if (!value || !/^[1-9]\d*$/.test(value)) return null;
|
|
17
|
+
const pid = Number(value);
|
|
18
|
+
return Number.isSafeInteger(pid) ? pid : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function payload(nonce: string, method: string, path: string, pid: number, port: number, expiresAt: number): string | null {
|
|
22
|
+
if (!BASE64URL_256.test(nonce) || method !== LOCAL_ASIDE_SYNC_METHOD || path !== LOCAL_ASIDE_SYNC_PATH) return null;
|
|
23
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isInteger(port) || port <= 0 || port > 65535) return null;
|
|
24
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt <= 0) return null;
|
|
25
|
+
return `opencodex-local-aside-sync-v1\n${nonce}\n${method}\n${path}\n${pid}\n${port}\n${expiresAt}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createLocalAsideSyncCapability(secret: string, nonce: string, method: string, path: string, pid: number, port: number, expiresAt: number): string | null {
|
|
29
|
+
if (!isLocalAttestationSecret(secret)) return null;
|
|
30
|
+
const value = payload(nonce, method, path, pid, port, expiresAt);
|
|
31
|
+
return value ? createHmac("sha256", secret).update(value).digest("base64url") : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function verifyLocalAsideSyncCapability(secret: string, nonce: string | null, method: string, path: string, pid: number, port: number, expiresAt: number, capability: string | null, now = Date.now()): boolean {
|
|
35
|
+
if (!nonce || !capability || !BASE64URL_256.test(capability) || expiresAt <= now || expiresAt > now + LOCAL_ASIDE_SYNC_CAPABILITY_TTL_MS) return false;
|
|
36
|
+
const expected = createLocalAsideSyncCapability(secret, nonce, method, path, pid, port, expiresAt);
|
|
37
|
+
if (!expected) return false;
|
|
38
|
+
const expectedBytes = Buffer.from(expected);
|
|
39
|
+
const actualBytes = Buffer.from(capability);
|
|
40
|
+
return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes);
|
|
41
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { statSync } from "node:fs";
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { isStandaloneBinary } from "./standalone";
|
|
3
3
|
|
|
4
4
|
export interface PackageTreeObservation {
|
|
@@ -14,13 +14,66 @@ export type PackageTreeIntegrityStatus =
|
|
|
14
14
|
|
|
15
15
|
export interface PackageTreeIntegrityGuard {
|
|
16
16
|
status(): PackageTreeIntegrityStatus;
|
|
17
|
+
/**
|
|
18
|
+
* Version recorded in the package manifest that is on disk NOW, once the replacement has
|
|
19
|
+
* settled. A fenced proxy reports it so `ocx restart` compares the CLI with the files an
|
|
20
|
+
* in-place respawn would run, not with the version this process booted from.
|
|
21
|
+
*
|
|
22
|
+
* A readable manifest is not an install-completion signal: npm can write package.json while
|
|
23
|
+
* it is still extracting the rest of the tree. So this stays undefined until the guard's own
|
|
24
|
+
* stability debounce has seen the same replacement identity for the full interval, and again
|
|
25
|
+
* whenever the tree has moved since. Undefined also covers an unreadable or malformed manifest.
|
|
26
|
+
*/
|
|
27
|
+
installedVersion?(): string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Permanently disarms the guard: cancels any pending restart timer and
|
|
30
|
+
* invalidates queued callbacks. Called from `server.stop()` so a still-queued
|
|
31
|
+
* replacement callback cannot schedule a drain-and-restart after shutdown
|
|
32
|
+
* has already begun.
|
|
33
|
+
*/
|
|
34
|
+
dispose(): void;
|
|
17
35
|
}
|
|
18
36
|
|
|
19
|
-
|
|
20
|
-
|
|
37
|
+
export interface PackageTreeIntegrityOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Called once when a replaced package tree persists past `replacedRestartDelayMs`
|
|
40
|
+
* of sustained failure. The intended handler is the graceful drain-and-restart
|
|
41
|
+
* acceptor: an out-of-band install (npm/bun/pnpm global upgrade under a live
|
|
42
|
+
* proxy) then self-heals instead of serving 503s until someone restarts by hand.
|
|
43
|
+
* Only `package_tree_replaced` counts — an unreadable manifest resets the timer,
|
|
44
|
+
* so an install still mid-write does not trigger a restart on partial state.
|
|
45
|
+
*/
|
|
46
|
+
onReplaced?: () => void;
|
|
47
|
+
/** Sustained-replacement delay before `onReplaced` fires. 0 fires on first detection. */
|
|
48
|
+
replacedRestartDelayMs?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Test seam; production uses an unref'd timer. May return a cancellation
|
|
51
|
+
* function; when it does, `resetRestartTimer` cancels the pending callback
|
|
52
|
+
* instead of leaving it queued behind a generation check.
|
|
53
|
+
*/
|
|
54
|
+
schedule?: (callback: () => void, delayMs: number) => (() => void) | void;
|
|
55
|
+
/** Test seam for `installedVersion()`; production reads the package manifest. */
|
|
56
|
+
readInstalledVersion?: () => string | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type ObservePackageTree = () => PackageTreeObservation | null;
|
|
60
|
+
export type PackageTreeRuntimeInstall = "bun" | "npm" | "pnpm" | "source";
|
|
21
61
|
|
|
22
62
|
const packageManifestUrl = new URL("../../package.json", import.meta.url);
|
|
23
63
|
|
|
64
|
+
const INSTALLED_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
65
|
+
|
|
66
|
+
function readInstalledManifestVersion(): string | undefined {
|
|
67
|
+
try {
|
|
68
|
+
const version = (JSON.parse(readFileSync(packageManifestUrl, "utf8")) as { version?: unknown }).version;
|
|
69
|
+
return typeof version === "string" && version.length <= 64 && INSTALLED_VERSION_PATTERN.test(version)
|
|
70
|
+
? version
|
|
71
|
+
: undefined;
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
24
77
|
function observePackageManifest(): PackageTreeObservation | null {
|
|
25
78
|
try {
|
|
26
79
|
const stat = statSync(packageManifestUrl, { bigint: true });
|
|
@@ -70,17 +123,133 @@ const PACKAGE_TREE_RECHECK_MS = 1_000;
|
|
|
70
123
|
export function createPackageTreeIntegrityGuard(
|
|
71
124
|
observe: ObservePackageTree = observePackageManifest,
|
|
72
125
|
now: () => number = Date.now,
|
|
126
|
+
options: PackageTreeIntegrityOptions = {},
|
|
73
127
|
): PackageTreeIntegrityGuard {
|
|
74
128
|
const boot = observe();
|
|
75
129
|
let lastOkAt: number | null = null;
|
|
130
|
+
let notified = false;
|
|
131
|
+
let timerGeneration = 0;
|
|
132
|
+
let timerScheduled = false;
|
|
133
|
+
let cancelScheduled: (() => void) | null = null;
|
|
134
|
+
let waitingForReadableTree = false;
|
|
135
|
+
let replacementCandidate: PackageTreeObservation | null = null;
|
|
136
|
+
/** The replacement identity that survived a full stability interval (see installedVersion). */
|
|
137
|
+
let settledReplacement: PackageTreeObservation | null = null;
|
|
138
|
+
const restartDelayMs = options.replacedRestartDelayMs ?? 5_000;
|
|
139
|
+
const readInstalledVersion = options.readInstalledVersion ?? readInstalledManifestVersion;
|
|
140
|
+
const schedule = options.schedule ?? ((callback, delayMs) => {
|
|
141
|
+
const timer = setTimeout(callback, delayMs);
|
|
142
|
+
timer.unref?.();
|
|
143
|
+
return () => clearTimeout(timer);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const resetRestartTimer = (): void => {
|
|
147
|
+
timerGeneration += 1;
|
|
148
|
+
timerScheduled = false;
|
|
149
|
+
const cancel = cancelScheduled;
|
|
150
|
+
cancelScheduled = null;
|
|
151
|
+
cancel?.();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const armRestartTimer = (delayMs = restartDelayMs): void => {
|
|
155
|
+
if (!options.onReplaced || notified || timerScheduled) return;
|
|
156
|
+
timerScheduled = true;
|
|
157
|
+
const generation = timerGeneration;
|
|
158
|
+
const verifyAndNotify = () => {
|
|
159
|
+
if (generation !== timerGeneration || notified) return;
|
|
160
|
+
timerScheduled = false;
|
|
161
|
+
const current = observe();
|
|
162
|
+
if (boot === null || current === null) {
|
|
163
|
+
// A package manager may replace package.json before the rest of the tree.
|
|
164
|
+
// Wait for a readable tree, then require a fresh full debounce interval.
|
|
165
|
+
resetRestartTimer();
|
|
166
|
+
waitingForReadableTree = true;
|
|
167
|
+
armRestartTimer(PACKAGE_TREE_RECHECK_MS);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (sameObservation(boot, current)) {
|
|
171
|
+
resetRestartTimer();
|
|
172
|
+
waitingForReadableTree = false;
|
|
173
|
+
replacementCandidate = null;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (waitingForReadableTree) {
|
|
177
|
+
waitingForReadableTree = false;
|
|
178
|
+
replacementCandidate = current;
|
|
179
|
+
resetRestartTimer();
|
|
180
|
+
armRestartTimer();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (replacementCandidate === null || !sameObservation(replacementCandidate, current)) {
|
|
184
|
+
replacementCandidate = current;
|
|
185
|
+
resetRestartTimer();
|
|
186
|
+
armRestartTimer();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
settledReplacement = current;
|
|
190
|
+
try {
|
|
191
|
+
options.onReplaced?.();
|
|
192
|
+
notified = true;
|
|
193
|
+
} catch {
|
|
194
|
+
// A failed restart admission must not leave the proxy fenced forever.
|
|
195
|
+
// Re-observe after the normal debounce and try again if replacement persists.
|
|
196
|
+
armRestartTimer(Math.max(PACKAGE_TREE_RECHECK_MS, restartDelayMs));
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
if (delayMs === 0) {
|
|
200
|
+
// Defer like the scheduled path: verifyAndNotify can arm the next timer, and a
|
|
201
|
+
// synchronous verify inside this frame would re-enter armRestartTimer while this
|
|
202
|
+
// arm is still running.
|
|
203
|
+
queueMicrotask(verifyAndNotify);
|
|
204
|
+
} else {
|
|
205
|
+
// The seam may run the callback synchronously; defer the work so
|
|
206
|
+
// cancelScheduled ownership is settled before verifyAndNotify can
|
|
207
|
+
// re-enter armRestartTimer.
|
|
208
|
+
const cancel = schedule(() => {
|
|
209
|
+
queueMicrotask(verifyAndNotify);
|
|
210
|
+
}, delayMs);
|
|
211
|
+
if (generation === timerGeneration && timerScheduled && typeof cancel === "function") {
|
|
212
|
+
cancelScheduled = cancel;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
76
217
|
return {
|
|
218
|
+
installedVersion: () => {
|
|
219
|
+
if (settledReplacement === null) return undefined;
|
|
220
|
+
const current = observe();
|
|
221
|
+
if (current === null || !sameObservation(settledReplacement, current)) return undefined;
|
|
222
|
+
return readInstalledVersion();
|
|
223
|
+
},
|
|
224
|
+
dispose(): void {
|
|
225
|
+
resetRestartTimer();
|
|
226
|
+
notified = true;
|
|
227
|
+
},
|
|
77
228
|
status(): PackageTreeIntegrityStatus {
|
|
78
229
|
const at = now();
|
|
79
230
|
if (lastOkAt !== null && at - lastOkAt < PACKAGE_TREE_RECHECK_MS) return { ok: true };
|
|
80
231
|
const current = observe();
|
|
81
|
-
if (boot === null || current === null)
|
|
82
|
-
|
|
232
|
+
if (boot === null || current === null) {
|
|
233
|
+
const wasWatchingReplacement = timerScheduled;
|
|
234
|
+
resetRestartTimer();
|
|
235
|
+
if (wasWatchingReplacement && boot !== null) {
|
|
236
|
+
waitingForReadableTree = true;
|
|
237
|
+
armRestartTimer(PACKAGE_TREE_RECHECK_MS);
|
|
238
|
+
}
|
|
239
|
+
return { ok: false, reason: "package_tree_unreadable" };
|
|
240
|
+
}
|
|
241
|
+
if (!sameObservation(boot, current)) {
|
|
242
|
+
if (replacementCandidate === null || !sameObservation(replacementCandidate, current)) {
|
|
243
|
+
replacementCandidate = current;
|
|
244
|
+
resetRestartTimer();
|
|
245
|
+
}
|
|
246
|
+
armRestartTimer();
|
|
247
|
+
return { ok: false, reason: "package_tree_replaced" };
|
|
248
|
+
}
|
|
83
249
|
lastOkAt = at;
|
|
250
|
+
resetRestartTimer();
|
|
251
|
+
waitingForReadableTree = false;
|
|
252
|
+
replacementCandidate = null;
|
|
84
253
|
return { ok: true };
|
|
85
254
|
},
|
|
86
255
|
};
|
|
@@ -96,7 +265,10 @@ export function createRuntimePackageTreeIntegrityGuard(
|
|
|
96
265
|
installer: PackageTreeRuntimeInstall,
|
|
97
266
|
observe: ObservePackageTree = observePackageManifest,
|
|
98
267
|
now: () => number = Date.now,
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
268
|
+
options: PackageTreeIntegrityOptions = {},
|
|
269
|
+
): PackageTreeIntegrityGuard {
|
|
270
|
+
if (installer === "source" || isStandaloneBinary()) {
|
|
271
|
+
return { status: () => ({ ok: true }), dispose: () => {} };
|
|
272
|
+
}
|
|
273
|
+
return createPackageTreeIntegrityGuard(observe, now, options);
|
|
274
|
+
}
|
package/src/lib/proxy-env.ts
CHANGED
|
@@ -29,6 +29,7 @@ export function noProxyMatches(
|
|
|
29
29
|
if (!entry) continue;
|
|
30
30
|
if (entry === "*") return true;
|
|
31
31
|
entry = entry.replace(/^(?:https?|wss?):\/\//, "").split("/", 1)[0]!;
|
|
32
|
+
const domainForm = /^\*?\./.test(entry);
|
|
32
33
|
|
|
33
34
|
let entryHost = entry;
|
|
34
35
|
let entryPort = "";
|
|
@@ -46,11 +47,20 @@ export function noProxyMatches(
|
|
|
46
47
|
}
|
|
47
48
|
if (entryPort && entryPort !== port) continue;
|
|
48
49
|
entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, ""));
|
|
49
|
-
if (entryHost
|
|
50
|
+
if (!entryHost) continue;
|
|
51
|
+
if (hostname === entryHost) return true;
|
|
52
|
+
// A bare loopback name or an IP literal names one host: "localhost" must not send
|
|
53
|
+
// "anything.localhost" direct, which need not resolve to loopback. ".localhost" still does.
|
|
54
|
+
if (!domainForm && isExactOnlyNoProxyHost(entryHost)) continue;
|
|
55
|
+
if (hostname.endsWith(`.${entryHost}`)) return true;
|
|
50
56
|
}
|
|
51
57
|
return false;
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
function isExactOnlyNoProxyHost(host: string): boolean {
|
|
61
|
+
return host === "localhost" || host.includes(":") || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host);
|
|
62
|
+
}
|
|
63
|
+
|
|
54
64
|
export function resolveProxyRoute(
|
|
55
65
|
url: URL,
|
|
56
66
|
env: ProxyEnvMap = process.env,
|
|
@@ -211,6 +221,13 @@ export function configuredOutboundFetch(
|
|
|
211
221
|
} catch {
|
|
212
222
|
return base!(input, init);
|
|
213
223
|
}
|
|
224
|
+
// A mixed inherited SOCKS/HTTP ALL_PROXY environment cannot put bare "localhost" in
|
|
225
|
+
// NO_PROXY: Bun would also bypass its HTTP proxy for app.localhost. Keep the name exact
|
|
226
|
+
// here and force native fetch direct so the opposite-case HTTP proxy cannot take over.
|
|
227
|
+
if (proxy && explicitProxy === undefined && (url.protocol === "http:" || url.protocol === "https:")
|
|
228
|
+
&& normalizeProxyHostname(url.hostname) === "localhost") {
|
|
229
|
+
return base!(input, { ...init, proxy: false } as ProxyCapableRequestInit);
|
|
230
|
+
}
|
|
214
231
|
if (proxy && (url.protocol === "http:" || url.protocol === "https:") && (explicitProxy !== undefined || !noProxyMatches(url))) {
|
|
215
232
|
return socks5Fetch(input, init, proxy);
|
|
216
233
|
}
|
|
@@ -98,6 +98,7 @@ export function deriveRequestFailureStage(facts: RequestFailureFacts): RequestFa
|
|
|
98
98
|
const STATUS_CONFIRMED_RECOVERY_KINDS: Readonly<Partial<Record<AttemptRecoveryKind, number>>> = Object.freeze({
|
|
99
99
|
"opaque-blob-rejection": 400,
|
|
100
100
|
"reasoning-effort-downgrade": 400,
|
|
101
|
+
"anthropic-fast-downgrade": 400,
|
|
101
102
|
});
|
|
102
103
|
|
|
103
104
|
function refinedFourHundredCause(
|
|
@@ -229,6 +229,9 @@ const RECOVERY_KIND_CAUSE = {
|
|
|
229
229
|
"opaque-blob-rejection": "ciphertext-refusal",
|
|
230
230
|
"empty-completion": "empty-output",
|
|
231
231
|
"reasoning-effort-downgrade": "parameter-rejected",
|
|
232
|
+
// Anthropic refused `speed: "fast"` (no usage credits, org not enabled, model outside the
|
|
233
|
+
// lane); the same turn succeeds once the parameter is dropped.
|
|
234
|
+
"anthropic-fast-downgrade": "parameter-rejected",
|
|
232
235
|
} as const satisfies Record<AttemptRecoveryKind, RequestFailureCause>;
|
|
233
236
|
|
|
234
237
|
export function causeForRecoveryKind(kind: AttemptRecoveryKind): RequestFailureCause {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { closeSync, constants, existsSync, fchmodSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, readSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { getConfigDir } from "../config";
|
|
5
|
-
import { atomicWriteFile } from "../config/atomic-write";
|
|
5
|
+
import { atomicWriteFile, atomicWriteFileNoFollow } from "../config/atomic-write";
|
|
6
6
|
|
|
7
7
|
const MAX_SERVICE_API_TOKEN_BYTES = 4096;
|
|
8
8
|
|
|
@@ -49,6 +49,103 @@ export function readServiceApiTokenState(): ServiceApiTokenState {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Validate and tighten a reused service token without applying permissions to a
|
|
54
|
+
* pathname that may have been replaced since validation.
|
|
55
|
+
*
|
|
56
|
+
* The token is read off the opened descriptor — never off the path a second
|
|
57
|
+
* time — and once it validates, it is REPUBLISHED through the no-follow atomic
|
|
58
|
+
* writer rather than hardened in place. Windows ACL tooling is pathname-based,
|
|
59
|
+
* so an in-place harden there could still land on a substituted entry; the
|
|
60
|
+
* republish instead replaces whatever entry sits at the path with a freshly
|
|
61
|
+
* hardened owner-only file holding the same token. On return the path names
|
|
62
|
+
* that file, which is the contract `origin: "file"` reports. On POSIX the
|
|
63
|
+
* opened descriptor is also fchmod'd first, so a token-bearing inode a race
|
|
64
|
+
* moved aside is still tightened wherever its entry ended up.
|
|
65
|
+
*
|
|
66
|
+
*
|
|
67
|
+
* Return contract vs `readServiceApiTokenState`: an empty or malformed token file
|
|
68
|
+
* reports `unsafe` here and is never written — the path-based pre-check may still
|
|
69
|
+
* pass the install on loopback while this writer deliberately leaves the file
|
|
70
|
+
* untouched. Only `absent` permits a fresh write; anything unreadable stays as-is.
|
|
71
|
+
*
|
|
72
|
+
* Callers must run this under `withConfigMutationLockSync`: client-key rotation
|
|
73
|
+
* replaces the token under that lock, and a republish outside it could rename a
|
|
74
|
+
* stale token back over a committed rotation.
|
|
75
|
+
*/
|
|
76
|
+
export function hardenReusedServiceApiToken(
|
|
77
|
+
validate: (token: string) => void,
|
|
78
|
+
): ServiceApiTokenState {
|
|
79
|
+
const path = serviceApiTokenFilePath();
|
|
80
|
+
// O_NOFOLLOW refuses a symlinked entry and O_NONBLOCK keeps a FIFO (or other
|
|
81
|
+
// blocking node) from stalling the open before fstat can reject it. Windows
|
|
82
|
+
// omits both flags, so there the descriptor is bound to its entry by the
|
|
83
|
+
// lstat/fstat identity comparison below.
|
|
84
|
+
const flags = process.platform === "win32"
|
|
85
|
+
? constants.O_RDONLY
|
|
86
|
+
: constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
87
|
+
let fd: number | undefined;
|
|
88
|
+
try {
|
|
89
|
+
fd = openSync(path, flags);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
92
|
+
if (code === "ENOENT") return { kind: "absent" };
|
|
93
|
+
if (code === "ELOOP") return { kind: "unsafe", reason: "service token path is not a bounded regular file" };
|
|
94
|
+
return { kind: "unsafe", reason: "service token path could not be inspected" };
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const stat = fstatSync(fd, { bigint: true });
|
|
98
|
+
if (!stat.isFile() || stat.size > BigInt(MAX_SERVICE_API_TOKEN_BYTES)) {
|
|
99
|
+
return { kind: "unsafe", reason: "service token path is not a bounded regular file" };
|
|
100
|
+
}
|
|
101
|
+
if (process.platform === "win32") {
|
|
102
|
+
let entry;
|
|
103
|
+
try {
|
|
104
|
+
entry = lstatSync(path, { bigint: true });
|
|
105
|
+
} catch {
|
|
106
|
+
return { kind: "unsafe", reason: "service token path could not be inspected" };
|
|
107
|
+
}
|
|
108
|
+
if (entry.isSymbolicLink() || !entry.isFile() || entry.dev !== stat.dev || entry.ino !== stat.ino) {
|
|
109
|
+
return { kind: "unsafe", reason: "service token path is not a bounded regular file" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Bound the read as well as the stat: a file that grows past the cap after
|
|
113
|
+
// fstat is unsafe, not something to buffer whole.
|
|
114
|
+
const bytes = Buffer.alloc(MAX_SERVICE_API_TOKEN_BYTES + 1);
|
|
115
|
+
let length = 0;
|
|
116
|
+
try {
|
|
117
|
+
while (length < bytes.length) {
|
|
118
|
+
const count = readSync(fd, bytes, length, bytes.length - length, null);
|
|
119
|
+
if (!count) break;
|
|
120
|
+
length += count;
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
return { kind: "unsafe", reason: "service token file could not be read" };
|
|
124
|
+
}
|
|
125
|
+
if (length > MAX_SERVICE_API_TOKEN_BYTES) {
|
|
126
|
+
return { kind: "unsafe", reason: "service token path is not a bounded regular file" };
|
|
127
|
+
}
|
|
128
|
+
const token = bytes.subarray(0, length).toString("utf8").trim();
|
|
129
|
+
if (!token) return { kind: "unsafe", reason: "service token file is empty" };
|
|
130
|
+
validate(token);
|
|
131
|
+
if (process.platform !== "win32") {
|
|
132
|
+
// Best-effort matches the previous repair behavior: the descriptor binds
|
|
133
|
+
// the chmod to the regular file opened above even if its directory entry
|
|
134
|
+
// moved, so the validated inode is never left loose under another name.
|
|
135
|
+
try { fchmodSync(fd, 0o600); } catch { /* best-effort */ }
|
|
136
|
+
}
|
|
137
|
+
// The descriptor's work ends here — and must: Windows refuses to rename over
|
|
138
|
+
// a file this process still holds open, so the republish cannot run while it
|
|
139
|
+
// is held.
|
|
140
|
+
closeSync(fd);
|
|
141
|
+
fd = undefined;
|
|
142
|
+
atomicWriteFileNoFollow(path, `${token}\n`);
|
|
143
|
+
return { kind: "present", token, fingerprint: serviceApiTokenFingerprint(token) };
|
|
144
|
+
} finally {
|
|
145
|
+
if (fd !== undefined) closeSync(fd);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
52
149
|
export function writeServiceApiTokenFile(token: string): PersistedServiceApiToken {
|
|
53
150
|
const value = token.trim();
|
|
54
151
|
if (!value || /[\r\n\0]/.test(value) || Buffer.byteLength(value) > MAX_SERVICE_API_TOKEN_BYTES) {
|