@bitkyc08/opencodex 2.63.0 → 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-CncKDBTp.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-BqrsSrIR.js +0 -50
- package/gui/dist/assets/index-C6SJrh0N.js +0 -86
- package/gui/dist/assets/index-DdDunwDb.css +0 -1
- package/gui/dist/assets/usage-companion-chart-CzAAAB1o.js +0 -1
- package/src/adapters/kiro-thinking.ts +0 -112
|
@@ -47,6 +47,14 @@ import { fetchQoderModels } from "../../adapters/qoder/live-models";
|
|
|
47
47
|
import { resolveQoderProfile } from "../../adapters/qoder/profiles";
|
|
48
48
|
import { parseAntigravityAvailableModels } from "../../providers/antigravity-models";
|
|
49
49
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
50
|
+
import {
|
|
51
|
+
applyProviderCompatPatchFields,
|
|
52
|
+
carryProviderCompatFields,
|
|
53
|
+
providerCompatFieldConfigError,
|
|
54
|
+
providerOverwriteKeepsDestination,
|
|
55
|
+
sampleProviderOverwrite,
|
|
56
|
+
} from "./provider-overwrite-carry";
|
|
57
|
+
import { shadowInterceptProviderDependency } from "./shadow-call-validation";
|
|
50
58
|
import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive";
|
|
51
59
|
import { initializeProviderModelSelection } from "../../providers/initial-model-selection";
|
|
52
60
|
import { effectiveGoogleMode, providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry";
|
|
@@ -111,7 +119,7 @@ import {
|
|
|
111
119
|
LOCAL_PROVIDER_RELOAD_NAME_HEADER,
|
|
112
120
|
LOCAL_PROVIDER_RELOAD_PATH,
|
|
113
121
|
} from "../../lib/local-provider-reload-contract";
|
|
114
|
-
import {
|
|
122
|
+
import { refreshConfigDerivedRegistries } from "../../config/derived-registries";
|
|
115
123
|
import { redactSecretString } from "../../lib/redact";
|
|
116
124
|
import {
|
|
117
125
|
XAI_RESPONSES_OPT_IN_MODELS,
|
|
@@ -742,6 +750,10 @@ function applyProviderPatchFields(
|
|
|
742
750
|
}
|
|
743
751
|
touched = true;
|
|
744
752
|
}
|
|
753
|
+
// The reasoning-replay lists, foldDeveloperRoleToSystem and reasoningWireFormat (#5563).
|
|
754
|
+
const compat = applyProviderCompatPatchFields(rawBody, next);
|
|
755
|
+
if ("error" in compat) return { error: compat.error };
|
|
756
|
+
if (compat.touched) touched = true;
|
|
745
757
|
|
|
746
758
|
// headers is the one object-valued field in the mask. PATCH semantics merge it
|
|
747
759
|
// shallowly into the existing block so a single fingerprint header can be added
|
|
@@ -1003,7 +1015,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1003
1015
|
reconcileLiveStateStores();
|
|
1004
1016
|
// The complete disk snapshot owns display overlays, including providers that this
|
|
1005
1017
|
// live routing instance deliberately does not adopt.
|
|
1006
|
-
|
|
1018
|
+
refreshConfigDerivedRegistries(currentDiskConfig);
|
|
1007
1019
|
clearGatherRoutedModelsInflight();
|
|
1008
1020
|
(deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
|
|
1009
1021
|
clearAccountQuotaCache(name);
|
|
@@ -1105,7 +1117,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1105
1117
|
|
|
1106
1118
|
adoptProviderEditorCandidate(config, outcome.value.config);
|
|
1107
1119
|
reconcileLiveStateStores();
|
|
1108
|
-
|
|
1120
|
+
refreshConfigDerivedRegistries(outcome.value.config);
|
|
1109
1121
|
clearGatherRoutedModelsInflight();
|
|
1110
1122
|
(deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
|
|
1111
1123
|
clearAccountQuotaCache();
|
|
@@ -1140,7 +1152,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1140
1152
|
const pinError = applyProviderPinFields(transportCandidate as unknown as OcxProviderConfig, body.provider, existing);
|
|
1141
1153
|
if (pinError) return jsonResponse({ error: pinError }, 400);
|
|
1142
1154
|
const providerError = providerManagementConfigError(name, transportCandidate)
|
|
1143
|
-
?? providerEmptyToolOutputConfigError(name, transportCandidate)
|
|
1155
|
+
?? providerEmptyToolOutputConfigError(name, transportCandidate)
|
|
1156
|
+
?? providerCompatFieldConfigError(body.provider as Record<string, unknown>);
|
|
1144
1157
|
if (providerError) return jsonResponse({ error: providerError }, 400);
|
|
1145
1158
|
const rawProvider = body.provider as Record<string, unknown>;
|
|
1146
1159
|
if (rawProvider.upstreamWebsocket !== undefined && typeof rawProvider.upstreamWebsocket !== "boolean") {
|
|
@@ -1195,12 +1208,17 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1195
1208
|
// from "the registry supplied it" either. Without this sample, an unrelated edit that
|
|
1196
1209
|
// omits the key resurrects the registry default over an operator's explicit `false`.
|
|
1197
1210
|
const submittedAnnotateEmptyToolOutputs = Object.hasOwn(prov, "annotateEmptyToolOutputs");
|
|
1211
|
+
// And for the compatibility settings, several of which enrichment fills from the registry
|
|
1212
|
+
// seed (#5563); the sample also records whether the request named an auth mode.
|
|
1213
|
+
const overwriteSample = sampleProviderOverwrite(prov);
|
|
1198
1214
|
enrichProviderFromCatalog(name, prov);
|
|
1199
1215
|
const { saveConfigPreservingClaudeCode: save } = await import("../../config");
|
|
1200
1216
|
// Overwriting an existing provider must not drop its multi-key pool: carry it over, then
|
|
1201
|
-
// let the (possibly new) apiKey join the pool as the active entry.
|
|
1217
|
+
// let the (possibly new) apiKey join the pool as the active entry. Only while the provider
|
|
1218
|
+
// keeps its destination: those keys were issued for the previous upstream.
|
|
1202
1219
|
const existingPool = config.providers[name]?.apiKeyPool;
|
|
1203
|
-
if (existingPool && !prov.apiKeyPool
|
|
1220
|
+
if (existingPool && !prov.apiKeyPool
|
|
1221
|
+
&& providerOverwriteKeepsDestination(prov, config.providers[name], overwriteSample)) prov.apiKeyPool = existingPool;
|
|
1204
1222
|
// The same rule applies to user-configured price overlays: the dashboard's
|
|
1205
1223
|
// add/edit form does not send modelCosts, so an overwrite must not silently
|
|
1206
1224
|
// erase hand-edited per-model prices from Logs/Usage estimates.
|
|
@@ -1259,6 +1277,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1259
1277
|
if (!submittedUpstreamWebsocket && existing?.upstreamWebsocket !== undefined) {
|
|
1260
1278
|
prov.upstreamWebsocket = existing.upstreamWebsocket;
|
|
1261
1279
|
}
|
|
1280
|
+
// The form sends none of the compatibility settings either (#5563). Read the live row rather
|
|
1281
|
+
// than `existing`, like the alias overlays below: a PATCH that saved one of them while DNS
|
|
1282
|
+
// validation awaited must not be undone. Nothing is carried to a new destination.
|
|
1283
|
+
carryProviderCompatFields(prov, config.providers[name], overwriteSample);
|
|
1262
1284
|
if (existing?.modelContextWindows) {
|
|
1263
1285
|
// When the client did send a map, its keys win and the user's other keys survive. When
|
|
1264
1286
|
// it did not, the stored value is the user's map alone: merging the registry seed in
|
|
@@ -1475,6 +1497,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1475
1497
|
// mask onto the newest provider under the mutation lock right before saving, so two
|
|
1476
1498
|
// concurrent PATCHes updating different fields/headers both survive instead of the
|
|
1477
1499
|
// later save clobbering the earlier snapshot.
|
|
1500
|
+
// Read before the save: once the provider is disabled the target no longer resolves (#5618).
|
|
1501
|
+
const shadowDependency = rawBody.disabled === true && config.providers[name]!.disabled !== true
|
|
1502
|
+
? shadowInterceptProviderDependency(config, name)
|
|
1503
|
+
: null;
|
|
1478
1504
|
let replayError: string | undefined;
|
|
1479
1505
|
withConfigMutationLockSync(() => {
|
|
1480
1506
|
const replay = applyProviderPatchFields(name, config.providers[name]!, rawBody, keys, config);
|
|
@@ -1540,6 +1566,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1540
1566
|
name,
|
|
1541
1567
|
disabled: config.providers[name]!.disabled === true,
|
|
1542
1568
|
hasApiKey: !!config.providers[name]!.apiKey,
|
|
1569
|
+
...(shadowDependency ? { dependentShadowIntercept: shadowDependency } : {}),
|
|
1543
1570
|
...(name === "xai"
|
|
1544
1571
|
? { xaiResponsesOptInState: xaiResponsesOptInState(config.providers[name]!) }
|
|
1545
1572
|
: {}),
|
|
@@ -1753,6 +1780,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1753
1780
|
}, 409);
|
|
1754
1781
|
}
|
|
1755
1782
|
const { saveConfigPreservingClaudeCode: save } = await import("../../config");
|
|
1783
|
+
// Deleting still succeeds; the response names the shadow-call target left without a provider.
|
|
1784
|
+
const shadowDependency = shadowInterceptProviderDependency(config, name);
|
|
1756
1785
|
if (fallbackDefault) config.defaultProvider = fallbackDefault;
|
|
1757
1786
|
delete config.providers[name];
|
|
1758
1787
|
const { dropProviderCustomModels } = await import("../../providers/provider-id-rewrite");
|
|
@@ -1768,6 +1797,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1768
1797
|
success: true,
|
|
1769
1798
|
...(fallbackDefault ? { defaultProvider: fallbackDefault } : {}),
|
|
1770
1799
|
...(droppedCustomModels > 0 ? { droppedCustomModels } : {}),
|
|
1800
|
+
...(shadowDependency ? { dependentShadowIntercept: shadowDependency } : {}),
|
|
1771
1801
|
catalogRefresh,
|
|
1772
1802
|
});
|
|
1773
1803
|
}
|
|
@@ -10,9 +10,9 @@ function response(body: unknown, status = 200): Response {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function sessionOnly(ctx: ManagementContext): Response | null {
|
|
13
|
-
return ctx.principal === "gui-session"
|
|
13
|
+
return ctx.principal === "gui-session" && ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true
|
|
14
14
|
? null
|
|
15
|
-
: response({ error: "A dashboard session is required for Remote Workspace changes." }, 403);
|
|
15
|
+
: response({ error: "A paired dashboard session is required for Remote Workspace changes." }, 403);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
async function jsonObject(req: Request): Promise<Record<string, unknown>> {
|
|
@@ -148,6 +148,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
148
148
|
{ method: "POST", path: "/api/grok/apply", module: "server/management/agent-settings-routes", mutates: true },
|
|
149
149
|
{ method: "GET", path: "/api/grok/reset-coupons", module: "server/management/grok-coupon-routes", mutates: false },
|
|
150
150
|
{ method: "POST", path: "/api/grok/reset-coupons/consume", module: "server/management/grok-coupon-routes", mutates: true },
|
|
151
|
+
{ method: "GET", path: "/api/anthropic/reset-grants", module: "server/management/anthropic-reset-grant-routes", mutates: false, exempt: { reason: "deferred-verb", why: "Reading Claude reset grants has no CLI verb yet; the dashboard ticket badge is the only reader in this unit.", owner: "260923_claude_reset_grants wp1", ownerDoc: "devlog/_plan/260923_claude_reset_grants/010_plan.md" } },
|
|
152
|
+
{ method: "POST", path: "/api/anthropic/reset-grants/consume", module: "server/management/anthropic-reset-grant-routes", mutates: true, exempt: { reason: "session-only", why: "Spending a Claude reset grant requires the gui-session principal (anthropic-reset-grant-routes.ts handleConsume); the admin token is refused." } },
|
|
151
153
|
{ method: "PUT", path: "/api/claude-code", module: "server/management/agent-settings-routes", mutates: true },
|
|
152
154
|
{ method: "PUT", path: "/api/claude-desktop", module: "server/management/agent-settings-routes", mutates: true },
|
|
153
155
|
{ method: "PUT", path: "/api/codex-auth/features/default-mode-request-user-input", module: "server/management/agent-settings-routes", mutates: true },
|
|
@@ -13,6 +13,11 @@ export function shadowCallTargetError(config: OcxConfig, targetModel: string | u
|
|
|
13
13
|
} catch {
|
|
14
14
|
return "model must resolve to a configured provider";
|
|
15
15
|
}
|
|
16
|
+
// A qualified target that only the router's last-resort default-provider fallback accepts names
|
|
17
|
+
// nothing configured; saving it would leave the same dead target the request path refuses (#5618).
|
|
18
|
+
if (target.routeKind === "default-provider" && targetModel.includes("/")) {
|
|
19
|
+
return "model must resolve to a configured provider";
|
|
20
|
+
}
|
|
16
21
|
|
|
17
22
|
const intersectsSource = shadowSourceModels(config.shadowCallIntercept?.sourceModels).some(sourceModel => {
|
|
18
23
|
let source = { providerName: OPENAI_CODEX_PROVIDER_ID, modelId: sourceModel };
|
|
@@ -27,3 +32,37 @@ export function shadowCallTargetError(config: OcxConfig, targetModel: string | u
|
|
|
27
32
|
? "shadow-call target must not intersect a source model"
|
|
28
33
|
: null;
|
|
29
34
|
}
|
|
35
|
+
|
|
36
|
+
/** The shadow-call reference a provider change leaves behind, reported by disable and delete. */
|
|
37
|
+
export interface ShadowInterceptDependency {
|
|
38
|
+
model: string;
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether `shadowCallIntercept.model` depends on `providerName` (#5618). Call it before the
|
|
44
|
+
* mutation. A `provider/model` or `alias/model` target naming the provider counts, and so does
|
|
45
|
+
* any other target the router currently resolves to it. Combo and routing-profile targets do not:
|
|
46
|
+
* their pickers already skip a disabled or missing member, and deleting a provider a combo uses is
|
|
47
|
+
* refused outright.
|
|
48
|
+
*/
|
|
49
|
+
export function shadowInterceptProviderDependency(config: OcxConfig, providerName: string): ShadowInterceptDependency | null {
|
|
50
|
+
const shadow = config.shadowCallIntercept;
|
|
51
|
+
const model = shadow?.model?.trim();
|
|
52
|
+
if (!model) return null;
|
|
53
|
+
const dependency = { model, enabled: shadow?.enabled === true };
|
|
54
|
+
const slash = model.indexOf("/");
|
|
55
|
+
if (slash > 0) {
|
|
56
|
+
const prefix = model.slice(0, slash).toLowerCase();
|
|
57
|
+
const alias = config.providers[providerName]?.alias?.trim().toLowerCase();
|
|
58
|
+
if (prefix === providerName.toLowerCase() || (alias && prefix === alias)) return dependency;
|
|
59
|
+
}
|
|
60
|
+
let target;
|
|
61
|
+
try {
|
|
62
|
+
target = routeModel(config, model);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
if (target.routeKind === "combo" || target.routeKind === "policy") return null;
|
|
67
|
+
return target.providerName === providerName ? dependency : null;
|
|
68
|
+
}
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { spawn } from "node:child_process";
|
|
25
25
|
import {
|
|
26
|
+
acquireTemporaryDrain,
|
|
26
27
|
beginShutdownDrain,
|
|
27
28
|
drainAndShutdown,
|
|
28
29
|
getActiveTurnCount,
|
|
@@ -54,6 +55,7 @@ export interface ReplacementReadinessIo {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export interface SystemRestartIo {
|
|
58
|
+
acquireTemporaryDrain?: () => { release(): void } | null;
|
|
57
59
|
drainAndShutdown?: typeof drainAndShutdown;
|
|
58
60
|
/** True when a background service can actually respawn this process after exit(1). */
|
|
59
61
|
isServiceViable?: () => boolean;
|
|
@@ -75,6 +77,13 @@ export interface SystemRestartIo {
|
|
|
75
77
|
now?: () => number;
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
export interface SystemRestartAdmission {
|
|
81
|
+
/** Only the caller that created this pending restart receives its veto. */
|
|
82
|
+
onAccepted?: (veto: () => void) => void;
|
|
83
|
+
/** Recheck external authority before draining or handing off. */
|
|
84
|
+
beforeScheduledDrain?: () => boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
78
87
|
let restartIo: SystemRestartIo = {};
|
|
79
88
|
/** Prevents double-scheduling in the 200ms window before drainAndShutdown sets draining. */
|
|
80
89
|
let restartAccepted = false;
|
|
@@ -135,10 +144,21 @@ function waitForBoundedSettlement(
|
|
|
135
144
|
});
|
|
136
145
|
}
|
|
137
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Set when an operator-initiated shutdown (signal or management stop) begins. An automatic
|
|
149
|
+
* restart that is already draining cannot tell its own listener stop from an independent one,
|
|
150
|
+
* so it consults this instead and never hands off after the process was asked to stop.
|
|
151
|
+
*/
|
|
152
|
+
let explicitShutdownRequested = false;
|
|
153
|
+
export function noteExplicitShutdownRequested(): void {
|
|
154
|
+
explicitShutdownRequested = true;
|
|
155
|
+
}
|
|
156
|
+
|
|
138
157
|
/** Test seam — reset between tests. */
|
|
139
158
|
export function setSystemRestartIoForTests(io: SystemRestartIo = {}): void {
|
|
140
159
|
restartIo = io;
|
|
141
160
|
restartAccepted = false;
|
|
161
|
+
explicitShutdownRequested = false;
|
|
142
162
|
}
|
|
143
163
|
|
|
144
164
|
function resolveListenPort(): number | undefined {
|
|
@@ -297,7 +317,9 @@ async function completeDeferredParentExitHandoff(
|
|
|
297
317
|
exitProcess: (code: number) => void,
|
|
298
318
|
port: number | undefined,
|
|
299
319
|
phase: "deadline" | "listener-stop fallback",
|
|
320
|
+
canHandoff: () => boolean = () => true,
|
|
300
321
|
): Promise<void> {
|
|
322
|
+
if (!canHandoff()) return;
|
|
301
323
|
try {
|
|
302
324
|
await (io.spawnStart ?? spawnDetachedStart)(port, false);
|
|
303
325
|
} catch (err) {
|
|
@@ -317,10 +339,13 @@ async function completeDeadlineRestartHandoff(
|
|
|
317
339
|
exitProcess: (code: number) => void,
|
|
318
340
|
port: number | undefined,
|
|
319
341
|
scheduleDeadline: NonNullable<SystemRestartIo["scheduleDeadline"]>,
|
|
342
|
+
canHandoff: () => boolean = () => true,
|
|
320
343
|
): Promise<void> {
|
|
344
|
+
if (!canHandoff()) return;
|
|
321
345
|
const supervised = (io.isSupervisedServiceChild ?? (() => isSupervisedServiceChild(io)))();
|
|
322
346
|
if (supervised) {
|
|
323
347
|
// Failure-only supervisors ignore exit(0); intentional non-zero triggers respawn.
|
|
348
|
+
if (!canHandoff()) return;
|
|
324
349
|
exitProcess(1);
|
|
325
350
|
return;
|
|
326
351
|
}
|
|
@@ -340,7 +365,7 @@ async function completeDeadlineRestartHandoff(
|
|
|
340
365
|
}
|
|
341
366
|
// The ordinary child must survive parent exit: a failed/pending socket close
|
|
342
367
|
// or overdue cleanup is completed by process teardown, without a hidden mode.
|
|
343
|
-
await completeDeferredParentExitHandoff(io, exitProcess, port, "deadline");
|
|
368
|
+
await completeDeferredParentExitHandoff(io, exitProcess, port, "deadline", canHandoff);
|
|
344
369
|
}
|
|
345
370
|
|
|
346
371
|
/**
|
|
@@ -348,7 +373,7 @@ async function completeDeadlineRestartHandoff(
|
|
|
348
373
|
* respawn runs on a short timer so the HTTP response can flush first.
|
|
349
374
|
* Idempotent while already draining: returns the accepted shape again.
|
|
350
375
|
*/
|
|
351
|
-
export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
|
|
376
|
+
export function acceptSystemRestart(io: SystemRestartIo = restartIo, admission: SystemRestartAdmission = {}): {
|
|
352
377
|
accepted: true;
|
|
353
378
|
alreadyDraining: boolean;
|
|
354
379
|
activeTurnCount: number;
|
|
@@ -363,13 +388,50 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
|
|
|
363
388
|
|
|
364
389
|
if (!alreadyDraining) {
|
|
365
390
|
restartAccepted = true;
|
|
391
|
+
const automatic = Boolean(admission.onAccepted);
|
|
392
|
+
const temporaryDrain = automatic
|
|
393
|
+
? (io.acquireTemporaryDrain ?? (() => acquireTemporaryDrain("automatic-restart")))()
|
|
394
|
+
: null;
|
|
395
|
+
const releasePending = () => {
|
|
396
|
+
temporaryDrain?.release();
|
|
397
|
+
restartAccepted = false;
|
|
398
|
+
};
|
|
399
|
+
if (automatic && !temporaryDrain) {
|
|
400
|
+
restartAccepted = false;
|
|
401
|
+
return { accepted: true, alreadyDraining: true, activeTurnCount, drainTimeoutMs: MEMORY_DRAIN_RESTART_MS };
|
|
402
|
+
}
|
|
403
|
+
let pending = true;
|
|
404
|
+
let vetoed = false;
|
|
405
|
+
admission.onAccepted?.(() => {
|
|
406
|
+
if (!pending) return;
|
|
407
|
+
pending = false;
|
|
408
|
+
vetoed = true;
|
|
409
|
+
releasePending();
|
|
410
|
+
});
|
|
366
411
|
const now = io.now ?? Date.now;
|
|
367
412
|
const restartDeadlineMs = now() + MEMORY_DRAIN_RESTART_MS;
|
|
368
413
|
// Reject new data-plane traffic immediately (503), before the 200ms response-flush delay.
|
|
369
|
-
if (
|
|
370
|
-
|
|
371
|
-
|
|
414
|
+
if (!automatic) {
|
|
415
|
+
if (io.beginShutdownDrain) io.beginShutdownDrain();
|
|
416
|
+
else if (io.setDraining) io.setDraining(true);
|
|
417
|
+
else beginShutdownDrain();
|
|
418
|
+
}
|
|
372
419
|
schedule(async () => {
|
|
420
|
+
if (vetoed) return;
|
|
421
|
+
pending = false;
|
|
422
|
+
const canHandoff = () => {
|
|
423
|
+
// Only admission-bound (automatic) restarts yield to an explicit stop; manual restart
|
|
424
|
+
// requests keep their existing semantics.
|
|
425
|
+
if (admission.onAccepted && explicitShutdownRequested) return false;
|
|
426
|
+
try { return admission.beforeScheduledDrain?.() ?? true; }
|
|
427
|
+
catch { return false; } // Unknown ownership is not authority to restart.
|
|
428
|
+
};
|
|
429
|
+
if (!canHandoff()) { releasePending(); return; }
|
|
430
|
+
if (automatic) {
|
|
431
|
+
if (io.beginShutdownDrain) io.beginShutdownDrain();
|
|
432
|
+
else if (!io.setDraining) beginShutdownDrain();
|
|
433
|
+
temporaryDrain?.release();
|
|
434
|
+
}
|
|
373
435
|
// Preserve the live binding before drainAndShutdown (or its deadline race)
|
|
374
436
|
// closes the listener and makes both the server ref and runtime metadata stale.
|
|
375
437
|
const restartPort = (io.listenPort ?? resolveListenPort)();
|
|
@@ -381,10 +443,11 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
|
|
|
381
443
|
return () => clearTimeout(timer);
|
|
382
444
|
});
|
|
383
445
|
const drainOutcome = await waitForRestartDrain(drainPromise, restartDeadlineMs, now, scheduleDeadline);
|
|
446
|
+
if (!canHandoff()) return;
|
|
384
447
|
const exitProcess = io.exitProcess ?? ((code: number) => { process.exit(code); });
|
|
385
448
|
if (drainOutcome === "deadline") {
|
|
386
449
|
console.warn("Drain-and-restart deadline expired; forcing terminal restart handoff");
|
|
387
|
-
await completeDeadlineRestartHandoff(io, exitProcess, restartPort, scheduleDeadline);
|
|
450
|
+
await completeDeadlineRestartHandoff(io, exitProcess, restartPort, scheduleDeadline, canHandoff);
|
|
388
451
|
return;
|
|
389
452
|
}
|
|
390
453
|
if (drainOutcome === "failed" || drainOutcome === "rejected") {
|
|
@@ -395,6 +458,7 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
|
|
|
395
458
|
const supervised = (io.isSupervisedServiceChild ?? (() => isSupervisedServiceChild(io)))();
|
|
396
459
|
if (supervised) {
|
|
397
460
|
// Failure-only supervisors ignore exit(0); intentional non-zero triggers respawn.
|
|
461
|
+
if (!canHandoff()) return;
|
|
398
462
|
(io.exitProcess ?? ((code: number) => { process.exit(code); }))(1);
|
|
399
463
|
return;
|
|
400
464
|
}
|
|
@@ -407,10 +471,12 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
|
|
|
407
471
|
exitProcess,
|
|
408
472
|
restartPort,
|
|
409
473
|
"listener-stop fallback",
|
|
474
|
+
canHandoff,
|
|
410
475
|
);
|
|
411
476
|
return;
|
|
412
477
|
}
|
|
413
478
|
try {
|
|
479
|
+
if (!canHandoff()) return;
|
|
414
480
|
// A rejected drain has uncertain cleanup ownership, so it uses the same
|
|
415
481
|
// parent-exit handoff as a deadline. Only a fully completed drain waits
|
|
416
482
|
// for replacement health in the old process.
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
import type { OcxClaudeCodeConfig, OcxClaudeDesktopProfile, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../types";
|
|
51
51
|
import type { DesktopProfileModel } from "../claude/desktop-profile";
|
|
52
52
|
import { drainAndShutdown } from "./lifecycle";
|
|
53
|
+
import { noteExplicitShutdownRequested } from "./management/system-restart";
|
|
53
54
|
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "./request-log";
|
|
54
55
|
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../usage/cost";
|
|
55
56
|
import type { PersistedUsageAttempt } from "../usage/log";
|
|
@@ -159,6 +160,12 @@ async function handleGrokCouponRoutesOnDemand(ctx: ManagementContext): Promise<R
|
|
|
159
160
|
return handleGrokCouponRoutes(ctx);
|
|
160
161
|
}
|
|
161
162
|
|
|
163
|
+
async function handleAnthropicResetGrantRoutesOnDemand(ctx: ManagementContext): Promise<Response | null> {
|
|
164
|
+
if (!pathInManagementNamespace(ctx.url.pathname, "/api/anthropic/reset-grants", true)) return null;
|
|
165
|
+
const { handleAnthropicResetGrantRoutes } = await import("./management/anthropic-reset-grant-routes");
|
|
166
|
+
return handleAnthropicResetGrantRoutes(ctx);
|
|
167
|
+
}
|
|
168
|
+
|
|
162
169
|
async function handleRemoteWorkspaceRoutesOnDemand(ctx: ManagementContext): Promise<Response | null> {
|
|
163
170
|
if (!pathInManagementNamespace(ctx.url.pathname, "/api/remote-workspace")) return null;
|
|
164
171
|
if (!remoteWorkspaceEnabled(ctx.config)) {
|
|
@@ -166,8 +173,11 @@ async function handleRemoteWorkspaceRoutesOnDemand(ctx: ManagementContext): Prom
|
|
|
166
173
|
status: ctx.req.method === "GET" ? 200 : 404, headers: { "cache-control": "no-store" },
|
|
167
174
|
});
|
|
168
175
|
}
|
|
169
|
-
if (ctx.req.method !== "GET" &&
|
|
170
|
-
|
|
176
|
+
if (ctx.req.method !== "GET" && (
|
|
177
|
+
ctx.principal !== "gui-session"
|
|
178
|
+
|| ctx.sessionControl?.isPaired(ctx.req, ctx.config) !== true
|
|
179
|
+
)) {
|
|
180
|
+
return Response.json({ error: "A paired dashboard session is required for Remote Workspace changes." }, { status: 403 });
|
|
171
181
|
}
|
|
172
182
|
const { handleRemoteWorkspaceRoutes } = await import("./management/remote-workspace-routes");
|
|
173
183
|
return handleRemoteWorkspaceRoutes(ctx);
|
|
@@ -274,6 +284,7 @@ export async function handleManagementAPI(
|
|
|
274
284
|
?? (await handleQuotaResetRoutesOnDemand(ctx))
|
|
275
285
|
?? (await handleWorkflowBudgetRoutesOnDemand(ctx))
|
|
276
286
|
?? (await handleGrokCouponRoutesOnDemand(ctx))
|
|
287
|
+
?? (await handleAnthropicResetGrantRoutesOnDemand(ctx))
|
|
277
288
|
?? handleMetricsRoutes(ctx)
|
|
278
289
|
?? (await handleRoutingAnalyticsRoutes(ctx))
|
|
279
290
|
?? (await handleRoutingProfileRoutesOnDemand(ctx))
|
|
@@ -395,6 +406,9 @@ export async function handleManagementAPI(
|
|
|
395
406
|
// syncCleanup skips this when OCX_SERVICE is set (so a crash/respawn keeps the fence),
|
|
396
407
|
// which is exactly why an intentional stop has to do it here — unless the caller is
|
|
397
408
|
// `ocx stop`, which does it itself once the proxy is proven down.
|
|
409
|
+
// Mark the stop before the first await after acceptance, so an automatic restart draining
|
|
410
|
+
// concurrently cannot reach its handoff while teardown is still pending.
|
|
411
|
+
noteExplicitShutdownRequested();
|
|
398
412
|
const teardown = await performStopTeardown(url, { ownsReceipt: deferralMatchesReceipt });
|
|
399
413
|
setTimeout(async () => {
|
|
400
414
|
let shutdownSucceeded = false;
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
LOCAL_ASIDE_SYNC_CAPABILITY_HEADER,
|
|
4
|
+
LOCAL_ASIDE_SYNC_EXPECTED_PID_HEADER,
|
|
5
|
+
LOCAL_ASIDE_SYNC_EXPIRES_AT_HEADER,
|
|
6
|
+
LOCAL_ASIDE_SYNC_NONCE_HEADER,
|
|
7
|
+
LOCAL_ASIDE_SYNC_PATH,
|
|
8
|
+
parseExpectedLocalAsideSyncPid,
|
|
9
|
+
verifyLocalAsideSyncCapability,
|
|
10
|
+
} from "../lib/local-aside-sync-contract";
|
|
2
11
|
import {
|
|
3
12
|
chmodSync,
|
|
4
13
|
closeSync,
|
|
@@ -70,6 +79,8 @@ const admittedLocalReadRequests = new WeakSet<Request>();
|
|
|
70
79
|
const LOCAL_PROVIDER_RELOAD_REPLAY_LIMIT = 256;
|
|
71
80
|
const consumedLocalProviderReloadCapabilities = new Map<string, number>();
|
|
72
81
|
const admittedLocalProviderReloadRequests = new WeakSet<Request>();
|
|
82
|
+
const consumedLocalAsideSyncCapabilities = new Map<string, number>();
|
|
83
|
+
const admittedLocalAsideSyncRequests = new WeakSet<Request>();
|
|
73
84
|
const GUI_PAIR_REPLAY_LIMIT = 256;
|
|
74
85
|
const consumedGuiPairCapabilities = new Map<string, number>();
|
|
75
86
|
const admittedGuiPairRequests = new WeakSet<Request>();
|
|
@@ -253,23 +264,35 @@ export interface ManagementSessionControl {
|
|
|
253
264
|
revokeCurrent(req: Request): boolean;
|
|
254
265
|
/** Revalidate a long-lived request against current authority, without cached admission or renewal. */
|
|
255
266
|
isCurrent(req: Request, config: OcxConfig): boolean;
|
|
267
|
+
/** Prove that the current browser session came from the operator-mediated pairing flow. */
|
|
268
|
+
isPaired(req: Request, config: OcxConfig): boolean;
|
|
256
269
|
}
|
|
257
270
|
|
|
258
271
|
export function createManagementSessionControl(state: ManagementAuthState): ManagementSessionControl {
|
|
272
|
+
function currentSession(req: Request, config: OcxConfig): GuiSessionRecord | null {
|
|
273
|
+
if (!state.available) return null;
|
|
274
|
+
const adminToken = state.token;
|
|
275
|
+
const credential = requestManagementCredential(req);
|
|
276
|
+
if (!credential || equalSecret(credential, adminToken)) return null;
|
|
277
|
+
const session = state.sessions.get(credential);
|
|
278
|
+
if (!session) return null;
|
|
279
|
+
// Reuse the full origin/expiry/CSRF predicate against the current record, but
|
|
280
|
+
// isolate its sliding-expiry mutation: authority checks are not browser activity.
|
|
281
|
+
return authorizeGuiSessionRequest(req, config, {
|
|
282
|
+
sessions: new Map([[credential, { ...session }]]),
|
|
283
|
+
pairingGrants: state.pairingGrants,
|
|
284
|
+
}).ok ? session : null;
|
|
285
|
+
}
|
|
259
286
|
return {
|
|
260
287
|
isCurrent(req: Request, config: OcxConfig): boolean {
|
|
261
288
|
if (!state.available) return false;
|
|
262
289
|
const credential = requestManagementCredential(req);
|
|
263
290
|
if (!credential) return false;
|
|
264
291
|
if (equalSecret(credential, state.token)) return true;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return authorizeGuiSessionRequest(req, config, {
|
|
270
|
-
sessions: new Map([[credential, { ...session }]]),
|
|
271
|
-
pairingGrants: state.pairingGrants,
|
|
272
|
-
}).ok;
|
|
292
|
+
return currentSession(req, config) !== null;
|
|
293
|
+
},
|
|
294
|
+
isPaired(req: Request, config: OcxConfig): boolean {
|
|
295
|
+
return currentSession(req, config)?.issuance === "pairing";
|
|
273
296
|
},
|
|
274
297
|
revokeCurrent(req: Request): boolean {
|
|
275
298
|
if (!state.available) return false;
|
|
@@ -302,6 +325,7 @@ export type ManagementPrincipal =
|
|
|
302
325
|
| "gui-pair-capability"
|
|
303
326
|
| "local-read-capability"
|
|
304
327
|
| "local-provider-reload-capability"
|
|
328
|
+
| "local-aside-sync-capability"
|
|
305
329
|
| "system-restart-capability";
|
|
306
330
|
|
|
307
331
|
export interface LocalManagementAuthContext {
|
|
@@ -432,6 +456,25 @@ function hasLocalProviderReloadCapability(
|
|
|
432
456
|
return true;
|
|
433
457
|
}
|
|
434
458
|
|
|
459
|
+
function hasLocalAsideSyncCapability(req: Request, local: LocalManagementAuthContext | undefined): boolean {
|
|
460
|
+
if (admittedLocalAsideSyncRequests.has(req)) return true;
|
|
461
|
+
if (!local || req.method !== "POST") return false;
|
|
462
|
+
let url: URL;
|
|
463
|
+
try { url = new URL(req.url); } catch { return false; }
|
|
464
|
+
if (url.pathname !== LOCAL_ASIDE_SYNC_PATH || url.search !== "") return false;
|
|
465
|
+
if (parseExpectedLocalAsideSyncPid(req.headers.get(LOCAL_ASIDE_SYNC_EXPECTED_PID_HEADER)) !== local.pid) return false;
|
|
466
|
+
const expiresAt = Number(req.headers.get(LOCAL_ASIDE_SYNC_EXPIRES_AT_HEADER));
|
|
467
|
+
if (!Number.isSafeInteger(expiresAt)) return false;
|
|
468
|
+
const capability = req.headers.get(LOCAL_ASIDE_SYNC_CAPABILITY_HEADER);
|
|
469
|
+
const now = Date.now();
|
|
470
|
+
if (!verifyLocalAsideSyncCapability(local.attestationSecret, req.headers.get(LOCAL_ASIDE_SYNC_NONCE_HEADER), req.method, url.pathname, local.pid, local.port, expiresAt, capability, now)) return false;
|
|
471
|
+
for (const [used, until] of consumedLocalAsideSyncCapabilities) if (until <= now) consumedLocalAsideSyncCapabilities.delete(used);
|
|
472
|
+
if (!capability || consumedLocalAsideSyncCapabilities.has(capability) || consumedLocalAsideSyncCapabilities.size >= 256) return false;
|
|
473
|
+
consumedLocalAsideSyncCapabilities.set(capability, expiresAt);
|
|
474
|
+
admittedLocalAsideSyncRequests.add(req);
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
|
|
435
478
|
function hasGuiPairCapability(
|
|
436
479
|
req: Request,
|
|
437
480
|
local: LocalManagementAuthContext | undefined,
|
|
@@ -505,6 +548,7 @@ function resolveManagementAdmission(
|
|
|
505
548
|
if (cached) return cached;
|
|
506
549
|
let principal: ManagementPrincipal | null = null;
|
|
507
550
|
if (hasSystemRestartCapability(req, local)) principal = "system-restart-capability";
|
|
551
|
+
else if (hasLocalAsideSyncCapability(req, local)) principal = "local-aside-sync-capability";
|
|
508
552
|
else if (hasLocalProviderReloadCapability(req, local)) principal = "local-provider-reload-capability";
|
|
509
553
|
else if (hasLocalReadCapability(req, local)) principal = "local-read-capability";
|
|
510
554
|
else if (hasGuiPairCapability(req, local)) principal = "gui-pair-capability";
|