@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922
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 +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-E64Rzjap.js +50 -0
- package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
- package/gui/dist/assets/index-DpdfZWMK.js +86 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +8 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +20 -1
- package/src/adapters/base.ts +23 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +32 -4
- package/src/adapters/command-code.ts +52 -4
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +20 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +14 -3
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
- package/src/adapters/devin.ts +125 -36
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +34 -7
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google-http.ts +49 -10
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +66 -11
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +23 -58
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +17 -3
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +13 -151
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/account-extended.ts +4 -4
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +46 -7
- package/src/cli/doctor.ts +28 -9
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +203 -59
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-api.ts +2 -2
- package/src/client/machine-listener.ts +4 -7
- package/src/client/runtime.ts +26 -2
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/account-store.ts +65 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/auth-api/pool-quota-probe.ts +30 -7
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/gather-capture.ts +21 -2
- package/src/codex/catalog/model-hints.ts +29 -28
- package/src/codex/catalog/parsing.ts +7 -0
- package/src/codex/catalog/provider-models.ts +43 -17
- package/src/codex/catalog/retained-sync.ts +24 -28
- package/src/codex/catalog/routed-gather.ts +19 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +31 -166
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +99 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +116 -54
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/observed-model-denials.ts +101 -8
- package/src/codex/prompt-text-probe.ts +9 -6
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +12 -42
- package/src/codex/shim-templates.ts +29 -3
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +22 -4
- package/src/combos/failover.ts +3 -0
- package/src/companion/settings.ts +132 -0
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/diagnostics.ts +22 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +52 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/proxy-env.ts +8 -2
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +31 -1
- package/src/config/schema/leaf-validators.ts +59 -0
- package/src/config.ts +3 -3
- package/src/generated/compatibility-version.json +604 -280
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +921 -0
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +134 -110
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +63 -0
- package/src/lib/errors.ts +79 -0
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +109 -16
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/socks5-fetch.ts +136 -26
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +218 -27
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +1 -0
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +27 -108
- package/src/oauth/login-cli.ts +80 -29
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/key-failover.ts +97 -19
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +179 -38
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/vendor-probes-key.ts +38 -23
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +47 -20
- package/src/providers/registry/entries-extended.ts +63 -4
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +56 -10
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/apply-patch-envelope.ts +0 -12
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +3 -0
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +108 -117
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +60 -4
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +61 -20
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +142 -39
- package/src/server/index/spend-ledger-lifecycle.ts +92 -0
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +34 -38
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/config-routes.ts +18 -2
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +20 -0
- package/src/server/management/shared.ts +28 -4
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +10 -9
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +138 -12
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +169 -2
- package/src/server/request-metrics.ts +298 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +11 -6
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +43 -10
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +8 -3
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +6 -12
- package/src/server/responses/core-opaque-recovery.ts +91 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +45 -15
- package/src/server/responses/passthrough-dispatch.ts +215 -44
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +109 -18
- package/src/server/responses/request-send-budget.ts +17 -1
- package/src/server/responses/request-sidecar-auth.ts +1 -1
- package/src/server/responses/request-transport.ts +26 -6
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses/run-turn-execution.ts +25 -3
- package/src/server/responses/sidecar-execution.ts +17 -2
- package/src/server/responses/ws-upstream.ts +14 -27
- package/src/server/responses-custom-tool-repair.ts +27 -54
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +35 -2
- package/src/server/search.ts +25 -1
- package/src/server/sse-payload-rewrite.ts +1 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +156 -4
- package/src/types/config.ts +48 -3
- package/src/types/provider.ts +91 -0
- package/src/types/request.ts +30 -2
- package/src/types/tools.ts +33 -0
- package/src/types.ts +4 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/alpha-search.ts +21 -1
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/gui/dist/assets/index-C5IebErG.js +0 -136
- package/gui/dist/assets/index-OESInAjC.css +0 -1
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* Bodies are unchanged from their previous home; only `export` was added.
|
|
10
10
|
*/
|
|
11
11
|
import type { CatalogModel } from "../../codex/catalog";
|
|
12
|
+
import { observeModelCacheRevision } from "../../codex/model-cache";
|
|
13
|
+
import {
|
|
14
|
+
captureExportConfigAdmission,
|
|
15
|
+
isExportConfigAdmissionCurrent,
|
|
16
|
+
type ExportConfigAdmission,
|
|
17
|
+
} from "../../config/admitted-identity";
|
|
12
18
|
import {
|
|
13
19
|
catalogModelSlug,
|
|
14
20
|
filterCatalogVisibleModels,
|
|
@@ -28,7 +34,7 @@ import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
|
28
34
|
import type { OcxConfig } from "../../types";
|
|
29
35
|
import { ensureCodexEntitlementFreshness } from "../../codex/model-entitlements";
|
|
30
36
|
import { fetchAllModels } from "./shared";
|
|
31
|
-
import { initialModelSelectionPending } from "../../providers/initial-model-selection";
|
|
37
|
+
import { initialModelSelectionPending, pendingModelSelectionProviders } from "../../providers/initial-model-selection";
|
|
32
38
|
import { catalogFastRowEligible, fastRowId } from "../fast-row";
|
|
33
39
|
import { knownEffortRowIds } from "../effort-row";
|
|
34
40
|
|
|
@@ -77,14 +83,29 @@ export function effectiveManagementDisplayName(
|
|
|
77
83
|
*/
|
|
78
84
|
export async function listManagementModelRows(
|
|
79
85
|
config: OcxConfig,
|
|
80
|
-
options: {
|
|
86
|
+
options: {
|
|
87
|
+
entitlementWaitMs?: number;
|
|
88
|
+
models?: readonly CatalogModel[];
|
|
89
|
+
/** Filled with each provider's content revision as of the moment its rows were chosen. */
|
|
90
|
+
providerContentRevisions?: Map<string, string>;
|
|
91
|
+
} = {},
|
|
81
92
|
): Promise<ManagementModelRow[]> {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
93
|
+
/*
|
|
94
|
+
* A supplied roster skips the gather, and that is the point rather than an optimization.
|
|
95
|
+
* `fetchAllModels` reaches providers and can persist an initial model selection, which a
|
|
96
|
+
* read-only caller must not do. Everything below this line is the projection — the disabled
|
|
97
|
+
* computation, native and account-bound rows, custom rows and the public list — so a caller
|
|
98
|
+
* that brings its own roster still sees exactly what a writer would, and the two cannot
|
|
99
|
+
* disagree about the roster for any reason except the roster itself.
|
|
100
|
+
*/
|
|
101
|
+
const models = options.models === undefined
|
|
102
|
+
? (await Promise.all([
|
|
103
|
+
fetchAllModels(config, options.providerContentRevisions),
|
|
104
|
+
ensureCodexEntitlementFreshness(config, {
|
|
105
|
+
waitMs: options.entitlementWaitMs ?? 3_000,
|
|
106
|
+
}),
|
|
107
|
+
]))[0]
|
|
108
|
+
: [...options.models];
|
|
88
109
|
const disabled = new Set(config.disabledModels ?? []);
|
|
89
110
|
// Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
|
|
90
111
|
// from the static supported set so a disabled model stays listed and re-enableable.
|
|
@@ -236,10 +257,201 @@ export function toExportModel(row: ManagementModelRow): ExportModel {
|
|
|
236
257
|
* tab is absent from `/v1/models` and exporting it would hand the client a
|
|
237
258
|
* selector the proxy refuses to route.
|
|
238
259
|
*/
|
|
239
|
-
export async function loadExportModels(
|
|
240
|
-
|
|
260
|
+
export async function loadExportModels(
|
|
261
|
+
config: OcxConfig,
|
|
262
|
+
models?: readonly CatalogModel[],
|
|
263
|
+
): Promise<ExportModel[]> {
|
|
264
|
+
// Initial selection adopts into the live configuration and persists it, so it has to finish
|
|
265
|
+
// before anything is admitted. Admitting first would bind this roster to bytes the same load is
|
|
266
|
+
// about to rewrite, and finalizing against a detached copy would adopt the choices into the copy
|
|
267
|
+
// while leaving the live configuration pending.
|
|
268
|
+
if (models === undefined && pendingModelSelectionProviders(config).size > 0) {
|
|
269
|
+
const { resolvePendingInitialModelSelection } = await import("../../providers/initial-model-selection-runtime");
|
|
270
|
+
await resolvePendingInitialModelSelection(config);
|
|
271
|
+
}
|
|
272
|
+
// The configuration this pass will use from beginning to end, proved to be the one on disk.
|
|
273
|
+
// Without it there is nothing that may be retained, and the caller still gets its rows: only the
|
|
274
|
+
// preview authority is withheld.
|
|
275
|
+
const admission = captureExportConfigAdmission(config);
|
|
276
|
+
const admitted = admission?.config ?? config;
|
|
277
|
+
// The gather stamps each provider as it chooses its rows, so the roster and the revisions that
|
|
278
|
+
// vouch for it come from the same moment. Sampling afterwards would let a concurrent flight's
|
|
279
|
+
// publication be recorded against rows it never produced.
|
|
280
|
+
const gathered = new Map<string, string>();
|
|
281
|
+
// Gathering here rather than through the shared fetch is what keeps the detached copy out of the
|
|
282
|
+
// initial-selection finalizer: the projection below takes a roster, and that branch performs no
|
|
283
|
+
// discovery and no configuration write. The entitlement refresh keeps the budget it has always
|
|
284
|
+
// had, and runs alongside as it did inside the projection.
|
|
285
|
+
const roster = models === undefined
|
|
286
|
+
? (await Promise.all([
|
|
287
|
+
(await import("../../codex/catalog")).gatherRoutedModels(admitted, { providerContentRevisions: gathered }),
|
|
288
|
+
ensureCodexEntitlementFreshness(admitted, { waitMs: 3_000 }),
|
|
289
|
+
]))[0]
|
|
290
|
+
: models;
|
|
291
|
+
const rows = await listManagementModelRows(admitted, { models: roster });
|
|
241
292
|
// Management deliberately lists the full roster so hidden models can be enabled.
|
|
242
293
|
// A client picker must also honor the provider selection, not just its blocklist.
|
|
243
|
-
const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native),
|
|
244
|
-
|
|
294
|
+
const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native), admitted));
|
|
295
|
+
const exported = rows.filter(row => !row.disabled && (row.native || visibleRouted.has(row))).map(toExportModel);
|
|
296
|
+
// Retain the FINAL projection, not an input to it. A preview that rebuilt from raw provider
|
|
297
|
+
// caches would miss static and forward providers, which never populate one, and would skip the
|
|
298
|
+
// retention, metadata, combo and filtering this function applies afterwards.
|
|
299
|
+
// A deep clone, not a frozen view of the caller's array. Freezing the array alone left the model
|
|
300
|
+
// objects shared, so a caller mutating one in place would have silently rewritten the roster a
|
|
301
|
+
// later preview plans against, and the fingerprint would have moved with it.
|
|
302
|
+
// Still the configuration these rows were chosen under, on disk and in hand alike. Revalidating
|
|
303
|
+
// rather than re-reading an identity is what makes this fail closed: a configuration that moved
|
|
304
|
+
// during the load leaves no snapshot rather than one recorded under a state its rows never had.
|
|
305
|
+
if (admission === null || !isExportConfigAdmissionCurrent(admission, config)) {
|
|
306
|
+
lastExportSnapshot = null;
|
|
307
|
+
return exported;
|
|
308
|
+
}
|
|
309
|
+
// Prefer the revisions the gather stamped; fall back to observing only when the roster was
|
|
310
|
+
// supplied and no gather happened, where there is nothing tighter to use.
|
|
311
|
+
const cacheStamp = gathered.size > 0 ? stampFrom(admitted, gathered) : modelCacheStamp(admitted);
|
|
312
|
+
const retained = lastExportSnapshot;
|
|
313
|
+
/*
|
|
314
|
+
* An identical roster keeps the identity it already had.
|
|
315
|
+
*
|
|
316
|
+
* The generation moved on every load, so an ordinary read that rebuilt the same rows, which the
|
|
317
|
+
* Integrations collection does, invalidated a confirmation an operator was in the middle of
|
|
318
|
+
* submitting. Nothing about the roster had changed; only the counter had. The rows themselves
|
|
319
|
+
* are compared rather than assumed equal from the configuration and the cache stamp, because a
|
|
320
|
+
* projection also reads entitlement state neither of those two describes.
|
|
321
|
+
*/
|
|
322
|
+
const projection = Object.freeze(structuredClone(exported));
|
|
323
|
+
if (retained !== null
|
|
324
|
+
&& retained.cacheStamp === cacheStamp
|
|
325
|
+
&& isExportConfigAdmissionCurrent(retained.admission, config)
|
|
326
|
+
&& JSON.stringify(retained.models) === JSON.stringify(projection)) {
|
|
327
|
+
return exported;
|
|
328
|
+
}
|
|
329
|
+
lastExportSnapshot = { admission, cacheStamp, generation: ++exportSnapshotGeneration, models: projection };
|
|
330
|
+
return exported;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The completed export roster from the last ordinary load, if it still describes this config.
|
|
335
|
+
*
|
|
336
|
+
* A preview may not gather, so it reads only what an authoritative load already finished. The
|
|
337
|
+
* admission it carries proved, when the roster was built, that the configuration in hand was the
|
|
338
|
+
* one on disk; a later read repeats that proof, so a rewritten file, an edited resident object or
|
|
339
|
+
* a mutated working copy each retire the snapshot rather than letting a preview plan against a
|
|
340
|
+
* configuration nobody has.
|
|
341
|
+
*
|
|
342
|
+
* A cold process has no snapshot and the caller answers a bounded refusal, and an ordinary load
|
|
343
|
+
* populates one: the Integrations collection read calls `loadExportModels`, so the page an
|
|
344
|
+
* operator opens before confirming anything is usually the page that fills this in. That is not a
|
|
345
|
+
* repair for every refusal. A configuration that disagrees with its file keeps refusing however
|
|
346
|
+
* many times the page is opened, because nothing here reloads or reconciles anything; once the
|
|
347
|
+
* two agree again the next ordinary read rebuilds the snapshot by itself.
|
|
348
|
+
*/
|
|
349
|
+
let lastExportSnapshot:
|
|
350
|
+
| { admission: ExportConfigAdmission; cacheStamp: string; generation: number; models: readonly ExportModel[] }
|
|
351
|
+
| null = null;
|
|
352
|
+
let exportSnapshotGeneration = 0;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A process-local prefix for the roster identity a caller carries between a preview and the
|
|
356
|
+
* mutation that confirms it.
|
|
357
|
+
*
|
|
358
|
+
* The identity used to be the configuration digest with a counter appended, which handed a
|
|
359
|
+
* dashboard an opaque-looking string that was in fact a fingerprint of the operator's
|
|
360
|
+
* configuration file. It only has to be unforgeable within this process and distinct across
|
|
361
|
+
* restarts, so it says nothing about the configuration at all.
|
|
362
|
+
*/
|
|
363
|
+
const rosterIdentityPrefix = `r${Math.trunc(Math.random() * 0xffffffff).toString(36)}`;
|
|
364
|
+
|
|
365
|
+
function rosterIdentity(generation: number): string {
|
|
366
|
+
return `${rosterIdentityPrefix}:${generation}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Where the gathered half of the roster stands, observed without changing it.
|
|
371
|
+
*
|
|
372
|
+
* The config key cannot see a provider's models changing underneath an unchanged configuration,
|
|
373
|
+
* which is exactly what discovery does. This reads the cache's own generation for each configured
|
|
374
|
+
* provider through the passive observer, so a completed discovery retires the snapshot and a
|
|
375
|
+
* preview stops planning against a roster that no longer reflects the provider.
|
|
376
|
+
*/
|
|
377
|
+
function modelCacheStamp(config: OcxConfig): string {
|
|
378
|
+
return Object.keys(config.providers ?? {})
|
|
379
|
+
.sort()
|
|
380
|
+
.map(provider => `${provider}=${observeModelCacheRevision(provider)}`)
|
|
381
|
+
.join(",");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The same stamp shape, built from revisions the gather recorded rather than from observation.
|
|
386
|
+
*
|
|
387
|
+
* A provider the gather did not report falls back to observation so the stamp stays total; that
|
|
388
|
+
* happens for a provider configured after the rows were chosen, and it retires the snapshot on
|
|
389
|
+
* the next read rather than pretending the roster covered it.
|
|
390
|
+
*/
|
|
391
|
+
function stampFrom(config: OcxConfig, gathered: ReadonlyMap<string, string>): string {
|
|
392
|
+
return Object.keys(config.providers ?? {})
|
|
393
|
+
.sort()
|
|
394
|
+
.map(provider => `${provider}=${gathered.get(provider) ?? observeModelCacheRevision(provider)}`)
|
|
395
|
+
.join(",");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Opaque identity of the snapshot a caller is holding, or null when there is none for this config.
|
|
400
|
+
*
|
|
401
|
+
* A fingerprint check that rebuilt its own roster could validate against one snapshot while the
|
|
402
|
+
* mutation wrote from another, because an ordinary load can replace the snapshot at any moment and
|
|
403
|
+
* nothing about that is serialised against the writer lock. Carrying this identity alongside the
|
|
404
|
+
* captured roster lets a revalidation prove the snapshot it captured is still the current one
|
|
405
|
+
* without ever swapping the roster the mutation is about to use.
|
|
406
|
+
*/
|
|
407
|
+
export function exportSnapshotIdentity(config: OcxConfig): string | null {
|
|
408
|
+
const snapshot = lastExportSnapshot;
|
|
409
|
+
if (snapshot === null) return null;
|
|
410
|
+
if (!isExportConfigAdmissionCurrent(snapshot.admission, config)) return null;
|
|
411
|
+
if (snapshot.cacheStamp !== modelCacheStamp(config)) return null;
|
|
412
|
+
return rosterIdentity(snapshot.generation);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Test seam: a fresh process has no snapshot, and suites must be able to reproduce that. */
|
|
416
|
+
export function resetExportSnapshotForTests(): void {
|
|
417
|
+
lastExportSnapshot = null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The export roster for a read that must change nothing at all, or null when there is not one.
|
|
422
|
+
*
|
|
423
|
+
* Skipping the initial-selection finalizer was not enough. Discovery itself refreshes credentials
|
|
424
|
+
* and writes the provider model cache, so a preview that gathered would still be a write dressed
|
|
425
|
+
* as a read, and "the models list already does this" describes what a GET happens to do rather
|
|
426
|
+
* than what a preview is allowed to do.
|
|
427
|
+
*
|
|
428
|
+
* So this reads already-captured per-provider cache entries and never fetches. When no provider
|
|
429
|
+
* has a cached roster there is no honest snapshot to plan against, and the caller reports a
|
|
430
|
+
* bounded refusal rather than triggering a gather to manufacture one.
|
|
431
|
+
*/
|
|
432
|
+
export function previewExportSnapshot(
|
|
433
|
+
config: OcxConfig,
|
|
434
|
+
): { models: readonly ExportModel[]; identity: string } | null {
|
|
435
|
+
// One synchronous read of one const. Taking the roster and its identity in two steps let a
|
|
436
|
+
// concurrent load publish a new snapshot between them, so a caller could hold one roster while
|
|
437
|
+
// believing it held the identity of another.
|
|
438
|
+
const snapshot = lastExportSnapshot;
|
|
439
|
+
if (snapshot === null) return null;
|
|
440
|
+
// The roster was built from a configuration proved to be the one on disk; this asks whether both
|
|
441
|
+
// are still that same configuration, and reads nothing but the file to answer.
|
|
442
|
+
if (!isExportConfigAdmissionCurrent(snapshot.admission, config)) return null;
|
|
443
|
+
// A completed discovery retires the snapshot: the configuration is unchanged, but the models it
|
|
444
|
+
// resolves to are not the ones this roster was built from.
|
|
445
|
+
if (snapshot.cacheStamp !== modelCacheStamp(config)) return null;
|
|
446
|
+
// Cloned on the way out as well as on the way in. The retained copy is the authority, and a
|
|
447
|
+
// reader holding its objects could edit the roster every later preview plans against without
|
|
448
|
+
// going anywhere near this module.
|
|
449
|
+
return {
|
|
450
|
+
models: structuredClone(snapshot.models) as readonly ExportModel[],
|
|
451
|
+
identity: rosterIdentity(snapshot.generation),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function previewExportModels(config: OcxConfig): readonly ExportModel[] | null {
|
|
456
|
+
return previewExportSnapshot(config)?.models ?? null;
|
|
245
457
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state";
|
|
1
2
|
/**
|
|
2
3
|
* Toggle routes for the integrations that are NOT file-merged clients.
|
|
3
4
|
*
|
|
@@ -18,13 +19,22 @@
|
|
|
18
19
|
* 011 (Claude Code), 012 (Grok).
|
|
19
20
|
*/
|
|
20
21
|
import { join } from "node:path";
|
|
21
|
-
import { loadConfig, saveConfigPreservingClaudeCode } from "../../config";
|
|
22
|
+
import { loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../../config";
|
|
22
23
|
import { readRuntimePort } from "../../config/process-state";
|
|
23
24
|
import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits } from "../../codex/catalog";
|
|
24
25
|
import { getCodexHome } from "../../codex/paths";
|
|
25
26
|
import { providerContextCap } from "../../providers/context-cap";
|
|
26
27
|
import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
27
28
|
import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p";
|
|
29
|
+
import {
|
|
30
|
+
applyDesktopFirstParty,
|
|
31
|
+
captureDesktopFirstPartyRollback,
|
|
32
|
+
inspectDesktopFirstParty,
|
|
33
|
+
recordClaudeDesktopMode,
|
|
34
|
+
removeDesktopFirstParty,
|
|
35
|
+
resolveClaudeDesktopApplyMode,
|
|
36
|
+
type ClaudeDesktopMode,
|
|
37
|
+
} from "../../claude/desktop-first-party";
|
|
28
38
|
import { projectGrokCatalog } from "../../grok/catalog";
|
|
29
39
|
import { injectGrokConfig, stripGrokConfig } from "../../grok/inject";
|
|
30
40
|
import { inspectGrokConfig } from "../../grok/inspect";
|
|
@@ -47,7 +57,9 @@ export type NativeRefusalReason =
|
|
|
47
57
|
| "write_failed"
|
|
48
58
|
| "metadata_unreadable"
|
|
49
59
|
| "cleanup_incomplete"
|
|
50
|
-
| "desired_state_changed"
|
|
60
|
+
| "desired_state_changed"
|
|
61
|
+
| "foreign_env"
|
|
62
|
+
| "intercept_disabled";
|
|
51
63
|
|
|
52
64
|
export interface NativeStatus {
|
|
53
65
|
clientId: NativeIntegrationClientId;
|
|
@@ -126,7 +138,7 @@ function desktopStatus(config: ManagementContext["config"]): NativeStatus {
|
|
|
126
138
|
const seen = inspectDesktop3pConfigLibrary({
|
|
127
139
|
appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null,
|
|
128
140
|
});
|
|
129
|
-
const
|
|
141
|
+
const gatewayState: NativeStatus["state"] = seen.kind === "gateway_ours"
|
|
130
142
|
? "current"
|
|
131
143
|
: seen.kind === "unsafe" || seen.kind === "broken" ? "unsafe" : "absent";
|
|
132
144
|
const disableBlocked = seen.kind === "unsafe" || seen.kind === "broken" || seen.kind === "foreign"
|
|
@@ -135,6 +147,14 @@ function desktopStatus(config: ManagementContext["config"]): NativeStatus {
|
|
|
135
147
|
message: "Claude Desktop configuration cannot be changed safely.",
|
|
136
148
|
}
|
|
137
149
|
: null;
|
|
150
|
+
// First-party mode lives in Claude Code's settings.json, not in Desktop's library. A leftover
|
|
151
|
+
// gateway profile still counts as current (it is what Desktop is actually running).
|
|
152
|
+
const firstParty = resolveClaudeDesktopApplyMode(config) === "first-party" && gatewayState === "absent"
|
|
153
|
+
? inspectDesktopFirstParty(config)
|
|
154
|
+
: null;
|
|
155
|
+
const state: NativeStatus["state"] = firstParty
|
|
156
|
+
? firstParty.settings.kind === "unreadable" ? "unsafe" : firstParty.applied ? "current" : "absent"
|
|
157
|
+
: gatewayState;
|
|
138
158
|
return {
|
|
139
159
|
clientId: "claude-desktop",
|
|
140
160
|
state,
|
|
@@ -599,6 +619,28 @@ async function handleGrokToggle(ctx: ManagementContext): Promise<Response> {
|
|
|
599
619
|
}
|
|
600
620
|
}
|
|
601
621
|
|
|
622
|
+
export function firstPartyRefusalMessage(
|
|
623
|
+
reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env",
|
|
624
|
+
path: string,
|
|
625
|
+
): string {
|
|
626
|
+
switch (reason) {
|
|
627
|
+
case "intercept_disabled":
|
|
628
|
+
return "First-party mode needs the Claude intercept proxy, which is off in this configuration (claudeCode.intercept.enabled / client role). Use gateway mode instead.";
|
|
629
|
+
case "ca_unavailable":
|
|
630
|
+
return `The local intercept certificate could not be created (${path}).`;
|
|
631
|
+
case "unreadable":
|
|
632
|
+
return `Claude Code settings could not be parsed (${path}); nothing was written.`;
|
|
633
|
+
case "foreign_env":
|
|
634
|
+
return `Claude Code settings already set HTTPS_PROXY or NODE_EXTRA_CA_CERTS to a value opencodex does not own (${path}); remove them first or use gateway mode.`;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Record which Desktop mode is applied; `false` when the config file could not be updated. */
|
|
639
|
+
function persistDesktopModeMarker(desktopMode: ClaudeDesktopMode): boolean {
|
|
640
|
+
const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode));
|
|
641
|
+
return outcome.status !== "unavailable";
|
|
642
|
+
}
|
|
643
|
+
|
|
602
644
|
let claudeDesktopToggleFlight: Promise<Response> | null = null;
|
|
603
645
|
|
|
604
646
|
async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise<Response> {
|
|
@@ -626,6 +668,11 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise<Respon
|
|
|
626
668
|
const fingerprint = current.claudeCode?.desktopProfile?.appliedFingerprint ?? null;
|
|
627
669
|
|
|
628
670
|
if (!body.enabled) {
|
|
671
|
+
const firstPartyRemoved = removeDesktopFirstParty();
|
|
672
|
+
if (!firstPartyRemoved.ok) {
|
|
673
|
+
return postCommitRefusal(409, "claude-desktop", "write_failed",
|
|
674
|
+
`Claude Code settings could not be read (${firstPartyRemoved.path}); the first-party proxy env was left in place.`, { desiredEnabled });
|
|
675
|
+
}
|
|
629
676
|
const removed = (ctx.deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint: fingerprint });
|
|
630
677
|
if (removed.kind === "cleanup_incomplete") {
|
|
631
678
|
return postCommitRefusal(500, "claude-desktop", "cleanup_incomplete",
|
|
@@ -636,9 +683,52 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise<Respon
|
|
|
636
683
|
return postCommitRefusal(409, "claude-desktop", removed.reason === "metadata_unreadable" ? "metadata_unreadable" : "write_failed",
|
|
637
684
|
"Claude Desktop configuration could not be changed safely.", { desiredEnabled });
|
|
638
685
|
}
|
|
686
|
+
const changed = removed.changed || firstPartyRemoved.changed;
|
|
687
|
+
return jsonResponse({
|
|
688
|
+
ok: true, clientId: "claude-desktop", changed, state: "absent", desiredEnabled,
|
|
689
|
+
message: changed ? "Claude Desktop integration disabled." : "Claude Desktop integration is already off.",
|
|
690
|
+
} satisfies NativeToggleEnvelope);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (resolveClaudeDesktopApplyMode(current) === "first-party") {
|
|
694
|
+
const rollback = captureDesktopFirstPartyRollback(current);
|
|
695
|
+
const applied = applyDesktopFirstParty(current);
|
|
696
|
+
if (!applied.ok) {
|
|
697
|
+
const reason = applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? applied.reason : "write_failed";
|
|
698
|
+
return postCommitRefusal(applied.reason === "unreadable" || applied.reason === "ca_unavailable" ? 500 : 409, "claude-desktop", reason,
|
|
699
|
+
firstPartyRefusalMessage(applied.reason, applied.path), { desiredEnabled });
|
|
700
|
+
}
|
|
701
|
+
const library = inspectDesktop3pConfigLibrary({ appliedFingerprint: fingerprint });
|
|
702
|
+
let gatewayRemoved = false;
|
|
703
|
+
if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") {
|
|
704
|
+
const removed = (ctx.deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint: fingerprint, replaceWhileEnabled: true });
|
|
705
|
+
if (!removed.ok && !removed.changed && applied.changed && !rollback()) {
|
|
706
|
+
return postCommitRefusal(500, "claude-desktop", "write_failed",
|
|
707
|
+
"Gateway cleanup and first-party settings rollback did not complete.", { desiredEnabled });
|
|
708
|
+
}
|
|
709
|
+
const partialModeWarning = !removed.ok && removed.changed && !persistDesktopModeMarker("first-party")
|
|
710
|
+
? " First-party is active but its mode marker was not saved." : "";
|
|
711
|
+
if (removed.kind === "cleanup_incomplete") {
|
|
712
|
+
return postCommitRefusal(500, "claude-desktop", "cleanup_incomplete",
|
|
713
|
+
"Claude Desktop now points at standard mode, but gateway credential cleanup is incomplete; the first-party connection remains active." + partialModeWarning,
|
|
714
|
+
{ desiredEnabled, residualPaths: removed.residualPaths ?? [] });
|
|
715
|
+
}
|
|
716
|
+
if (!removed.ok) {
|
|
717
|
+
return postCommitRefusal(409, "claude-desktop", removed.reason === "metadata_unreadable" ? "metadata_unreadable" : "write_failed",
|
|
718
|
+
"The gateway profile could not be removed safely; the mode switch is incomplete." + partialModeWarning, { desiredEnabled });
|
|
719
|
+
}
|
|
720
|
+
gatewayRemoved = removed.changed;
|
|
721
|
+
}
|
|
722
|
+
const modeSaved = persistDesktopModeMarker("first-party");
|
|
723
|
+
const changed = applied.changed || gatewayRemoved;
|
|
639
724
|
return jsonResponse({
|
|
640
|
-
ok: true, clientId: "claude-desktop", changed
|
|
641
|
-
message:
|
|
725
|
+
ok: true, clientId: "claude-desktop", changed, state: "current", desiredEnabled,
|
|
726
|
+
message: [
|
|
727
|
+
changed
|
|
728
|
+
? "Claude Desktop integration enabled (first-party). Fully quit and reopen Claude Desktop."
|
|
729
|
+
: "Claude Desktop integration is already on.",
|
|
730
|
+
modeSaved ? "" : "The first-party mode marker could not be saved to config; status may report the mode as unsaved.",
|
|
731
|
+
].filter(Boolean).join(" "),
|
|
642
732
|
} satisfies NativeToggleEnvelope);
|
|
643
733
|
}
|
|
644
734
|
|
|
@@ -665,9 +755,16 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise<Respon
|
|
|
665
755
|
nativeContextLimits(latest),
|
|
666
756
|
);
|
|
667
757
|
if (!result.written) return postCommitRefusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled: latestDesiredEnabled });
|
|
758
|
+
const committed = persistCommittedDesktopGateway(ctx.config, latest.claudeCode?.desktopProfile, result.fingerprint);
|
|
759
|
+
const stateWarning = committed.ok ? "" : " The committed gateway mode/profile state was not saved.";
|
|
760
|
+
const removed = removeDesktopFirstParty();
|
|
761
|
+
if (!removed.ok) return postCommitRefusal(500, "claude-desktop", "write_failed", "Gateway applied, but first-party settings cleanup did not complete." + stateWarning, { desiredEnabled: latestDesiredEnabled });
|
|
668
762
|
return jsonResponse({
|
|
669
763
|
ok: true, clientId: "claude-desktop", changed: true, state: "current", desiredEnabled: latestDesiredEnabled,
|
|
670
|
-
message:
|
|
764
|
+
message: [
|
|
765
|
+
"Claude Desktop integration enabled.",
|
|
766
|
+
stateWarning,
|
|
767
|
+
].filter(Boolean).join(" "),
|
|
671
768
|
} satisfies NativeToggleEnvelope);
|
|
672
769
|
} catch {
|
|
673
770
|
return postCommitRefusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled });
|
|
@@ -210,7 +210,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
210
210
|
const { shouldOpenBrowserForLogin } = await import("../../oauth/open-browser-choice");
|
|
211
211
|
if (authUrl && !deviceCode && shouldOpenBrowserForLogin(body.openBrowser, config)) {
|
|
212
212
|
const { openUrl } = await import("../../lib/open-url");
|
|
213
|
-
openUrl(authUrl);
|
|
213
|
+
void openUrl(authUrl);
|
|
214
214
|
}
|
|
215
215
|
return jsonResponse({ url: authUrl, instructions, deviceCode });
|
|
216
216
|
} catch (err) {
|
|
@@ -869,6 +869,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
869
869
|
name: k.name,
|
|
870
870
|
prefix: k.key.slice(0, 17) + "...",
|
|
871
871
|
createdAt: k.createdAt,
|
|
872
|
+
// Scope is metadata, not secret: an operator has to be able to read
|
|
873
|
+
// what a key may reach without minting a replacement to find out.
|
|
874
|
+
...(k.allowedProviders ? { allowedProviders: [...k.allowedProviders] } : {}),
|
|
875
|
+
...(k.allowedModels ? { allowedModels: [...k.allowedModels] } : {}),
|
|
872
876
|
...(k.pendingRotation ? { pendingRotation: {
|
|
873
877
|
id: k.pendingRotation.id,
|
|
874
878
|
createdAt: k.pendingRotation.createdAt,
|
|
@@ -952,15 +956,49 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
952
956
|
const body = await readJsonBody(req);
|
|
953
957
|
if (!body) return jsonResponse({ error: "invalid body" }, 400, req, config);
|
|
954
958
|
if (typeof body.id !== "string" || !body.id) return jsonResponse({ error: "id required" }, 400, req, config);
|
|
955
|
-
const
|
|
956
|
-
if (
|
|
957
|
-
const entry =
|
|
958
|
-
|
|
959
|
-
|
|
959
|
+
const existing = (config.apiKeys ?? []).find(k => k.id === body.id);
|
|
960
|
+
if (!existing) return jsonResponse({ error: "key not found" }, 404, req, config);
|
|
961
|
+
const entry = { ...existing };
|
|
962
|
+
// Rename and scope are independent edits. A scope-only PATCH must not have
|
|
963
|
+
// to restate the name, and a rename must not silently widen a scope, so
|
|
964
|
+
// each field is applied only when the caller actually sent it.
|
|
965
|
+
const renaming = body.name !== undefined;
|
|
966
|
+
const scopingProviders = body.allowedProviders !== undefined;
|
|
967
|
+
const scopingModels = body.allowedModels !== undefined;
|
|
968
|
+
if (!renaming && !scopingProviders && !scopingModels) {
|
|
969
|
+
return jsonResponse({ error: "name, allowedProviders or allowedModels required" }, 400, req, config);
|
|
970
|
+
}
|
|
971
|
+
if (renaming) {
|
|
972
|
+
const nameField = validateKeyName(body.name, { required: true });
|
|
973
|
+
if ("error" in nameField) return jsonResponse({ error: nameField.error }, 400, req, config);
|
|
974
|
+
entry.name = nameField.value;
|
|
975
|
+
}
|
|
976
|
+
for (const [field, sent] of [["allowedProviders", scopingProviders], ["allowedModels", scopingModels]] as const) {
|
|
977
|
+
if (!sent) continue;
|
|
978
|
+
const value = body[field];
|
|
979
|
+
// `null` and `[]` both clear the list back to unrestricted; anything else
|
|
980
|
+
// must be a list of non-empty strings, because a silently ignored malformed
|
|
981
|
+
// scope would read as "allowed everything" to whoever set it.
|
|
982
|
+
if (value === null) { delete entry[field]; continue; }
|
|
983
|
+
if (!Array.isArray(value) || value.some(item => typeof item !== "string" || !item.trim() || item.length > 256)) {
|
|
984
|
+
return jsonResponse({ error: `${field} must be a list of non-empty names` }, 400, req, config);
|
|
985
|
+
}
|
|
986
|
+
const normalized = [...new Set((value as string[]).map(item => item.trim()))];
|
|
987
|
+
if (normalized.length === 0) delete entry[field];
|
|
988
|
+
else entry[field] = normalized;
|
|
989
|
+
}
|
|
990
|
+
// Publish the validated replacement only after every field is accepted.
|
|
991
|
+
config.apiKeys = config.apiKeys!.map(key => key === existing ? entry : key);
|
|
960
992
|
saveConfigPreservingClaudeCode(config);
|
|
961
993
|
reconcileLiveStateStores();
|
|
962
994
|
// Never echo key material from a rename.
|
|
963
|
-
return jsonResponse({
|
|
995
|
+
return jsonResponse({
|
|
996
|
+
id: entry.id,
|
|
997
|
+
name: entry.name,
|
|
998
|
+
createdAt: entry.createdAt,
|
|
999
|
+
...(entry.allowedProviders ? { allowedProviders: [...entry.allowedProviders] } : {}),
|
|
1000
|
+
...(entry.allowedModels ? { allowedModels: [...entry.allowedModels] } : {}),
|
|
1001
|
+
}, 200, req, config);
|
|
964
1002
|
}
|
|
965
1003
|
|
|
966
1004
|
if (url.pathname === "/api/keys" && req.method === "DELETE") {
|
|
@@ -38,8 +38,17 @@ export type ExemptionReason =
|
|
|
38
38
|
| "test-seam"
|
|
39
39
|
/** The CLI reaches the same data through a local transport instead of HTTP. */
|
|
40
40
|
| "local-transport"
|
|
41
|
+
/** Machine scrape target whose HTTP exposition is the operator contract. */
|
|
42
|
+
| "scrape-target"
|
|
41
43
|
/** Older clients use this alias; the current CLI drives its declared replacement. */
|
|
42
44
|
| "compatibility-alias"
|
|
45
|
+
/**
|
|
46
|
+
* A read-only POST whose purpose is to bind an interactive confirmation to the mutation that
|
|
47
|
+
* immediately follows it. There is no standalone thing for a CLI to do with one: the plan is
|
|
48
|
+
* only meaningful to the caller that is about to commit it, and a scripted caller drives the
|
|
49
|
+
* mutation directly.
|
|
50
|
+
*/
|
|
51
|
+
| "interactive-preview"
|
|
43
52
|
/** Unreachable in the live dispatch order; delete rather than expose. */
|
|
44
53
|
| "dead"
|
|
45
54
|
/**
|
|
@@ -159,6 +168,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
159
168
|
{ method: "GET", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
|
|
160
169
|
{ method: "DELETE", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside history deletion uses the dashboard journal cleanup; the CLI has history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
|
|
161
170
|
{ method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
|
|
171
|
+
{ method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/preview", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode", exempt: { reason: "interactive-preview", why: "Plans one Aside profile's change so a dashboard confirmation can show what would change and bind to it. Read-only, and useful only to the caller about to commit; a scripted caller drives the profile toggle or restore directly." } },
|
|
162
172
|
{ method: "DELETE", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside profile history deletion uses the dashboard journal cleanup; the CLI has scoped history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
|
|
163
173
|
{ method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/restore", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode" },
|
|
164
174
|
// server/management/codex-prompt-routes
|
|
@@ -197,6 +207,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
197
207
|
{ method: "GET", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: false },
|
|
198
208
|
{ method: "DELETE", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Retiring one rollback row is a dashboard-local cleanup; the CLI verb that would drive it is owed by a later work-phase and is not implemented here.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
|
|
199
209
|
{ method: "POST", path: "/api/client-integrations/restore", module: "server/management/integration-routes", mutates: true },
|
|
210
|
+
{ method: "POST", path: "/api/client-integrations/preview", module: "server/management/integration-routes", mutates: false, exempt: { reason: "interactive-preview", why: "Plans an apply, overwrite or disable so a dashboard confirmation can show what would change and bind to it. Read-only, and useful only to the caller about to commit; a scripted caller drives PUT /api/client-integrations/{clientId} directly." } },
|
|
211
|
+
{ method: "POST", path: "/api/client-integrations/restore/preview", module: "server/management/integration-routes", mutates: false, exempt: { reason: "interactive-preview", why: "Plans an undo so drift is shown before a restore rather than discovered by a rejected mutation. Read-only, and useful only to the caller about to commit; a scripted caller drives POST /api/client-integrations/restore directly." } },
|
|
200
212
|
// server/management/lab-automation-routes
|
|
201
213
|
{ method: "GET", path: "/api/lab/automation", module: "server/management/lab-automation-routes", mutates: false, exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } },
|
|
202
214
|
{ method: "GET", path: "/api/lab/automation/runs", module: "server/management/lab-automation-routes", mutates: false, exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } },
|
|
@@ -228,12 +240,20 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
228
240
|
{ method: "GET", path: "/api/storage/trash", module: "server/management/logs-usage-routes", mutates: false },
|
|
229
241
|
{ method: "GET", path: "/api/storage/trash/restore/test-stream", module: "server/management/logs-usage-routes", mutates: false, exempt: { reason: "test-seam", why: "Opt-in streaming seam declared at src/storage/restore-job.ts:34." } },
|
|
230
242
|
{ method: "GET", path: "/api/usage", module: "server/management/logs-usage-routes", mutates: false },
|
|
243
|
+
// server/management/usage-timeline-routes
|
|
244
|
+
{ method: "GET", path: "/api/usage/timeline", module: "server/management/usage-timeline-routes", mutates: false },
|
|
245
|
+
// server/management/companion-routes
|
|
246
|
+
{ method: "POST", path: "/api/companion/open-in-browser", module: "server/management/companion-routes", mutates: true, exempt: { reason: "session-only", why: "Dashboard-only navigation helper; the GUI session opens its current view in the system browser, and there is no standalone CLI operation to drive." } },
|
|
247
|
+
{ method: "GET", path: "/api/companion/settings", module: "server/management/companion-routes", mutates: false },
|
|
248
|
+
{ method: "PUT", path: "/api/companion/settings", module: "server/management/companion-routes", mutates: true },
|
|
231
249
|
{ method: "POST", path: "/api/storage/cleanup", module: "server/management/logs-usage-routes", mutates: true },
|
|
232
250
|
{ method: "POST", path: "/api/storage/cleanup-policy/run", module: "server/management/logs-usage-routes", mutates: true },
|
|
233
251
|
{ method: "POST", path: "/api/storage/cleanup/preview", module: "server/management/logs-usage-routes", mutates: true },
|
|
234
252
|
{ method: "POST", path: "/api/storage/trash/restore", module: "server/management/logs-usage-routes", mutates: true },
|
|
235
253
|
{ method: "PUT", path: "/api/debug", module: "server/management/logs-usage-routes", mutates: true },
|
|
236
254
|
{ method: "PUT", path: "/api/storage/cleanup-policy", module: "server/management/logs-usage-routes", mutates: true },
|
|
255
|
+
// server/management/metrics-routes
|
|
256
|
+
{ method: "GET", path: "/api/metrics", module: "server/management/metrics-routes", mutates: false, exempt: { reason: "scrape-target", why: "This machine scrape target exposes authenticated text exposition for monitoring systems; a CLI JSON verb would be a different contract." } },
|
|
237
257
|
// server/management/model-routes
|
|
238
258
|
{ method: "GET", path: "/api/aliases", module: "server/management/model-routes", mutates: false },
|
|
239
259
|
{ method: "GET", path: "/api/catalog", module: "server/management/model-routes", mutates: false },
|