@bitkyc08/opencodex 2.82.0 → 2.84.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/gui/dist/assets/App-CZtqiGcw.js +56 -0
- package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
- package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/coding-agent/protocol.ts +15 -8
- package/src/adapters/cursor/catalog.ts +2 -2
- package/src/adapters/cursor/effort-map.ts +1 -1
- package/src/adapters/devin/live-models.ts +2 -2
- package/src/adapters/google-truncation.ts +22 -0
- package/src/adapters/google.ts +7 -3
- package/src/adapters/ollama-native.ts +47 -15
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -0
- package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
- package/src/adapters/openai-responses/reasoning.ts +11 -1
- package/src/adapters/openai-responses/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +16 -14
- package/src/bridge/errors.ts +26 -1
- package/src/bridge.ts +1 -1
- package/src/claude/context-windows.ts +57 -4
- package/src/claude/desktop-picker.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/picker-ca-persistence.ts +3 -1
- package/src/claude/intercept/picker-listener.ts +49 -1
- package/src/claude/intercept/settings-no-proxy.ts +146 -0
- package/src/claude/intercept/settings.ts +101 -9
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +44 -12
- package/src/cli/account.ts +41 -5
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-base.ts +13 -1
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/claude.ts +15 -6
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +16 -3
- package/src/cli/export-command.ts +14 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +13 -4
- package/src/cli/link-child.ts +104 -0
- package/src/cli/link-enrollment.ts +96 -0
- package/src/cli/link.ts +39 -24
- package/src/cli/observe-stream.ts +6 -1
- package/src/cli/observe.ts +10 -2
- package/src/cli/provider.ts +12 -0
- package/src/cli/registry.ts +5 -4
- package/src/cli/resolve.ts +1 -1
- package/src/cli/runtime-api.ts +27 -2
- package/src/cli/status.ts +104 -20
- package/src/client/connect.ts +39 -4
- package/src/client/hub-client.ts +14 -7
- package/src/client/hub-state.ts +25 -14
- package/src/client/link-home.ts +248 -0
- package/src/client/link-management-relay.ts +61 -0
- package/src/client/link-relay.ts +6 -1
- package/src/client/link-status.ts +14 -0
- package/src/client/machine-api.ts +14 -7
- package/src/client/machine-listener.ts +29 -4
- package/src/clients/config-export/model-metadata.ts +16 -0
- package/src/clients/config-export/zcode-store.ts +46 -11
- package/src/clients/config-export/zcode.ts +3 -5
- package/src/clients/config-export.ts +47 -17
- package/src/clients/omo-role-models.ts +6 -3
- package/src/codex/agent-role-models.ts +26 -8
- package/src/codex/auth-api/account-list.ts +17 -2
- package/src/codex/auth-api/account-selection.ts +17 -7
- package/src/codex/auth-api/pool-mode-gate.ts +4 -5
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/auth-context.ts +8 -3
- package/src/codex/autostart-health.ts +3 -1
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/effort.ts +2 -2
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/native-models.ts +17 -3
- package/src/codex/catalog/provider-models.ts +25 -0
- package/src/codex/desktop-app/darwin.ts +1 -1
- package/src/codex/desktop-app/handoff.ts +1 -1
- package/src/codex/desktop-app/linux.ts +1 -1
- package/src/codex/desktop-app/lock.ts +1 -1
- package/src/codex/desktop-app/types.ts +1 -1
- package/src/codex/desktop-app/windows.ts +1 -1
- package/src/codex/desktop-app-restart.ts +1 -1
- package/src/codex/main-account.ts +6 -7
- package/src/codex/native-profile-startup.ts +4 -1
- package/src/codex/prompt-journal.ts +20 -2
- package/src/codex/prompt-layers/encoding.ts +49 -0
- package/src/codex/prompt-layers/import-source.ts +30 -0
- package/src/codex/prompt-layers/toml-edit.ts +269 -37
- package/src/codex/prompt-layers/toml-read.ts +52 -4
- package/src/codex/prompt-layers.ts +383 -20
- package/src/codex/prompt-lock-claim.ts +13 -3
- package/src/codex/prompt-lock-evidence.ts +3 -3
- package/src/codex/prompt-lock-owner.ts +14 -6
- package/src/codex/prompt-text-probe.ts +34 -5
- package/src/codex/quota.ts +13 -6
- package/src/codex/role-auto-assign.ts +2 -5
- package/src/codex/routing/active-account.ts +21 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-overlay.ts +8 -4
- package/src/codex/shim.ts +1 -1
- package/src/combos/failover.ts +3 -0
- package/src/config/api-key-reconcile.ts +34 -0
- package/src/config/diagnostics.ts +16 -0
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +43 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +2 -1
- package/src/config/paths.ts +3 -0
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +2 -0
- package/src/generated/compatibility-version.json +373 -217
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/inject.ts +245 -221
- package/src/grok/reset-coupon-ledger.ts +293 -57
- package/src/grok/reset-coupons.ts +15 -6
- package/src/integrations/cursor-detect.ts +1 -1
- package/src/integrations/mutation-plan.ts +13 -7
- package/src/integrations/registry.ts +45 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +14 -6
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/errors.ts +25 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/shadow-call.ts +44 -5
- package/src/link/codex-settings.ts +155 -0
- package/src/link/management-routes.ts +89 -0
- package/src/link/remote-probe.ts +38 -0
- package/src/link/ssh-argv.ts +26 -5
- package/src/link/ssh-runner.ts +58 -27
- package/src/link/status-projection.ts +22 -4
- package/src/link/supervisor.ts +144 -49
- package/src/link/tunnel-state.ts +11 -4
- package/src/oauth/anthropic-continuity.ts +4 -3
- package/src/oauth/anthropic-identity.ts +13 -2
- package/src/oauth/anthropic-model-quota.ts +13 -1
- package/src/oauth/anthropic-routing.ts +230 -23
- package/src/oauth/anthropic.ts +18 -4
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +25 -16
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro.ts +48 -5
- package/src/oauth/meta-muse-device.ts +1 -1
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +3 -1
- package/src/plugins/loader.ts +11 -0
- package/src/providers/anthropic-billing-ownership.ts +74 -0
- package/src/providers/anthropic-billing-recovery.ts +31 -0
- package/src/providers/anthropic-billing-refusal.ts +55 -0
- package/src/providers/anthropic-fast.ts +1 -1
- package/src/providers/anthropic-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
- package/src/providers/api-key-resolve.ts +17 -2
- package/src/providers/devin-cli-authmode-migration.ts +1 -1
- package/src/providers/devin-provider-merge-migration.ts +1 -1
- package/src/providers/github-copilot-auto.ts +169 -0
- package/src/providers/key-failover.ts +52 -5
- package/src/providers/model-presets.ts +1 -1
- package/src/providers/model-rename-startup.ts +5 -3
- package/src/providers/muse-subscription-usage.ts +1 -1
- package/src/providers/quota/account-cache.ts +11 -0
- package/src/providers/quota/anthropic-account-quota.ts +4 -2
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota.ts +4 -1
- package/src/providers/registry/entries-core.ts +20 -20
- package/src/providers/registry/entries-extended.ts +4 -1
- package/src/providers/registry/model-seeds.ts +20 -3
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/providers/xai-fast-model.ts +1 -1
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +7 -1
- package/src/server/auth-cors.ts +42 -0
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/inbound-body-admission.ts +24 -7
- package/src/server/index/link-listener.ts +30 -17
- package/src/server/index/link-management.ts +58 -0
- package/src/server/index/optional-listeners.ts +5 -1
- package/src/server/index/serve-options.ts +21 -2
- package/src/server/index.ts +1 -1
- package/src/server/lifecycle.ts +33 -5
- package/src/server/management/agent-settings-routes.ts +28 -10
- package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
- package/src/server/management/codex-agent-role-routes.ts +19 -6
- package/src/server/management/codex-prompt-routes.ts +136 -5
- package/src/server/management/codex-role-auto-assign.ts +1 -1
- package/src/server/management/grok-coupon-routes.ts +333 -57
- package/src/server/management/link-enrollment.ts +192 -0
- package/src/server/management/link-route-state.ts +185 -0
- package/src/server/management/link-routes.ts +92 -390
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/provider-overwrite-carry.ts +10 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/management/route-registry.ts +7 -5
- package/src/server/management/shared.ts +3 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native.ts +68 -42
- package/src/server/relay-eager.ts +4 -0
- package/src/server/relay.ts +18 -7
- package/src/server/request-log-failure-attribution.ts +3 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +243 -0
- package/src/server/request-log.ts +15 -118
- package/src/server/responses/adapter-continuation.ts +9 -1
- package/src/server/responses/adapter-dispatch.ts +293 -81
- package/src/server/responses/code-mode-collab-surface.ts +205 -0
- package/src/server/responses/codex-ws-exchange.ts +147 -6
- package/src/server/responses/codex-ws-wire.ts +203 -0
- package/src/server/responses/collaboration.ts +36 -7
- package/src/server/responses/compact.ts +129 -50
- package/src/server/responses/context-overflow.ts +13 -1
- package/src/server/responses/core-codex-account.ts +105 -1
- package/src/server/responses/core-combo-failure.ts +14 -0
- package/src/server/responses/core-combo.ts +7 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-opaque-recovery.ts +5 -5
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core.ts +6 -6
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/empty-completion-guard.ts +18 -2
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/jev-model-invoke.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +4 -1
- package/src/server/responses/passthrough-dispatch.ts +130 -64
- package/src/server/responses/passthrough-execution.ts +3 -3
- package/src/server/responses/request-prepare.ts +6 -4
- package/src/server/responses/request-transport.ts +131 -13
- package/src/server/responses/run-turn-execution.ts +13 -5
- package/src/server/responses/sidecar-execution.ts +16 -2
- package/src/server/responses/spend-storage-error.ts +33 -0
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/startup-action-control.ts +22 -2
- package/src/server/startup-health-cache.ts +4 -0
- package/src/server/system-env-shell.ts +7 -2
- package/src/server/system-env.ts +11 -1
- package/src/service/cli.ts +5 -1
- package/src/service/desktop-startup.ts +147 -34
- package/src/service/desktop-supervision.d.mts +27 -1
- package/src/service/desktop-supervision.mjs +111 -5
- package/src/service/health-sentinel-launch.ts +176 -0
- package/src/service/health-sentinel-main.ts +447 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +54 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/types/config.ts +5 -0
- package/src/types/request.ts +5 -0
- package/src/usage/expected-prices.ts +5 -1
- package/src/usage/log.ts +19 -1
- package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
- package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
- package/gui/dist/assets/index-D3FBCNiP.js +0 -94
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials this installation is configured with, read when an upstream diagnostic is checked.
|
|
3
|
+
*
|
|
4
|
+
* A gateway can echo a credential it learned before this process ever sent it: an inactive pool
|
|
5
|
+
* key, another provider's key, an OAuth token from an earlier session. Learning only from sends
|
|
6
|
+
* would let such an echo be warned and finalized before the value is first dispatched, and a warning
|
|
7
|
+
* cannot be retracted. So the request log also refuses any diagnostic that repeats a configured
|
|
8
|
+
* value: every provider's apiKey and apiKeyPool keys (keychain and env references resolved), every
|
|
9
|
+
* configured header value, every credential-named provider field, and every token in the OAuth
|
|
10
|
+
* store. The set is rebuilt when the bytes of config.json or auth.json change, a stored keychain
|
|
11
|
+
* secret is replaced in this process, or a referenced environment variable changes.
|
|
12
|
+
*
|
|
13
|
+
* Freshness is decided by content, never by file metadata. Every check reads both stores and
|
|
14
|
+
* compares a digest of the bytes it read; the rebuild parses those same bytes. An inode/size/mtime
|
|
15
|
+
* key missed a same-length rewrite inside one timestamp tick (coarse on Windows) and any writer that
|
|
16
|
+
* preserves or backdates mtime, and then kept serving the previous, complete-looking view (#6923).
|
|
17
|
+
* The cost is two small reads per check; checks run only for retained upstream diagnostics.
|
|
18
|
+
*
|
|
19
|
+
* Reads are observe-only: no hardening, backup, repair or warning. A file that exists but cannot be
|
|
20
|
+
* read, parsed or recognized as the expected shape, or a keychain reference that cannot be resolved,
|
|
21
|
+
* makes coverage incomplete and callers fail closed; an incomplete set is retried after a short delay
|
|
22
|
+
* so a keychain that becomes available restores diagnostics. Values are held in memory only and
|
|
23
|
+
* never serialized or exposed.
|
|
24
|
+
*
|
|
25
|
+
* Not covered: keychain secrets are cached per process (`readKeychain` in
|
|
26
|
+
* src/providers/api-key-resolve.ts), so a secret replaced by another process is seen only after
|
|
27
|
+
* this process re-resolves it.
|
|
28
|
+
*/
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import { readFileSync, statSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { getConfigDir } from "../config/paths";
|
|
33
|
+
import { resolveEnvValue } from "../config/proxy-env";
|
|
34
|
+
import { isKeychainReference, providerKeyResolutionGeneration, resolveProviderApiKeyQuietly } from "../providers/api-key-resolve";
|
|
35
|
+
import { credentialComponents, credentialSetMatches } from "../lib/outbound-credential-registry";
|
|
36
|
+
import { SENSITIVE_KEY_PATTERN } from "../lib/redact";
|
|
37
|
+
|
|
38
|
+
const CREDENTIAL_FIELD = /(?:key|token|secret|password|passwd|credential|cookie|authorization)$/i;
|
|
39
|
+
/** How long an incomplete set is trusted before its sources are read again. */
|
|
40
|
+
export const INCOMPLETE_RETRY_MS = 30_000;
|
|
41
|
+
|
|
42
|
+
export interface ConfiguredCredentials {
|
|
43
|
+
readonly complete: boolean;
|
|
44
|
+
matches(value: string): boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Snapshot {
|
|
48
|
+
/** The only object handed out: cache metadata (including env values) stays private here. */
|
|
49
|
+
readonly view: ConfiguredCredentials;
|
|
50
|
+
readonly signature: string;
|
|
51
|
+
readonly envNames: readonly string[];
|
|
52
|
+
readonly envFingerprint: string;
|
|
53
|
+
readonly builtAt: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let cached: Snapshot | undefined;
|
|
57
|
+
|
|
58
|
+
/** One read of a store: its state for the signature and, for a readable file, the bytes read. */
|
|
59
|
+
interface StoreRead {
|
|
60
|
+
/** "absent", "not-a-file", "unreadable", or "sha256:<hex>" of exactly the bytes in `text`. */
|
|
61
|
+
readonly state: string;
|
|
62
|
+
readonly text?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readStore(path: string): StoreRead {
|
|
66
|
+
try {
|
|
67
|
+
if (!statSync(path).isFile()) return { state: "not-a-file" };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
// Only a store that is not there at all counts as intentionally absent.
|
|
70
|
+
return { state: (error as NodeJS.ErrnoException)?.code === "ENOENT" ? "absent" : "unreadable" };
|
|
71
|
+
}
|
|
72
|
+
let bytes: Buffer;
|
|
73
|
+
try {
|
|
74
|
+
bytes = readFileSync(path);
|
|
75
|
+
} catch {
|
|
76
|
+
// Present a moment ago, gone or unreadable now (a replacement in progress): fail closed.
|
|
77
|
+
return { state: "unreadable" };
|
|
78
|
+
}
|
|
79
|
+
return { state: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, text: bytes.toString("utf8") };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseStore(read: StoreRead): { ok: boolean; value?: unknown } {
|
|
83
|
+
if (read.state === "absent") return { ok: true };
|
|
84
|
+
if (read.text === undefined) return { ok: false };
|
|
85
|
+
try {
|
|
86
|
+
return { ok: true, value: JSON.parse(read.text.replace(/^\uFEFF/, "")) };
|
|
87
|
+
} catch {
|
|
88
|
+
return { ok: false };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
93
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function envFingerprint(names: readonly string[]): string {
|
|
97
|
+
return names.map(name => process.env[name] ?? "\u0000unset").join("\u0000");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Mirrors resolveEnvValue: a braced or bare dollar reference reads the environment. */
|
|
101
|
+
function envReference(raw: string): string | undefined {
|
|
102
|
+
if (!raw.startsWith("$")) return undefined;
|
|
103
|
+
return /^\$\{(\w+)\}$/.exec(raw)?.[1] ?? raw.slice(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function build(configRead: StoreRead, authRead: StoreRead, signature: string, now: number): Snapshot {
|
|
107
|
+
const values = new Set<string>();
|
|
108
|
+
const envNames = new Set<string>();
|
|
109
|
+
let complete = true;
|
|
110
|
+
const add = (raw: string | undefined) => {
|
|
111
|
+
if (raw) for (const part of credentialComponents(raw)) values.add(part);
|
|
112
|
+
};
|
|
113
|
+
const noteEnv = (raw: string) => {
|
|
114
|
+
const name = envReference(raw);
|
|
115
|
+
if (name) envNames.add(name);
|
|
116
|
+
};
|
|
117
|
+
const addReference = (raw: unknown) => {
|
|
118
|
+
if (typeof raw !== "string" || !raw) return;
|
|
119
|
+
noteEnv(raw);
|
|
120
|
+
const resolved = resolveProviderApiKeyQuietly(raw);
|
|
121
|
+
if (resolved === undefined && isKeychainReference(raw)) complete = false;
|
|
122
|
+
add(resolved);
|
|
123
|
+
};
|
|
124
|
+
const isCredentialField = (field: string) => CREDENTIAL_FIELD.test(field) || SENSITIVE_KEY_PATTERN.test(field);
|
|
125
|
+
const walkProvider = (node: unknown, depth: number) => {
|
|
126
|
+
if (depth > 8) return;
|
|
127
|
+
if (Array.isArray(node)) { for (const item of node) walkProvider(item, depth + 1); return; }
|
|
128
|
+
if (!isRecord(node)) return;
|
|
129
|
+
for (const [field, value] of Object.entries(node)) {
|
|
130
|
+
if (field === "headers" && isRecord(value)) {
|
|
131
|
+
for (const header of Object.values(value)) {
|
|
132
|
+
if (typeof header !== "string") continue;
|
|
133
|
+
noteEnv(header);
|
|
134
|
+
add(resolveEnvValue(header) ?? header);
|
|
135
|
+
}
|
|
136
|
+
} else if (typeof value === "string") {
|
|
137
|
+
if (isCredentialField(field)) addReference(value);
|
|
138
|
+
} else {
|
|
139
|
+
walkProvider(value, depth + 1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const walkAuth = (node: unknown, depth: number) => {
|
|
144
|
+
if (depth > 8) return;
|
|
145
|
+
if (Array.isArray(node)) { for (const item of node) walkAuth(item, depth + 1); return; }
|
|
146
|
+
if (!isRecord(node)) return;
|
|
147
|
+
for (const [field, value] of Object.entries(node)) {
|
|
148
|
+
if (typeof value === "string") {
|
|
149
|
+
if (field === "access" || field === "refresh" || isCredentialField(field)) add(value);
|
|
150
|
+
} else {
|
|
151
|
+
walkAuth(value, depth + 1);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const configSig = configRead.state;
|
|
157
|
+
const authSig = authRead.state;
|
|
158
|
+
const config = parseStore(configRead);
|
|
159
|
+
const auth = parseStore(authRead);
|
|
160
|
+
if (!config.ok || !auth.ok) complete = false;
|
|
161
|
+
// An existing file must have the shape its loader expects; anything else hides credentials.
|
|
162
|
+
if (configSig !== "absent" && config.ok && (!isRecord(config.value)
|
|
163
|
+
|| (config.value.providers !== undefined && !isRecord(config.value.providers)))) complete = false;
|
|
164
|
+
if (authSig !== "absent" && auth.ok && !isRecord(auth.value)) complete = false;
|
|
165
|
+
if (isRecord(config.value) && isRecord(config.value.providers)) walkProvider(config.value.providers, 0);
|
|
166
|
+
if (isRecord(auth.value)) walkAuth(auth.value, 0);
|
|
167
|
+
|
|
168
|
+
const names = [...envNames].sort();
|
|
169
|
+
const view: ConfiguredCredentials = Object.freeze({
|
|
170
|
+
complete,
|
|
171
|
+
matches: (value: string) => credentialSetMatches(values, candidate => values.has(candidate), value),
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
view,
|
|
175
|
+
signature,
|
|
176
|
+
envNames: names,
|
|
177
|
+
envFingerprint: envFingerprint(names),
|
|
178
|
+
builtAt: now,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The configured credential set for the active config home, rebuilt when any input changes. */
|
|
183
|
+
export function configuredCredentials(now = Date.now()): ConfiguredCredentials {
|
|
184
|
+
const dir = getConfigDir();
|
|
185
|
+
const configRead = readStore(join(dir, "config.json"));
|
|
186
|
+
const authRead = readStore(join(dir, "auth.json"));
|
|
187
|
+
const signature = `${configRead.state}|${authRead.state}|${providerKeyResolutionGeneration()}|${dir}`;
|
|
188
|
+
const stale = !cached
|
|
189
|
+
|| cached.signature !== signature
|
|
190
|
+
|| cached.envFingerprint !== envFingerprint(cached.envNames)
|
|
191
|
+
|| (!cached.view.complete && now - cached.builtAt >= INCOMPLETE_RETRY_MS);
|
|
192
|
+
if (stale) cached = build(configRead, authRead, signature, now);
|
|
193
|
+
return cached!.view;
|
|
194
|
+
}
|
|
@@ -71,20 +71,37 @@ function capacityResponse(pathname: string, error: InboundBodyCapacityError): Re
|
|
|
71
71
|
}, { status: 503, headers: { "Retry-After": "1" } });
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function retainUntilResponseSettles(response: Response, release: () => void): Response {
|
|
74
|
+
function retainUntilResponseSettles(response: Response, signal: AbortSignal, release: () => void): Response {
|
|
75
75
|
if (!response.body) {
|
|
76
76
|
release();
|
|
77
77
|
return response;
|
|
78
78
|
}
|
|
79
79
|
const reader = response.body.getReader();
|
|
80
80
|
let cancelling = false;
|
|
81
|
+
let cancellation: Promise<void> | undefined;
|
|
81
82
|
let finalized = false;
|
|
83
|
+
let bodyController!: ReadableStreamDefaultController<Uint8Array>;
|
|
84
|
+
const onAbort = (): void => {
|
|
85
|
+
if (finalized) return;
|
|
86
|
+
bodyController.error(signal.reason);
|
|
87
|
+
void cancel(signal.reason).catch(() => undefined);
|
|
88
|
+
};
|
|
82
89
|
const finalize = (): void => {
|
|
83
90
|
if (finalized) return;
|
|
84
91
|
finalized = true;
|
|
92
|
+
signal.removeEventListener("abort", onAbort);
|
|
85
93
|
try { reader.releaseLock(); } finally { release(); }
|
|
86
94
|
};
|
|
95
|
+
const cancel = (reason: unknown): Promise<void> => {
|
|
96
|
+
if (cancellation) return cancellation;
|
|
97
|
+
if (finalized) return Promise.resolve();
|
|
98
|
+
cancelling = true;
|
|
99
|
+
// Share one settlement even if explicit cancellation races request abort.
|
|
100
|
+
cancellation = Promise.resolve().then(() => reader.cancel(reason)).finally(finalize);
|
|
101
|
+
return cancellation;
|
|
102
|
+
};
|
|
87
103
|
const body = new ReadableStream<Uint8Array>({
|
|
104
|
+
start(controller) { bodyController = controller; },
|
|
88
105
|
async pull(controller) {
|
|
89
106
|
try {
|
|
90
107
|
const result = await reader.read();
|
|
@@ -103,18 +120,18 @@ function retainUntilResponseSettles(response: Response, release: () => void): Re
|
|
|
103
120
|
controller.error(error);
|
|
104
121
|
}
|
|
105
122
|
},
|
|
106
|
-
|
|
107
|
-
cancelling = true;
|
|
108
|
-
try { await reader.cancel(reason); } finally { finalize(); }
|
|
109
|
-
},
|
|
123
|
+
cancel,
|
|
110
124
|
}, { highWaterMark: 0 });
|
|
111
125
|
// This is the outermost HTTP wrapper, after protocol conversion, relay
|
|
112
126
|
// preflight and request logging; no internal relay marker is consumed later.
|
|
113
|
-
|
|
127
|
+
const wrapped = new Response(body, {
|
|
114
128
|
status: response.status,
|
|
115
129
|
statusText: response.statusText,
|
|
116
130
|
headers: response.headers,
|
|
117
131
|
});
|
|
132
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
133
|
+
if (signal.aborted) onAbort();
|
|
134
|
+
return wrapped;
|
|
118
135
|
}
|
|
119
136
|
|
|
120
137
|
/**
|
|
@@ -155,7 +172,7 @@ export async function withRaisedInboundBodyAdmission(
|
|
|
155
172
|
reservedInboundBodyBytes -= maxBytes;
|
|
156
173
|
};
|
|
157
174
|
try {
|
|
158
|
-
return retainUntilResponseSettles(await work(), release);
|
|
175
|
+
return retainUntilResponseSettles(await work(), req.signal, release);
|
|
159
176
|
} catch (error) {
|
|
160
177
|
release();
|
|
161
178
|
throw error;
|
|
@@ -8,9 +8,18 @@ import {
|
|
|
8
8
|
} from "../../link/store";
|
|
9
9
|
import { linkStorePath } from "../../link/paths";
|
|
10
10
|
import { linkRouteAllowed } from "../../link/routes";
|
|
11
|
+
import { withConfigMutationLockSync } from "../../config/mutation-lock";
|
|
11
12
|
|
|
12
13
|
export const LINK_INGRESS_HOSTNAME = "opencodex-link.invalid";
|
|
13
14
|
|
|
15
|
+
/** Internal signal: the store's port differs from the port this bind landed on. */
|
|
16
|
+
class LinkListenerPortMismatchError extends Error {
|
|
17
|
+
constructor(storedPort: number, boundPort: number) {
|
|
18
|
+
super(`stored port ${storedPort} differs from bound port ${boundPort}`);
|
|
19
|
+
this.name = "LinkListenerPortMismatchError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
export interface LinkListenerStartContext<T> {
|
|
15
24
|
dispatch: (req: Request, server: Server<T>) => Promise<Response>;
|
|
16
25
|
maxRequestBodySize: number;
|
|
@@ -118,29 +127,33 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
|
|
|
118
127
|
closeWithoutAwait(bound);
|
|
119
128
|
return;
|
|
120
129
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
130
|
+
const port = bound.port;
|
|
131
|
+
if (!port || port === 0) {
|
|
132
|
+
closeWithoutAwait(bound);
|
|
133
|
+
reportFailure("bind", new Error("Bun did not report a concrete listener port"), "bind");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
// Revalidate the admission and persist a new port in one mutation section.
|
|
138
|
+
// Even a known-port bind can race the deletion of its last recorded link.
|
|
139
|
+
const needed = withConfigMutationLockSync(() => {
|
|
129
140
|
const current = readStore(storePath);
|
|
141
|
+
if (current.links.length === 0) return false;
|
|
130
142
|
if (current.listenerPort !== null && current.listenerPort !== port) {
|
|
131
|
-
|
|
132
|
-
// port, so serving on this one would strand them: give it up and let the next
|
|
133
|
-
// ensureStarted() bind the stored port.
|
|
134
|
-
closeWithoutAwait(bound);
|
|
135
|
-
reportFailure("listenerPort persistence", new Error(`stored port ${current.listenerPort} differs from bound port ${port}`), "persist");
|
|
136
|
-
return;
|
|
143
|
+
throw new LinkListenerPortMismatchError(current.listenerPort, port);
|
|
137
144
|
}
|
|
138
|
-
writeStore(storePath, { ...current, listenerPort: port });
|
|
139
|
-
|
|
145
|
+
if (current.listenerPort === null) writeStore(storePath, { ...current, listenerPort: port });
|
|
146
|
+
return true;
|
|
147
|
+
});
|
|
148
|
+
if (!needed) {
|
|
140
149
|
closeWithoutAwait(bound);
|
|
141
|
-
|
|
150
|
+
setStatus("off", null, null);
|
|
142
151
|
return;
|
|
143
152
|
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
closeWithoutAwait(bound);
|
|
155
|
+
reportFailure("listenerPort persistence", error, "persist");
|
|
156
|
+
return;
|
|
144
157
|
}
|
|
145
158
|
listener = bound as Server<T>;
|
|
146
159
|
relaySessions = sessions;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Home end of the Remote Link management relay (#6957).
|
|
3
|
+
*
|
|
4
|
+
* Reached only on the hub-link listener, after the request has passed its link-key admission
|
|
5
|
+
* (`resolveApiAuth` restricted to recorded link keys) and the shared route table in
|
|
6
|
+
* `src/link/management-routes.ts`. The management handler sees a fresh request built here: no
|
|
7
|
+
* caller header, cookie, Origin or credential crosses from the link, and the principal is
|
|
8
|
+
* absent, so no consent- or capability-gated branch can be reached. Only allowlisted reads run.
|
|
9
|
+
* They run as passive reads (`src/lib/passive-read.ts`): no discovery, config write or diagnostic (#6959).
|
|
10
|
+
*/
|
|
11
|
+
import type { OcxConfig } from "../../types";
|
|
12
|
+
import { handleManagementAPI, type ManagementApiDeps } from "../management-api";
|
|
13
|
+
import type { ManagementSessionControl } from "../management-auth";
|
|
14
|
+
import { runPassiveRead } from "../../lib/passive-read";
|
|
15
|
+
import { LINK_CODEX_SETTINGS_PATH, linkManagementRouteAllowed, linkManagementTarget } from "../../link/management-routes";
|
|
16
|
+
import { projectHomeCodexSettings } from "../../link/codex-settings";
|
|
17
|
+
import { packageVersion } from "../../lib/package-version";
|
|
18
|
+
|
|
19
|
+
const VERSION = packageVersion("0.0.0");
|
|
20
|
+
const KEPT_RESPONSE_HEADERS = ["content-type", "content-language", "etag", "last-modified"];
|
|
21
|
+
|
|
22
|
+
function notFound(): Response {
|
|
23
|
+
return Response.json({ error: "not_found" }, { status: 404, headers: { "cache-control": "no-store" } });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function handleLinkManagementRequest(
|
|
27
|
+
req: Request,
|
|
28
|
+
url: URL,
|
|
29
|
+
config: OcxConfig,
|
|
30
|
+
deps: ManagementApiDeps,
|
|
31
|
+
sessionControl?: ManagementSessionControl,
|
|
32
|
+
): Promise<Response> {
|
|
33
|
+
if (!linkManagementRouteAllowed(url, req)) return notFound();
|
|
34
|
+
if (url.pathname === LINK_CODEX_SETTINGS_PATH) {
|
|
35
|
+
return Response.json(projectHomeCodexSettings(config, VERSION), { headers: { "cache-control": "no-store" } });
|
|
36
|
+
}
|
|
37
|
+
const target = linkManagementTarget(url);
|
|
38
|
+
if (!target) return notFound();
|
|
39
|
+
const local = new URL(`${target.pathname}${target.search}`, "http://127.0.0.1");
|
|
40
|
+
// The handlers branch on GET; an admitted HEAD runs as GET and its body is dropped below.
|
|
41
|
+
const inner = new Request(local.href, {
|
|
42
|
+
method: "GET",
|
|
43
|
+
headers: { accept: "application/json", host: "127.0.0.1" },
|
|
44
|
+
});
|
|
45
|
+
const response = await runPassiveRead(() =>
|
|
46
|
+
handleManagementAPI(inner, local, config, deps, undefined, sessionControl, { trustedLoopback: false }));
|
|
47
|
+
if (!response) return notFound();
|
|
48
|
+
const headers = new Headers({ "cache-control": "no-store" });
|
|
49
|
+
for (const name of KEPT_RESPONSE_HEADERS) {
|
|
50
|
+
const value = response.headers.get(name);
|
|
51
|
+
if (value) headers.set(name, value);
|
|
52
|
+
}
|
|
53
|
+
if (req.method === "HEAD") {
|
|
54
|
+
try { await response.body?.cancel(); } catch { /* best effort */ }
|
|
55
|
+
return new Response(null, { status: response.status, headers });
|
|
56
|
+
}
|
|
57
|
+
return new Response(response.body, { status: response.status, headers });
|
|
58
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type LinkListenerStatus,
|
|
15
15
|
} from "./link-listener";
|
|
16
16
|
import { createLinkSupervisor, type LinkSupervisor } from "../../link/supervisor";
|
|
17
|
+
import { linkManagementRouteAllowed } from "../../link/management-routes";
|
|
17
18
|
import { saveConfigPreservingClaudeCode } from "../../config/live-reconcile";
|
|
18
19
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
19
20
|
export { LINK_INGRESS_HOSTNAME } from "./link-listener";
|
|
@@ -77,7 +78,10 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
|
|
|
77
78
|
if (claudeIntercept.ownsListener(server)) return "claude-intercept";
|
|
78
79
|
return undefined;
|
|
79
80
|
},
|
|
80
|
-
|
|
81
|
+
// The Home's link listener admits the data plane plus the read-only management relay
|
|
82
|
+
// (#6957). The Child's own ingress keeps the data-only table, so a local caller on the Child
|
|
83
|
+
// cannot reach the Home's management reads without the Child's dashboard session.
|
|
84
|
+
linkRouteAllowed: (url, req) => linkRouteAllowed(url, req) || linkManagementRouteAllowed(url, req),
|
|
81
85
|
linkAdmissionKeyIds: () => linkListener.linkAdmissionKeyIds(),
|
|
82
86
|
onAuthenticatedCatalog: listener => linkListener.onAuthenticatedCatalog(listener),
|
|
83
87
|
notifyAuthenticatedCatalog: apiKeyId => linkListener.notifyAuthenticatedCatalog(apiKeyId),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket } from "bun";
|
|
2
2
|
import type { StartServerDeps } from "./startup-warnings";
|
|
3
|
+
import { isLinkManagementPath } from "../../link/management-routes";
|
|
3
4
|
import {
|
|
4
5
|
GUI_PAIRING_EXCHANGE_BODY_LIMIT,
|
|
5
6
|
REMOTE_WORKSPACE_PAIRING_BODY_LIMIT,
|
|
@@ -96,6 +97,7 @@ import {
|
|
|
96
97
|
isAllowedManagementOrigin,
|
|
97
98
|
isApiAuthRequired,
|
|
98
99
|
isLoopbackHostname,
|
|
100
|
+
markTrustedHubManagementRequest,
|
|
99
101
|
jsonResponse,
|
|
100
102
|
admissionFields,
|
|
101
103
|
resolveApiAuth,
|
|
@@ -313,6 +315,11 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
313
315
|
maxRequestBodySize: inboundBodyLimitBytes,
|
|
314
316
|
async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
|
|
315
317
|
const ingress = ingressForServer(requestServer);
|
|
318
|
+
// Bind management-origin trust to the actual Bun listener object resolved above.
|
|
319
|
+
// Neither Host nor any X-Forwarded-* header can manufacture this process-local mark.
|
|
320
|
+
if (ingress === "hub-management" && config.runtimeRole === "hub") {
|
|
321
|
+
markTrustedHubManagementRequest(req);
|
|
322
|
+
}
|
|
316
323
|
const requestUrl = codexCompatibleUrl(req.url);
|
|
317
324
|
const linkPolicyView = ingress === "hub-link" ? linkPolicy() : undefined;
|
|
318
325
|
// The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
|
|
@@ -367,6 +374,12 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
367
374
|
if (requestUrl.pathname === "/v1/catalog" && requestUrl.search === ""
|
|
368
375
|
&& (req.method === "GET" || req.method === "HEAD")
|
|
369
376
|
&& admission.kind === "configured") onAuthenticatedCatalog(admission.keyId);
|
|
377
|
+
// #6957: the Child's read-only view of this Home. Admitted by the link key above and by
|
|
378
|
+
// the shared table in src/link/management-routes.ts; never reachable on another ingress.
|
|
379
|
+
if (isLinkManagementPath(requestUrl.pathname)) {
|
|
380
|
+
const { handleLinkManagementRequest } = await import("./link-management");
|
|
381
|
+
return withCors(await handleLinkManagementRequest(req, requestUrl, config, requestManagementApiDeps, managementSessionControl), req, policy);
|
|
382
|
+
}
|
|
370
383
|
}
|
|
371
384
|
const url = requestUrl;
|
|
372
385
|
const admissionOptions = { linkIngress: policy.linkIngress?.allowedKeyIds };
|
|
@@ -1594,8 +1607,11 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1594
1607
|
// `policy`, not `config`: this route is now served on the unauthenticated loopback
|
|
1595
1608
|
// listener too (#4236), and only the receiving listener's view produces CORS headers
|
|
1596
1609
|
// that match the admission decision made above.
|
|
1597
|
-
|
|
1598
|
-
|
|
1610
|
+
// Promote a caller `x-session-id` before admission, as Responses and Messages do (#6520):
|
|
1611
|
+
// the ChatGPT bridge forwards only canonical session headers, so the raw marker is lost.
|
|
1612
|
+
const sessionReq = withCallerSessionIdentity(req, admission);
|
|
1613
|
+
return runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => withCors(
|
|
1614
|
+
await handleChatCompletions(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }),
|
|
1599
1615
|
req,
|
|
1600
1616
|
policy,
|
|
1601
1617
|
), { requestId, start, logCtx });
|
|
@@ -1716,7 +1732,9 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1716
1732
|
if (audioController) registerTurn(audioController, turnAdmissionLease);
|
|
1717
1733
|
const acquisition = audioController
|
|
1718
1734
|
? clearableDeadline(120_000, AbortSignal.any([req.signal, audioController.signal])) : undefined;
|
|
1735
|
+
const detachClientAbort = turnAdmissionLease.bindAbortSignal(req.signal);
|
|
1719
1736
|
const releaseAcquisition = () => {
|
|
1737
|
+
detachClientAbort();
|
|
1720
1738
|
acquisition?.clear();
|
|
1721
1739
|
if (audioController) unregisterTurn(audioController);
|
|
1722
1740
|
else turnAdmissionLease.release();
|
|
@@ -1835,6 +1853,7 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1835
1853
|
return withCors(formatErrorResponse(502, "upstream_error", "Audio WebSocket upgrade failed"), req, policy);
|
|
1836
1854
|
}
|
|
1837
1855
|
if (upgraded) {
|
|
1856
|
+
detachClientAbort();
|
|
1838
1857
|
acquisition?.clear();
|
|
1839
1858
|
finalizeLiveRequest(101);
|
|
1840
1859
|
return undefined as unknown as Response;
|
package/src/server/index.ts
CHANGED
|
@@ -502,7 +502,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
502
502
|
work: (lease: ActiveTurnLease) => Promise<Response>,
|
|
503
503
|
refusalLog?: WorkflowRefusalLog,
|
|
504
504
|
): Promise<Response> {
|
|
505
|
-
const lease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers));
|
|
505
|
+
const lease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers), req.signal);
|
|
506
506
|
if (!lease) return serverBusyResponse(req, "active turns", policy);
|
|
507
507
|
// Root, lane, and the refusal that follows from them, all live in ./workflow-refusal.
|
|
508
508
|
let workflow: ReturnType<typeof admitHttpWorkflowTurn>;
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "../storage/policy-job";
|
|
9
9
|
import { abortRestoreTrashJobAsync } from "../storage/restore-job";
|
|
10
10
|
import { stopStorageCleanupScheduler } from "../storage/policy-scheduler";
|
|
11
|
-
import { runOptionalShutdownHooks } from "../lib/optional-shutdown-hooks";
|
|
11
|
+
import { runDrainStartHooks, runOptionalShutdownHooks } from "../lib/optional-shutdown-hooks";
|
|
12
12
|
import { stopStateStoreSweeper } from "../lib/state-store-sweeper";
|
|
13
13
|
import {
|
|
14
14
|
cancelQueuedStorageWorkerSpawns,
|
|
@@ -36,6 +36,7 @@ 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
38
|
attach(lease: AdmissionLease): void;
|
|
39
|
+
bindAbortSignal(signal: AbortSignal): () => void;
|
|
39
40
|
bindAbortController(ac: AbortController): void;
|
|
40
41
|
beginCodexAccountSelection(): CodexAccountSelectionAdmission;
|
|
41
42
|
isTransferred(): boolean;
|
|
@@ -169,7 +170,7 @@ export function resetLifecycleDrainStateForTests(): void {
|
|
|
169
170
|
serverStartupReleaseFlights = new WeakMap<ReturnType<typeof Bun.serve>, Promise<void>>();
|
|
170
171
|
releaseServerStartupLifecycleImpl = releaseNativeMainStartupLifecycle;
|
|
171
172
|
}
|
|
172
|
-
export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
|
|
173
|
+
export function tryAdmitTurn(sessionLaneId?: string, clientSignal?: AbortSignal): ActiveTurnLease | null {
|
|
173
174
|
if (isDraining()) return null;
|
|
174
175
|
const opaqueSessionLaneId = sessionLaneId
|
|
175
176
|
? createHash("sha256").update(sessionLaneId).digest("hex").slice(0, SESSION_LANE_ID_BYTES)
|
|
@@ -192,6 +193,8 @@ export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
|
|
|
192
193
|
}
|
|
193
194
|
const controllers = new Set<AbortController>();
|
|
194
195
|
const attachedLeases = new Set<AdmissionLease>();
|
|
196
|
+
const abortListeners = new Map<AbortSignal, () => void>();
|
|
197
|
+
let cancellationReason: unknown;
|
|
195
198
|
let active = true;
|
|
196
199
|
let transferred = false;
|
|
197
200
|
let nativeMainClaimed = false;
|
|
@@ -200,22 +203,40 @@ export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
|
|
|
200
203
|
if (!active) attachedLease.release();
|
|
201
204
|
else attachedLeases.add(attachedLease);
|
|
202
205
|
},
|
|
206
|
+
bindAbortSignal(signal) {
|
|
207
|
+
if (!active || abortListeners.has(signal)) return () => {};
|
|
208
|
+
const onAbort = () => {
|
|
209
|
+
cancellationReason = signal.reason;
|
|
210
|
+
const boundControllers = [...controllers];
|
|
211
|
+
lease.release();
|
|
212
|
+
for (const controller of boundControllers) controller.abort(cancellationReason);
|
|
213
|
+
};
|
|
214
|
+
abortListeners.set(signal, onAbort);
|
|
215
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
216
|
+
if (signal.aborted) onAbort();
|
|
217
|
+
return () => {
|
|
218
|
+
if (abortListeners.get(signal) !== onAbort) return;
|
|
219
|
+
signal.removeEventListener("abort", onAbort);
|
|
220
|
+
abortListeners.delete(signal);
|
|
221
|
+
};
|
|
222
|
+
},
|
|
203
223
|
bindAbortController(ac) {
|
|
204
224
|
knownTurnControllers.add(ac);
|
|
205
225
|
if (!active) {
|
|
206
|
-
ac.abort(new Error("turn already settled"));
|
|
226
|
+
ac.abort(cancellationReason ?? new Error("turn already settled"));
|
|
207
227
|
return;
|
|
208
228
|
}
|
|
209
229
|
transferred = true;
|
|
210
230
|
controllers.add(ac);
|
|
211
231
|
activeTurns.set(ac, lease);
|
|
232
|
+
lease.bindAbortSignal(ac.signal);
|
|
212
233
|
},
|
|
213
234
|
beginCodexAccountSelection() {
|
|
214
235
|
const mainProfileDraining = nativeMainDrainOwners.size > 0;
|
|
215
|
-
let selectionActive = !mainProfileDraining;
|
|
236
|
+
let selectionActive = active && !mainProfileDraining;
|
|
216
237
|
let released = false;
|
|
217
238
|
if (selectionActive) nativeMainSelections += 1;
|
|
218
|
-
|
|
239
|
+
const selection: CodexAccountSelectionAdmission = {
|
|
219
240
|
mainProfileDraining,
|
|
220
241
|
claimMainProfile() {
|
|
221
242
|
if (released || mainProfileDraining || !active) return false;
|
|
@@ -228,18 +249,23 @@ export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
|
|
|
228
249
|
release() {
|
|
229
250
|
if (released) return;
|
|
230
251
|
released = true;
|
|
252
|
+
attachedLeases.delete(selection);
|
|
231
253
|
if (selectionActive) {
|
|
232
254
|
selectionActive = false;
|
|
233
255
|
nativeMainSelections = Math.max(0, nativeMainSelections - 1);
|
|
234
256
|
}
|
|
235
257
|
},
|
|
236
258
|
};
|
|
259
|
+
lease.attach(selection);
|
|
260
|
+
return selection;
|
|
237
261
|
},
|
|
238
262
|
isTransferred() { return transferred; },
|
|
239
263
|
release() {
|
|
240
264
|
if (!active) return;
|
|
241
265
|
active = false;
|
|
242
266
|
admittedTurns.delete(lease);
|
|
267
|
+
for (const [signal, listener] of abortListeners) signal.removeEventListener("abort", listener);
|
|
268
|
+
abortListeners.clear();
|
|
243
269
|
for (const controller of controllers) {
|
|
244
270
|
if (activeTurns.get(controller) === lease) activeTurns.delete(controller);
|
|
245
271
|
}
|
|
@@ -258,6 +284,7 @@ export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
|
|
|
258
284
|
},
|
|
259
285
|
};
|
|
260
286
|
admittedTurns.add(lease);
|
|
287
|
+
if (clientSignal) lease.bindAbortSignal(clientSignal);
|
|
261
288
|
return lease;
|
|
262
289
|
}
|
|
263
290
|
export function codexAccountSelectionForTurn(
|
|
@@ -472,6 +499,7 @@ export async function drainAndShutdown(
|
|
|
472
499
|
// ordinary in-flight turns. A stuck scoped owner must not pin shutdown forever.
|
|
473
500
|
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
474
501
|
beginShutdownDrain();
|
|
502
|
+
runDrainStartHooks();
|
|
475
503
|
const temporaryDrainsSettled = await waitForTemporaryDrainsUntil(deadline);
|
|
476
504
|
if (!temporaryDrainsSettled) {
|
|
477
505
|
console.warn("Temporary drain lease did not settle before the shutdown deadline; forcing shutdown");
|