@bitkyc08/opencodex 2.63.0-preview.20260923 → 2.64.0-preview.20260923
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-D5eN1TID.js +50 -0
- package/gui/dist/assets/App-I5AnaSLh.css +1 -0
- package/gui/dist/assets/{Tray-B03pW-Uf.js → Tray-Bh_ErQDh.js} +1 -1
- package/gui/dist/assets/index-BEq4OCOz.js +86 -0
- package/gui/dist/assets/index-DiBRuK-d.css +1 -0
- package/gui/dist/assets/usage-companion-chart-DBG_37kQ.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-EJxiUFMq.js +0 -50
- package/gui/dist/assets/index-BmJwNBHL.js +0 -86
- package/gui/dist/assets/index-DdDunwDb.css +0 -1
- package/gui/dist/assets/usage-companion-chart-IftE60UK.js +0 -1
- package/src/adapters/kiro-thinking.ts +0 -112
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
REPLAY_REFUSAL_CLIENT_HEADERS,
|
|
33
33
|
REPLAY_REFUSED_STATUS,
|
|
34
34
|
retainReplayRefusal,
|
|
35
|
+
UpstreamRetryEvidenceError,
|
|
35
36
|
type UpstreamSendRecovery,
|
|
36
37
|
UPSTREAM_RESET_REPLAY_REFUSED_CODE,
|
|
37
38
|
} from "../lib/upstream-retry";
|
|
@@ -48,6 +49,7 @@ import {
|
|
|
48
49
|
transientRetryPolicyFor,
|
|
49
50
|
} from "../providers/key-failover";
|
|
50
51
|
import { fastPolicyForModel } from "../providers/service-tier";
|
|
52
|
+
import { stampApiKeyAccountLabel } from "../providers/label";
|
|
51
53
|
import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../providers/api-key-selection";
|
|
52
54
|
import { enrichOpenCodeZenFreeTierMessage } from "../providers/opencode-zen-rate-limit";
|
|
53
55
|
import type { OcxProviderTransport } from "../providers/xai-transport";
|
|
@@ -68,12 +70,16 @@ import {
|
|
|
68
70
|
} from "./request-log";
|
|
69
71
|
import { jsonCompletionSse, nativeChatSse, structuredError, usageFromChat } from "./chat-native-sse";
|
|
70
72
|
import { registerTurn, unregisterTurn } from "./lifecycle";
|
|
73
|
+
import { attachRequestSpendTracker } from "./responses/request-spend";
|
|
74
|
+
import { workflowRefusalResponse } from "./workflow-refusal";
|
|
71
75
|
|
|
72
76
|
type Rec = Record<string, unknown>;
|
|
73
77
|
|
|
74
78
|
const MAX_NATIVE_CHAT_JSON_BYTES = 32 * 1024 * 1024;
|
|
75
79
|
const MAX_NATIVE_CHAT_ERROR_BYTES = 64 * 1024;
|
|
76
80
|
|
|
81
|
+
class NativeChatSpendRefusal extends Error {}
|
|
82
|
+
|
|
77
83
|
const chatEffortSnapshots = new WeakMap<Rec, {
|
|
78
84
|
inputModel: string;
|
|
79
85
|
providerName: string;
|
|
@@ -262,6 +268,8 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
262
268
|
const proactiveKeyProvider = selectProactiveApiKeyTransport(config, route.providerName, route.provider);
|
|
263
269
|
if (proactiveKeyProvider) route.provider = proactiveKeyProvider;
|
|
264
270
|
let activeProvider: OcxProviderConfig = route.provider;
|
|
271
|
+
stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
|
|
272
|
+
const spendTracker = attachRequestSpendTracker(req, logCtx);
|
|
265
273
|
let activeAdapter: ProviderAdapter = createOpenAIChatAdapter(activeProvider);
|
|
266
274
|
let activeRequest: AdapterRequest;
|
|
267
275
|
let retainedRequestBytes = 0;
|
|
@@ -339,6 +347,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
339
347
|
throw new Error("Provider key selection is no longer available for native Chat");
|
|
340
348
|
}
|
|
341
349
|
activeProvider = current;
|
|
350
|
+
stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
|
|
342
351
|
activeAdapter = createOpenAIChatAdapter(current);
|
|
343
352
|
activeRequest.releaseBodyObservation?.();
|
|
344
353
|
releaseRetainedRequest();
|
|
@@ -353,6 +362,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
353
362
|
const encoding = new Headers(init.headers).get("accept-encoding");
|
|
354
363
|
if (!headers.has("accept-encoding") && encoding) headers.set("accept-encoding", encoding);
|
|
355
364
|
if (init.signal?.aborted) throw init.signal.reason;
|
|
365
|
+
if (!spendTracker.charge()) throw new NativeChatSpendRefusal();
|
|
356
366
|
noteProviderAttemptSend(logCtx, route.providerName, activeProvider, logCtx.usageLogInputTokens, transportRecovery ?? recovery);
|
|
357
367
|
// A reselected provider transport is still a physical send: the connection policy
|
|
358
368
|
// and manual-redirect ownership wrap the selected implementation (#4992).
|
|
@@ -432,6 +442,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
432
442
|
if (!transientSendAvailable()) break;
|
|
433
443
|
try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
434
444
|
activeProvider = rotated;
|
|
445
|
+
stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
|
|
435
446
|
activeAdapter = createOpenAIChatAdapter(activeProvider);
|
|
436
447
|
releaseRetainedRequest();
|
|
437
448
|
activeRequest = buildActiveRequest();
|
|
@@ -443,6 +454,12 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
443
454
|
cleanupAbort();
|
|
444
455
|
upstream.abort();
|
|
445
456
|
if (req.signal.aborted) return fail(499, "Client cancelled request", "client_cancelled");
|
|
457
|
+
const sendError = error instanceof UpstreamRetryEvidenceError ? error.cause : error;
|
|
458
|
+
if (sendError instanceof NativeChatSpendRefusal) {
|
|
459
|
+
const refusal = workflowRefusalResponse("workflow-spend-exhausted", logCtx);
|
|
460
|
+
finishLog(429);
|
|
461
|
+
return refusal;
|
|
462
|
+
}
|
|
446
463
|
if (isTranslatorBudgetExceededError(error)) {
|
|
447
464
|
return fail(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit");
|
|
448
465
|
}
|
|
@@ -740,9 +740,10 @@ async function handleClaudeMessagesWithBudget(
|
|
|
740
740
|
);
|
|
741
741
|
if (claudeConversationId) logCtx.conversationId = claudeConversationId;
|
|
742
742
|
}
|
|
743
|
-
// A fast row blocks passthrough, unlike the chat case:
|
|
744
|
-
//
|
|
745
|
-
//
|
|
743
|
+
// A fast row blocks passthrough, unlike the chat case: native passthrough forwards the
|
|
744
|
+
// caller's body with the caller's credential and never runs the Anthropic adapter, so the
|
|
745
|
+
// proxy-owned `speed` + beta (anthropic-speed wire) and its usage.speed observation would be
|
|
746
|
+
// silently skipped. Translation reaches the adapter, which owns both.
|
|
746
747
|
if (!effortRow && !fastRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model)) {
|
|
747
748
|
return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages");
|
|
748
749
|
}
|
|
@@ -6,6 +6,7 @@ const DIRECT_LOCAL_HTTP_TIMEOUT_MS = 10_000;
|
|
|
6
6
|
type DirectLocalHttpIo = {
|
|
7
7
|
timeoutMs?: number;
|
|
8
8
|
connect?: (hostname: string, port: number) => Socket;
|
|
9
|
+
scheduleDeadline?: (onTimeout: () => void, timeoutMs: number) => () => void;
|
|
9
10
|
};
|
|
10
11
|
|
|
11
12
|
function abortReason(signal: AbortSignal): Error {
|
|
@@ -270,18 +271,22 @@ export async function directLocalHttpFetch(
|
|
|
270
271
|
return await new Promise<Response>((resolve, reject) => {
|
|
271
272
|
let socket: Socket | undefined;
|
|
272
273
|
let settled = false;
|
|
274
|
+
let closed = false;
|
|
275
|
+
let completed = false;
|
|
276
|
+
let outcomeError: Error | undefined;
|
|
277
|
+
let cancelDeadline: (() => void) | undefined;
|
|
273
278
|
let receivedBytes = 0;
|
|
274
279
|
let responseBytes = Buffer.allocUnsafe(4 * 1024);
|
|
275
280
|
let framing: ResponseFraming = { kind: "head", searchFrom: 0 };
|
|
276
|
-
const
|
|
281
|
+
const disableSocketTimeout = () => { socket?.setTimeout(0); };
|
|
282
|
+
const settleAfterClose = (deadlineError?: Error) => {
|
|
283
|
+
if (!settled || (!closed && !deadlineError) || completed) return;
|
|
284
|
+
completed = true;
|
|
285
|
+
// Framing may finish before close, so the caller's deadline owns teardown too.
|
|
277
286
|
signal?.removeEventListener("abort", onAbort);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (settled) return;
|
|
282
|
-
settled = true;
|
|
283
|
-
cleanup();
|
|
284
|
-
try { socket?.destroy(); } catch { /* ignore */ }
|
|
287
|
+
cancelDeadline?.();
|
|
288
|
+
cancelDeadline = undefined;
|
|
289
|
+
const error = outcomeError ?? deadlineError;
|
|
285
290
|
if (error) {
|
|
286
291
|
reject(error);
|
|
287
292
|
return;
|
|
@@ -292,9 +297,28 @@ export async function directLocalHttpFetch(
|
|
|
292
297
|
reject(parseError instanceof Error ? parseError : new Error(String(parseError)));
|
|
293
298
|
}
|
|
294
299
|
};
|
|
300
|
+
const finish = (error?: Error) => {
|
|
301
|
+
if (settled) return;
|
|
302
|
+
settled = true;
|
|
303
|
+
outcomeError = error;
|
|
304
|
+
disableSocketTimeout();
|
|
305
|
+
try { socket?.destroy(); } catch (destroyError) {
|
|
306
|
+
settleAfterClose(error ?? (destroyError instanceof Error ? destroyError : new Error(String(destroyError))));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
settleAfterClose();
|
|
310
|
+
};
|
|
295
311
|
const onAbort = () => {
|
|
296
312
|
const error = signal ? abortReason(signal) : new Error("direct local HTTP request aborted");
|
|
313
|
+
if (settled) outcomeError ??= error;
|
|
314
|
+
else finish(error);
|
|
315
|
+
if (error.name === "TimeoutError") settleAfterClose(error);
|
|
316
|
+
};
|
|
317
|
+
const onDeadline = () => {
|
|
318
|
+
const error = new Error("direct local HTTP request timed out");
|
|
319
|
+
error.name = "TimeoutError";
|
|
297
320
|
finish(error);
|
|
321
|
+
settleAfterClose(error);
|
|
298
322
|
};
|
|
299
323
|
|
|
300
324
|
socket = (io.connect ?? ((host, selectedPort) => net.createConnection({
|
|
@@ -302,17 +326,9 @@ export async function directLocalHttpFetch(
|
|
|
302
326
|
port: selectedPort,
|
|
303
327
|
autoSelectFamily: true,
|
|
304
328
|
})))(hostname, port);
|
|
305
|
-
socket.setTimeout(timeoutMs,
|
|
306
|
-
const error = new Error("direct local HTTP request timed out");
|
|
307
|
-
error.name = "TimeoutError";
|
|
308
|
-
finish(error);
|
|
309
|
-
});
|
|
310
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
311
|
-
if (signal?.aborted) {
|
|
312
|
-
onAbort();
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
329
|
+
socket.setTimeout(timeoutMs, onDeadline);
|
|
315
330
|
socket.on("connect", () => {
|
|
331
|
+
if (settled) return;
|
|
316
332
|
try { socket?.write(requestBytes); } catch (error) {
|
|
317
333
|
finish(error instanceof Error ? error : new Error(String(error)));
|
|
318
334
|
}
|
|
@@ -342,6 +358,16 @@ export async function directLocalHttpFetch(
|
|
|
342
358
|
});
|
|
343
359
|
socket.once("end", () => finish());
|
|
344
360
|
socket.once("error", error => finish(error));
|
|
345
|
-
socket.once("close", () =>
|
|
361
|
+
socket.once("close", () => {
|
|
362
|
+
closed = true;
|
|
363
|
+
finish();
|
|
364
|
+
settleAfterClose();
|
|
365
|
+
});
|
|
366
|
+
cancelDeadline = (io.scheduleDeadline ?? ((callback, delayMs) => {
|
|
367
|
+
const timer = setTimeout(callback, delayMs);
|
|
368
|
+
return () => clearTimeout(timer);
|
|
369
|
+
}))(onDeadline, timeoutMs);
|
|
370
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
371
|
+
if (signal?.aborted) onAbort();
|
|
346
372
|
});
|
|
347
373
|
}
|
|
@@ -239,10 +239,8 @@ function findPairingGrant(
|
|
|
239
239
|
state: GuiSessionState,
|
|
240
240
|
): [string, GuiPairingGrantRecord] | null {
|
|
241
241
|
const digest = pairingGrantDigest(grant);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
return null;
|
|
242
|
+
const record = state.pairingGrants.get(digest);
|
|
243
|
+
return record ? [digest, record] : null;
|
|
246
244
|
}
|
|
247
245
|
|
|
248
246
|
function consumeGrantRateSlot(state: GuiSessionState, now: number): void {
|
|
@@ -341,19 +339,12 @@ export function consumeGuiPairingGrant(
|
|
|
341
339
|
tailscaleUser: null,
|
|
342
340
|
browserOrigin,
|
|
343
341
|
};
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
: undefined;
|
|
347
|
-
if (sourceRecord && sourceRecord.windowStartedAt + PAIRING_SOURCE_WINDOW_MS > now
|
|
348
|
-
&& sourceRecord.failures >= PAIRING_SOURCE_FAILURE_LIMIT) {
|
|
349
|
-
return {
|
|
350
|
-
allowed: false,
|
|
351
|
-
retryAfterSeconds: Math.max(1, Math.ceil((sourceRecord.windowStartedAt + PAIRING_SOURCE_WINDOW_MS - now) / 1000)),
|
|
352
|
-
reason: "source",
|
|
353
|
-
};
|
|
354
|
-
}
|
|
342
|
+
// Source throttling is only a cost bound for invalid guesses. Look up the grant
|
|
343
|
+
// first so callers sharing a proxy address cannot lock out a valid redemption.
|
|
355
344
|
const found = findPairingGrant(grant, state);
|
|
356
345
|
if (!found) {
|
|
346
|
+
// Cross-origin browser requests must not create limiter state as a side effect.
|
|
347
|
+
if (!isRemoteGuiBrowserOriginAllowed(browserOrigin, config)) return null;
|
|
357
348
|
const source = recordSourceFailure(state, context, now);
|
|
358
349
|
return attemptContext && !source.allowed ? source : null;
|
|
359
350
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRuntimePackageTreeIntegrityGuard,
|
|
3
|
+
type PackageTreeIntegrityGuard,
|
|
4
|
+
} from "../../lib/package-tree-integrity";
|
|
5
|
+
import { acceptSystemRestart } from "../management/system-restart";
|
|
6
|
+
import { inspectNativeCodexOwnership } from "../../integrations/native/ownership-preflight";
|
|
7
|
+
import { detectInstall } from "../../update/index";
|
|
8
|
+
import type { StartServerDeps } from "./startup-warnings";
|
|
9
|
+
|
|
10
|
+
// Production guard wiring for the package-tree integrity fence. Tests inject
|
|
11
|
+
// deps.packageTreeIntegrity and never reach this path; everything else is the
|
|
12
|
+
// same default the inline construction used to build.
|
|
13
|
+
export function createPackageTreeIntegrityGuardForServer(
|
|
14
|
+
deps: StartServerDeps,
|
|
15
|
+
serviceHomeOwned: () => boolean = deps.packageTreeServiceHomeOwned
|
|
16
|
+
?? (() => inspectNativeCodexOwnership().ownership === "owned"),
|
|
17
|
+
isServiceChild: () => boolean = deps.packageTreeServiceChild ?? (() => process.env.OCX_SERVICE === "1"),
|
|
18
|
+
): PackageTreeIntegrityGuard {
|
|
19
|
+
if (deps.packageTreeIntegrity) {
|
|
20
|
+
return deps.packageTreeIntegrity;
|
|
21
|
+
}
|
|
22
|
+
const acceptPackageTreeRestart = deps.acceptSystemRestart ?? acceptSystemRestart;
|
|
23
|
+
let vetoAcceptedRestart: (() => void) | undefined;
|
|
24
|
+
const guard = createRuntimePackageTreeIntegrityGuard(
|
|
25
|
+
deps.packageTreeInstaller ?? detectInstall(),
|
|
26
|
+
deps.observePackageTree,
|
|
27
|
+
undefined,
|
|
28
|
+
{
|
|
29
|
+
...deps.packageTreeIntegrityOptions,
|
|
30
|
+
onReplaced: () => {
|
|
31
|
+
// An out-of-band install replaced the package under this live process. Serve
|
|
32
|
+
// the 503 for the triggering request, then let the standard drain-and-restart
|
|
33
|
+
// path bring the new tree up instead of refusing traffic until a manual
|
|
34
|
+
// restart. acceptSystemRestart is idempotent and supervisor-aware.
|
|
35
|
+
const beforeScheduledDrain = () => !isServiceChild() || serviceHomeOwned();
|
|
36
|
+
if (!beforeScheduledDrain()) throw new Error("service home ownership changed");
|
|
37
|
+
acceptPackageTreeRestart(undefined, {
|
|
38
|
+
onAccepted: veto => { vetoAcceptedRestart = veto; },
|
|
39
|
+
beforeScheduledDrain,
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
return {
|
|
45
|
+
status: () => guard.status(),
|
|
46
|
+
installedVersion: () => guard.installedVersion?.(),
|
|
47
|
+
dispose: () => {
|
|
48
|
+
guard.dispose();
|
|
49
|
+
vetoAcceptedRestart?.();
|
|
50
|
+
vetoAcceptedRestart = undefined;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -168,6 +168,7 @@ import {
|
|
|
168
168
|
} from "../../lib/local-management-attestation";
|
|
169
169
|
import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../../lib/system-restart-contract";
|
|
170
170
|
import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../../lib/local-provider-reload-contract";
|
|
171
|
+
import { LOCAL_ASIDE_SYNC_CAPABILITY_VERSION } from "../../lib/local-aside-sync-contract";
|
|
171
172
|
import {
|
|
172
173
|
GUI_PAIR_BROWSER_ORIGIN_HEADER,
|
|
173
174
|
GUI_PAIR_CAPABILITY_VERSION,
|
|
@@ -350,20 +351,32 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
350
351
|
|| readyzPath !== undefined
|
|
351
352
|
|| url.pathname.startsWith("/v1/")
|
|
352
353
|
)) {
|
|
353
|
-
const message = "OpenCodex package files changed while this proxy was running; restart
|
|
354
|
-
const
|
|
354
|
+
const message = "OpenCodex package files changed while this proxy was running; it restarts on its own, or run 'ocx restart' ('ocx service restart' for a background service).";
|
|
355
|
+
const fencedPort = ctx.boundPort ?? requestServer.port ?? listenPort;
|
|
356
|
+
const fencedHealth = url.pathname === "/healthz";
|
|
357
|
+
const response = fencedHealth || readyzPath !== undefined
|
|
355
358
|
? jsonResponse({
|
|
356
359
|
status: "restart_required",
|
|
357
360
|
service: "opencodex",
|
|
358
361
|
version: VERSION,
|
|
359
362
|
uptime: process.uptime(),
|
|
360
363
|
pid: process.pid,
|
|
361
|
-
port:
|
|
364
|
+
port: fencedPort,
|
|
365
|
+
// Identity stays attestable while readiness is fenced (#5496): the CLI can only
|
|
366
|
+
// restart or stop what it can prove it owns, and an unverified 503 body is not proof.
|
|
367
|
+
// installedVersion is what an in-place respawn will run from the replaced tree.
|
|
368
|
+
...(fencedHealth ? {
|
|
369
|
+
restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION,
|
|
370
|
+
installedVersion: packageTreeIntegrity.installedVersion?.(),
|
|
371
|
+
} : {}),
|
|
362
372
|
error: { code: "package_tree_changed", message },
|
|
363
373
|
}, 503, req, policy)
|
|
364
374
|
: packageTreeChangedResponse(req, policy, message);
|
|
365
375
|
const headers = new Headers(response.headers);
|
|
366
376
|
headers.set("Retry-After", "5");
|
|
377
|
+
const challenge = fencedHealth ? req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER) : null;
|
|
378
|
+
const proof = challenge ? createLocalAttestationProof(localAttestationSecret, challenge, process.pid, fencedPort) : null;
|
|
379
|
+
if (proof) headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof);
|
|
367
380
|
return new Response(response.body, { status: 503, headers });
|
|
368
381
|
}
|
|
369
382
|
|
|
@@ -565,6 +578,7 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
565
578
|
port: healthPort,
|
|
566
579
|
restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION,
|
|
567
580
|
providerReloadCapability: LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION,
|
|
581
|
+
asideSyncCapability: LOCAL_ASIDE_SYNC_CAPABILITY_VERSION,
|
|
568
582
|
guiPairCapability: GUI_PAIR_CAPABILITY_VERSION,
|
|
569
583
|
}, 200, req, policy);
|
|
570
584
|
const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER);
|
|
@@ -6,6 +6,11 @@ import {
|
|
|
6
6
|
type OwnershipInspection,
|
|
7
7
|
} from "../../integrations/native/ownership-preflight";
|
|
8
8
|
import { registerCodexQuotaAutoRefreshWorker } from "../../codex/quota-auto-refresh";
|
|
9
|
+
import type {
|
|
10
|
+
ObservePackageTree,
|
|
11
|
+
PackageTreeIntegrityOptions,
|
|
12
|
+
PackageTreeRuntimeInstall,
|
|
13
|
+
} from "../../lib/package-tree-integrity";
|
|
9
14
|
import {
|
|
10
15
|
consumeForInspection,
|
|
11
16
|
relaySseWithHeartbeat,
|
|
@@ -141,6 +146,18 @@ export interface StartServerDeps {
|
|
|
141
146
|
readinessGate?: ReadinessGate;
|
|
142
147
|
/** Test-only package-tree observation; production captures package.json identity at boot. */
|
|
143
148
|
packageTreeIntegrity?: PackageTreeIntegrityGuard;
|
|
149
|
+
/** Test-only default-guard options; production observes the installed package manifest. */
|
|
150
|
+
packageTreeIntegrityOptions?: PackageTreeIntegrityOptions;
|
|
151
|
+
/** Test-only installed-package identity; production detects the current install. */
|
|
152
|
+
packageTreeInstaller?: PackageTreeRuntimeInstall;
|
|
153
|
+
/** Test-only manifest observer; production stats the installed package.json. */
|
|
154
|
+
observePackageTree?: ObservePackageTree;
|
|
155
|
+
/** Test-only restart acceptor; production uses the normal drain-and-restart path. */
|
|
156
|
+
acceptSystemRestart?: typeof import("../management/system-restart").acceptSystemRestart;
|
|
157
|
+
/** Test-only: whether this process is a service child, for the package-tree restart. */
|
|
158
|
+
packageTreeServiceChild?: () => boolean;
|
|
159
|
+
/** Test-only: whether this service child still owns its service home. */
|
|
160
|
+
packageTreeServiceHomeOwned?: () => boolean;
|
|
144
161
|
/** Test-only seam for observing quota-worker registration ownership. */
|
|
145
162
|
registerCodexQuotaAutoRefreshWorker?: typeof registerCodexQuotaAutoRefreshWorker;
|
|
146
163
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -78,6 +78,7 @@ export {
|
|
|
78
78
|
import { resolveGuiFilePath, rootFallbackPayload, serveGuiFile, serveSessionBootstrap } from "./gui-static";
|
|
79
79
|
export { resolveGuiFilePath, rootFallbackPayload } from "./gui-static";
|
|
80
80
|
export { resolveAdapter } from "./adapter-resolve";
|
|
81
|
+
export { noteExplicitShutdownRequested } from "./management/system-restart";
|
|
81
82
|
import { formatErrorResponse, type ResponsesTerminalStatus } from "../bridge";
|
|
82
83
|
import {
|
|
83
84
|
drainAndShutdown,
|
|
@@ -193,13 +194,9 @@ import {
|
|
|
193
194
|
createLocalAttestationSecret,
|
|
194
195
|
} from "../lib/local-management-attestation";
|
|
195
196
|
import { createReadinessGate, type ReadinessGate } from "./readiness";
|
|
196
|
-
import {
|
|
197
|
-
createRuntimePackageTreeIntegrityGuard,
|
|
198
|
-
type PackageTreeIntegrityGuard,
|
|
199
|
-
} from "../lib/package-tree-integrity";
|
|
200
|
-
import { detectInstall } from "../update/index";
|
|
201
197
|
import { createServeOptions, type ServerIngress } from "./index/serve-options";
|
|
202
198
|
import { createClaudeInterceptLifecycle } from "./index/claude-intercept-lifecycle";
|
|
199
|
+
import { createPackageTreeIntegrityGuardForServer } from "./index/package-tree-guard";
|
|
203
200
|
import { inspectStartupOwnership, resolveInboundBodyLimitWithWarning, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings";
|
|
204
201
|
import { acquireSpendLedgerServerLifecycle, recordFailedStartRollback, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle";
|
|
205
202
|
export { waitForFailedStartRollback } from "./index/spend-ledger-lifecycle";
|
|
@@ -255,10 +252,10 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
255
252
|
const resolveServiceHomes = deps.resolveServiceHomes ?? currentServiceHomes;
|
|
256
253
|
let startupOwnershipHomes: ReturnType<typeof currentServiceHomes> | null = null;
|
|
257
254
|
let startupOwnershipStatePaths: readonly string[] | null = null;
|
|
258
|
-
// #2923:
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
// Runtime ownership retries below intentionally omit this startup-local memo.
|
|
255
|
+
// #2923: retain a successful fallback listing only within the first startup
|
|
256
|
+
// ownership decision. A targeted query's bytes are not a Task Scheduler state
|
|
257
|
+
// generation, so the later race-sensitive decision must take a fresh listing.
|
|
258
|
+
// Runtime ownership retries below intentionally omit this startup-local memo too.
|
|
262
259
|
const startupWindowsTaskListingCache = createWindowsTaskListingCache();
|
|
263
260
|
try {
|
|
264
261
|
const homes = resolveServiceHomes();
|
|
@@ -320,12 +317,13 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
320
317
|
const listenPort = port ?? config.port ?? 10100;
|
|
321
318
|
setCorsOrigin(listenPort);
|
|
322
319
|
|
|
323
|
-
// Canonicalize an explicit "localhost" bind
|
|
320
|
+
// Canonicalize an explicit "localhost" bind (including its fully-qualified spelling) to IPv4
|
|
321
|
+
// so it matches the injected base_url (which
|
|
324
322
|
// resolves localhost→127.0.0.1): on Windows `localhost` resolves ::1-first, but the injected URL
|
|
325
323
|
// is 127.0.0.1, so binding literal "localhost" would reintroduce the F4 refusal. Wildcards
|
|
326
324
|
// (0.0.0.0/::) and specific hosts are left untouched so intentional exposure is preserved.
|
|
327
325
|
const configuredHost = config.hostname?.trim();
|
|
328
|
-
const bindHost = !configuredHost || /^localhost
|
|
326
|
+
const bindHost = !configuredHost || /^localhost\.?$/i.test(configuredHost) ? "127.0.0.1" : configuredHost;
|
|
329
327
|
|
|
330
328
|
// Unauthenticated loopback listener (#1102). Off unless explicitly enabled.
|
|
331
329
|
// A port-less enabled entry is the companion form: same port as the public listener, on
|
|
@@ -515,16 +513,14 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
515
513
|
// still unreadable to a browser dashboard -- which made exposing it pointless.
|
|
516
514
|
return withCors(workflowDecisionRefusalResponse(workflow, undefined, refusalLog), req, policy);
|
|
517
515
|
}
|
|
518
|
-
|
|
516
|
+
if (workflow?.admitted) lease.attach(workflow.lease);
|
|
519
517
|
let response: Response;
|
|
520
518
|
try {
|
|
521
519
|
response = await runAdmittedBodyWork(req, policy, config.maxInboundBodyBytes, () => work(lease), refusalLog);
|
|
522
520
|
} catch (error) {
|
|
523
|
-
releaseWorkflow();
|
|
524
521
|
lease.release();
|
|
525
522
|
throw error;
|
|
526
523
|
}
|
|
527
|
-
releaseWorkflow();
|
|
528
524
|
if (!lease.isTransferred()) {
|
|
529
525
|
lease.release();
|
|
530
526
|
}
|
|
@@ -537,8 +533,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
537
533
|
// passes it in, and transitions it after the post-startup sync settles. When
|
|
538
534
|
// no gate is supplied (tests, ad-hoc starts) a fresh pending gate is created.
|
|
539
535
|
const readinessGate = deps.readinessGate ?? createReadinessGate();
|
|
540
|
-
const packageTreeIntegrity = deps
|
|
541
|
-
?? createRuntimePackageTreeIntegrityGuard(detectInstall());
|
|
536
|
+
const packageTreeIntegrity = createPackageTreeIntegrityGuardForServer(deps);
|
|
542
537
|
// Actual bound port, filled in after Bun.serve binds so /readyz reports the
|
|
543
538
|
// real ephemeral port for startServer(0). /healthz keeps its existing port
|
|
544
539
|
// field (the requested listenPort) byte-for-byte.
|
|
@@ -554,7 +549,6 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
554
549
|
deps,
|
|
555
550
|
startupOwnershipHomes,
|
|
556
551
|
startupOwnershipStatePaths,
|
|
557
|
-
startupWindowsTaskListingCache,
|
|
558
552
|
);
|
|
559
553
|
const preparedNativeMainLifecycle = nativeOwnership.ownership !== "foreign"
|
|
560
554
|
&& startupOwnershipHomes !== null
|
|
@@ -757,6 +751,10 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
757
751
|
value: async (closeActiveConnections?: boolean): Promise<void> => {
|
|
758
752
|
remoteWorkspaceStopping = true;
|
|
759
753
|
liveCallBindings.clear();
|
|
754
|
+
// Disarm the package-tree restart timer before listener teardown: a queued
|
|
755
|
+
// replacement callback must not call acceptSystemRestart() after stop() has
|
|
756
|
+
// begun, or it would schedule a drain-and-restart on a stopped server.
|
|
757
|
+
packageTreeIntegrity.dispose();
|
|
760
758
|
// The orchestration lives in `runListenerShutdown` so its two competing properties —
|
|
761
759
|
// cleanup completes, failure propagates — are testable without a live socket.
|
|
762
760
|
await runListenerShutdown(
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -35,6 +35,7 @@ export const MAX_ACTIVE_SESSION_LANES = 64;
|
|
|
35
35
|
export const SESSION_LANE_ID_BYTES = 32;
|
|
36
36
|
const turnGate = createAdmissionGate("active_turns", MAX_ACTIVE_TURNS);
|
|
37
37
|
export interface ActiveTurnLease extends AdmissionLease {
|
|
38
|
+
attach(lease: AdmissionLease): void;
|
|
38
39
|
bindAbortController(ac: AbortController): void;
|
|
39
40
|
beginCodexAccountSelection(): CodexAccountSelectionAdmission;
|
|
40
41
|
isTransferred(): boolean;
|
|
@@ -190,10 +191,15 @@ export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
|
|
|
190
191
|
}
|
|
191
192
|
}
|
|
192
193
|
const controllers = new Set<AbortController>();
|
|
194
|
+
const attachedLeases = new Set<AdmissionLease>();
|
|
193
195
|
let active = true;
|
|
194
196
|
let transferred = false;
|
|
195
197
|
let nativeMainClaimed = false;
|
|
196
198
|
const lease: ActiveTurnLease = {
|
|
199
|
+
attach(attachedLease) {
|
|
200
|
+
if (!active) attachedLease.release();
|
|
201
|
+
else attachedLeases.add(attachedLease);
|
|
202
|
+
},
|
|
197
203
|
bindAbortController(ac) {
|
|
198
204
|
knownTurnControllers.add(ac);
|
|
199
205
|
if (!active) {
|
|
@@ -238,6 +244,8 @@ export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
|
|
|
238
244
|
if (activeTurns.get(controller) === lease) activeTurns.delete(controller);
|
|
239
245
|
}
|
|
240
246
|
controllers.clear();
|
|
247
|
+
for (const attachedLease of attachedLeases) attachedLease.release();
|
|
248
|
+
attachedLeases.clear();
|
|
241
249
|
nativeMainTurns.delete(lease);
|
|
242
250
|
if (opaqueSessionLaneId) {
|
|
243
251
|
const currentRefCount = activeSessionLaneRefCounts.get(opaqueSessionLaneId);
|
package/src/server/live.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { codexCompatibleUrl } from "../codex/context-compat";
|
|
|
27
27
|
* - `GET /v1/realtime?model=` — RealtimeV2 standalone (no intent)
|
|
28
28
|
* - `GET /v1/live?model=` — Frameless standalone
|
|
29
29
|
*/
|
|
30
|
-
import {
|
|
30
|
+
import { closeSync, fchmodSync, fstatSync, openSync, statSync, writeSync } from "node:fs";
|
|
31
31
|
import { formatErrorResponse } from "../bridge";
|
|
32
32
|
import {
|
|
33
33
|
CodexAccountCooldownError,
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
import { formatCodexProviderForLog } from "../codex/routing";
|
|
43
43
|
import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort";
|
|
44
44
|
import { sidecarEnter } from "../lib/sidecar-tracker";
|
|
45
|
+
import { hardenSecretPath } from "../lib/windows-secret-acl";
|
|
45
46
|
import type { OcxConfig } from "../types";
|
|
46
47
|
import { resolveFirstUsableOpenAiSidecar, selectOpenAiImagesProvider } from "../providers/openai-sidecar";
|
|
47
48
|
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "./auth-cors";
|
|
@@ -103,9 +104,58 @@ export const LIVE_CLIENT_PROTOCOL_HEADERS = [
|
|
|
103
104
|
* JSONL record: direction, frame kind, byte length, and whether the payload contains U+FFFD.
|
|
104
105
|
* Privacy: no frame content is written, including excerpts around replacement characters.
|
|
105
106
|
* For binary frames, U+FFFD may also be introduced by UTF-8 decoding; the flag alone does not
|
|
106
|
-
* identify the source of corruption.
|
|
107
|
+
* identify the source of corruption. The log is created with owner-only permissions and is
|
|
108
|
+
* disabled entirely when the env var is unset.
|
|
107
109
|
*/
|
|
108
110
|
export const LIVE_FRAME_LOG_ENV = "OCX_LIVE_FRAME_LOG";
|
|
111
|
+
/**
|
|
112
|
+
* Append one JSONL record with owner-only permissions. `appendFileSync`'s `mode` only applies
|
|
113
|
+
* when it creates the file, so an existing permissive log would stay readable by other local
|
|
114
|
+
* users. Open for append, harden the target, and verify that the path still names
|
|
115
|
+
* the opened file before writing. A failed harden or identity check writes nothing. On Windows
|
|
116
|
+
* the hardened file's identity is remembered, so a replaced file is hardened again but an
|
|
117
|
+
* unchanged one does not spawn icacls for every frame.
|
|
118
|
+
*/
|
|
119
|
+
/** Windows ACL hardening spawns icacls; remember the file already hardened so frames do not. */
|
|
120
|
+
let hardenedWindowsFrameLog: { path: string; dev: bigint; ino: bigint } | undefined;
|
|
121
|
+
|
|
122
|
+
export function appendOwnerOnly(
|
|
123
|
+
path: string,
|
|
124
|
+
line: string,
|
|
125
|
+
harden: (fd: number, path: string) => void = hardenLogDescriptor,
|
|
126
|
+
platform: NodeJS.Platform = process.platform,
|
|
127
|
+
): void {
|
|
128
|
+
const fd = openSync(path, "a", 0o600);
|
|
129
|
+
try {
|
|
130
|
+
const before = fstatSync(fd, { bigint: true });
|
|
131
|
+
const memo = hardenedWindowsFrameLog;
|
|
132
|
+
const alreadyHardened = platform === "win32" && memo !== undefined && memo.path === path
|
|
133
|
+
&& before.ino !== 0n && memo.dev === before.dev && memo.ino === before.ino;
|
|
134
|
+
if (!alreadyHardened) {
|
|
135
|
+
hardenedWindowsFrameLog = undefined;
|
|
136
|
+
harden(fd, path);
|
|
137
|
+
}
|
|
138
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
139
|
+
const named = statSync(path, { bigint: true });
|
|
140
|
+
if (!opened.isFile() || !named.isFile() || opened.ino === 0n || named.ino === 0n
|
|
141
|
+
|| opened.dev !== named.dev || opened.ino !== named.ino) {
|
|
142
|
+
throw new Error("Frame log path changed during hardening.");
|
|
143
|
+
}
|
|
144
|
+
if (platform === "win32") hardenedWindowsFrameLog = { path, dev: opened.dev, ino: opened.ino };
|
|
145
|
+
writeSync(fd, line);
|
|
146
|
+
} finally {
|
|
147
|
+
closeSync(fd);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function hardenLogDescriptor(fd: number, path: string): void {
|
|
152
|
+
if (process.platform === "win32") {
|
|
153
|
+
hardenSecretPath(path, { required: true });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
fchmodSync(fd, 0o600);
|
|
157
|
+
}
|
|
158
|
+
|
|
109
159
|
export function logLiveSidebandFrame(dir: "c2u" | "u2c", data: unknown): void {
|
|
110
160
|
const logPath = process.env[LIVE_FRAME_LOG_ENV];
|
|
111
161
|
if (!logPath) return;
|
|
@@ -134,7 +184,7 @@ export function logLiveSidebandFrame(dir: "c2u" | "u2c", data: unknown): void {
|
|
|
134
184
|
bytes,
|
|
135
185
|
fffd,
|
|
136
186
|
};
|
|
137
|
-
|
|
187
|
+
appendOwnerOnly(logPath, `${JSON.stringify(record)}\n`);
|
|
138
188
|
} catch {
|
|
139
189
|
// Frame forensics must never break the relay.
|
|
140
190
|
}
|
|
@@ -168,7 +218,7 @@ export function logLiveSidebandStage(
|
|
|
168
218
|
const record: Record<string, unknown> = { ts: new Date().toISOString(), stage };
|
|
169
219
|
if (detail?.status !== undefined) record.status = detail.status;
|
|
170
220
|
if (detail?.code !== undefined) record.code = detail.code;
|
|
171
|
-
|
|
221
|
+
appendOwnerOnly(logPath, JSON.stringify(record) + "\n");
|
|
172
222
|
} catch {
|
|
173
223
|
// Diagnostics must never break the relay.
|
|
174
224
|
}
|