@bitkyc08/opencodex 2.60.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 +5 -1
- package/src/adapters/anthropic.ts +16 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +10 -2
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +13 -3
- package/src/adapters/cursor.ts +11 -2
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin.ts +75 -23
- package/src/adapters/google-antigravity-wire.ts +5 -2
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google.ts +29 -5
- 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.ts +18 -57
- package/src/adapters/openai-responses/passthrough.ts +2 -0
- 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 +10 -0
- 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/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 +43 -4
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/index.ts +200 -61
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -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-listener.ts +2 -5
- 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/app-server-processes.ts +72 -40
- package/src/codex/auth-api/login-flow.ts +6 -1
- 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/provider-models.ts +24 -15
- package/src/codex/catalog/retained-sync.ts +2 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/history-provider.ts +12 -1
- 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/main-account.ts +6 -0
- package/src/codex/model-cache.ts +52 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +87 -44
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +5 -41
- package/src/codex/shim-templates.ts +29 -3
- package/src/companion/settings.ts +132 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/load-degrade.ts +34 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/schema/config-schema.ts +27 -1
- package/src/config/schema/leaf-validators.ts +47 -0
- package/src/config.ts +1 -1
- package/src/generated/compatibility-version.json +418 -174
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +124 -18
- 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 +49 -11
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug.ts +40 -0
- 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/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +59 -14
- 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/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/index.ts +24 -1
- package/src/oauth/login-cli.ts +80 -29
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/key-failover.ts +31 -1
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +124 -37
- package/src/providers/quota/vendor-probes-key.ts +37 -22
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +9 -4
- package/src/providers/registry/entries-extended.ts +29 -4
- package/src/providers/registry/model-seeds.ts +47 -10
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -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/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/schema.ts +3 -0
- package/src/router.ts +17 -2
- 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 +26 -0
- package/src/server/chat-completions.ts +55 -2
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +55 -17
- 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 +56 -10
- package/src/server/index/spend-ledger-lifecycle.ts +34 -8
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index.ts +21 -28
- 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/companion-routes.ts +77 -0
- package/src/server/management/logs-usage-routes.ts +19 -0
- 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 +6 -0
- package/src/server/management/shared.ts +18 -1
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +8 -9
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/relay.ts +19 -2
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +114 -0
- package/src/server/request-metrics.ts +92 -30
- 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 +11 -0
- package/src/server/responses/core-opaque-recovery.ts +90 -0
- 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 +14 -1
- package/src/server/responses/passthrough-dispatch.ts +179 -35
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/request-prepare.ts +42 -1
- package/src/server/responses/request-send-budget.ts +12 -0
- package/src/server/responses/request-transport.ts +24 -4
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +4 -1
- package/src/server/search.ts +25 -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 +1 -1
- package/src/types/config.ts +37 -0
- package/src/types/provider.ts +73 -0
- package/src/types/request.ts +28 -2
- package/src/types/tools.ts +19 -0
- package/src/types.ts +3 -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/web-search/alpha-search.ts +21 -1
- package/gui/dist/assets/index-BTuCbqQd.css +0 -1
- package/gui/dist/assets/index-DoBVdPHP.js +0 -134
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { readFileSync } from "node:fs";
|
|
3
4
|
import type { CatalogModel } from "../../codex/catalog";
|
|
@@ -131,6 +132,17 @@ function persistDesktopProfileField(
|
|
|
131
132
|
return { ok: true };
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
async function persistDesktopModeField(
|
|
136
|
+
config: OcxConfig,
|
|
137
|
+
desktopMode: "first-party" | "gateway",
|
|
138
|
+
): Promise<{ ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" }> {
|
|
139
|
+
const { recordClaudeDesktopMode } = await import("../../claude/desktop-first-party");
|
|
140
|
+
const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode));
|
|
141
|
+
if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason };
|
|
142
|
+
recordClaudeDesktopMode(config, desktopMode);
|
|
143
|
+
return { ok: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
134
146
|
export function grokApplyFlightSnapshot(): { currentBytes: number; highWaterBytes: number; active: number } {
|
|
135
147
|
return {
|
|
136
148
|
currentBytes: grokApplyFlight?.bytes ?? 0,
|
|
@@ -1002,7 +1014,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1002
1014
|
if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") {
|
|
1003
1015
|
try {
|
|
1004
1016
|
// #859: the CLI delegates here so the registry is built in the serving
|
|
1005
|
-
// process.
|
|
1017
|
+
// process. `mode` selects the Desktop mode: `first-party` (default when the
|
|
1018
|
+
// install has no gateway profile) or `gateway`; the legacy gateway shapes
|
|
1019
|
+
// (static/hybrid/discovery) still select gateway with that library layout.
|
|
1006
1020
|
let mode: "static" | "hybrid" | "discovery" = "static";
|
|
1007
1021
|
let parsed: unknown;
|
|
1008
1022
|
try {
|
|
@@ -1011,12 +1025,17 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1011
1025
|
rethrowManagementBodyTooLarge(error);
|
|
1012
1026
|
return jsonResponse({ error: "invalid JSON body" }, 400);
|
|
1013
1027
|
}
|
|
1028
|
+
const { resolveClaudeDesktopApplyMode, applyDesktopFirstParty, captureDesktopFirstPartyRollback, removeDesktopFirstParty } = await import("../../claude/desktop-first-party");
|
|
1014
1029
|
const requested = (parsed as { mode?: unknown } | null)?.mode;
|
|
1030
|
+
let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config);
|
|
1015
1031
|
if (requested !== undefined) {
|
|
1016
1032
|
if (requested === "static" || requested === "hybrid" || requested === "discovery") {
|
|
1017
1033
|
mode = requested;
|
|
1034
|
+
desktopMode = "gateway";
|
|
1035
|
+
} else if (requested === "gateway" || requested === "first-party") {
|
|
1036
|
+
desktopMode = requested;
|
|
1018
1037
|
} else {
|
|
1019
|
-
return jsonResponse({ error: "mode must be static, hybrid, or discovery" }, 400);
|
|
1038
|
+
return jsonResponse({ error: "mode must be first-party, gateway, static, hybrid, or discovery" }, 400);
|
|
1020
1039
|
}
|
|
1021
1040
|
}
|
|
1022
1041
|
// #859: a delegated CLI apply carries the profile it just saved — the
|
|
@@ -1032,6 +1051,60 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1032
1051
|
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
1033
1052
|
}
|
|
1034
1053
|
}
|
|
1054
|
+
if (desktopMode === "first-party") {
|
|
1055
|
+
const { setIntegrationEnabled } = await import("../../codex/desired-state");
|
|
1056
|
+
const desired = setIntegrationEnabled("claude-desktop", true);
|
|
1057
|
+
if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500);
|
|
1058
|
+
mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true);
|
|
1059
|
+
const { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } = await import("../../claude/desktop-3p");
|
|
1060
|
+
const rollback = captureDesktopFirstPartyRollback(config);
|
|
1061
|
+
const applied = applyDesktopFirstParty(config);
|
|
1062
|
+
if (!applied.ok) {
|
|
1063
|
+
const { firstPartyRefusalMessage } = await import("./native-integration-routes");
|
|
1064
|
+
return jsonResponse({
|
|
1065
|
+
error: firstPartyRefusalMessage(applied.reason, applied.path),
|
|
1066
|
+
code: "claude_desktop_first_party_refused",
|
|
1067
|
+
reason: applied.reason,
|
|
1068
|
+
path: applied.path,
|
|
1069
|
+
}, applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? 409 : 500);
|
|
1070
|
+
}
|
|
1071
|
+
// Remove the gateway only after the replacement env was written.
|
|
1072
|
+
const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null;
|
|
1073
|
+
const library = inspectDesktop3pConfigLibrary({ appliedFingerprint });
|
|
1074
|
+
let gatewayRemoved = false;
|
|
1075
|
+
if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") {
|
|
1076
|
+
const removed = (deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint, replaceWhileEnabled: true });
|
|
1077
|
+
if (!removed.ok) {
|
|
1078
|
+
const restored = removed.changed || !applied.changed || rollback();
|
|
1079
|
+
const modeSaved = !removed.changed || (await persistDesktopModeField(config, "first-party")).ok;
|
|
1080
|
+
const warning = [restored ? "" : "first-party settings rollback did not complete",
|
|
1081
|
+
modeSaved ? "" : "first-party is active but its mode marker was not saved"].filter(Boolean).join("; ");
|
|
1082
|
+
return jsonResponse({
|
|
1083
|
+
error: removed.kind === "cleanup_incomplete"
|
|
1084
|
+
? "Claude Desktop now points at standard mode, but gateway credential cleanup is incomplete; the first-party connection remains active."
|
|
1085
|
+
: "The gateway profile could not be removed safely, so first-party mode was not applied.",
|
|
1086
|
+
code: "claude_desktop_gateway_removal_failed",
|
|
1087
|
+
...(warning ? { warning } : {}),
|
|
1088
|
+
reason: removed.reason ?? removed.kind,
|
|
1089
|
+
...(removed.residualPaths ? { residualPaths: removed.residualPaths } : {}),
|
|
1090
|
+
}, removed.kind === "cleanup_incomplete" ? 500 : 409);
|
|
1091
|
+
}
|
|
1092
|
+
gatewayRemoved = removed.changed;
|
|
1093
|
+
}
|
|
1094
|
+
const modeSaved = await persistDesktopModeField(config, "first-party");
|
|
1095
|
+
return jsonResponse({
|
|
1096
|
+
ok: true,
|
|
1097
|
+
mode: "first-party",
|
|
1098
|
+
saved: modeSaved.ok,
|
|
1099
|
+
applied: true,
|
|
1100
|
+
changed: applied.changed || gatewayRemoved,
|
|
1101
|
+
gatewayRemoved,
|
|
1102
|
+
path: applied.path,
|
|
1103
|
+
proxyPort: applied.proxyPort,
|
|
1104
|
+
caCertPath: applied.env.NODE_EXTRA_CA_CERTS,
|
|
1105
|
+
...(modeSaved.ok ? {} : { warning: `First-party env was applied, but the mode marker was not saved (${modeSaved.reason}).` }),
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1035
1108
|
const { setIntegrationEnabled, claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state");
|
|
1036
1109
|
const desired = setIntegrationEnabled("claude-desktop", true);
|
|
1037
1110
|
if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500);
|
|
@@ -1083,42 +1156,31 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1083
1156
|
nativeContextLimits(latest),
|
|
1084
1157
|
);
|
|
1085
1158
|
if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500);
|
|
1159
|
+
const committed = persistCommittedDesktopGateway(config, state.profile, result.fingerprint);
|
|
1160
|
+
const modeWarning = committed.ok ? undefined : `Gateway applied, but its mode/profile state was not saved (${committed.reason}).`;
|
|
1161
|
+
// The durable marker describes the committed gateway even if old-mode cleanup fails.
|
|
1162
|
+
const firstPartyRemoved = removeDesktopFirstParty();
|
|
1163
|
+
if (!firstPartyRemoved.ok) {
|
|
1164
|
+
return jsonResponse({
|
|
1165
|
+
error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); first-party cleanup remains incomplete after gateway apply.`,
|
|
1166
|
+
code: "claude_desktop_first_party_refused", reason: firstPartyRemoved.reason,
|
|
1167
|
+
applied: true, saved: committed.ok, mode: "gateway", path: result.path,
|
|
1168
|
+
...(modeWarning ? { warning: modeWarning } : {}),
|
|
1169
|
+
}, 500);
|
|
1170
|
+
}
|
|
1086
1171
|
const { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy");
|
|
1087
|
-
const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({
|
|
1088
|
-
platform: deps.platform ?? process.platform,
|
|
1089
|
-
});
|
|
1172
|
+
const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({ platform: deps.platform ?? process.platform });
|
|
1090
1173
|
const policyWarning = claudeDesktopPolicyWarning(policyState);
|
|
1091
|
-
|
|
1092
|
-
if (result.fingerprint) {
|
|
1093
|
-
// The Desktop write already landed, so a failed bookkeeping save is not
|
|
1094
|
-
// an apply failure: report the miss instead of claiming a clean apply.
|
|
1095
|
-
const marked = persistDesktopProfileField(config, {
|
|
1096
|
-
...state.profile,
|
|
1097
|
-
appliedFingerprint: result.fingerprint,
|
|
1098
|
-
appliedAt: new Date().toISOString(),
|
|
1099
|
-
});
|
|
1100
|
-
if (!marked.ok) {
|
|
1101
|
-
return jsonResponse({
|
|
1102
|
-
ok: true,
|
|
1103
|
-
applied: true,
|
|
1104
|
-
saved: false,
|
|
1105
|
-
path: result.path,
|
|
1106
|
-
fingerprint: result.fingerprint,
|
|
1107
|
-
warning: [
|
|
1108
|
-
`Claude Desktop was applied, but the applied marker was not saved (${marked.reason}).`,
|
|
1109
|
-
policyWarning,
|
|
1110
|
-
].filter(Boolean).join(" "),
|
|
1111
|
-
});
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1174
|
+
const warning = [modeWarning, policyWarning].filter(Boolean).join(" ");
|
|
1114
1175
|
return jsonResponse({
|
|
1115
1176
|
ok: true,
|
|
1116
|
-
|
|
1177
|
+
mode: "gateway",
|
|
1178
|
+
saved: committed.ok,
|
|
1117
1179
|
applied: true,
|
|
1118
1180
|
path: result.path,
|
|
1119
1181
|
fingerprint: result.fingerprint,
|
|
1120
1182
|
policyState,
|
|
1121
|
-
...(
|
|
1183
|
+
...(warning ? { warning } : {}),
|
|
1122
1184
|
});
|
|
1123
1185
|
} catch (error) {
|
|
1124
1186
|
rethrowManagementBodyTooLarge(error);
|
|
@@ -1131,21 +1193,39 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1131
1193
|
try {
|
|
1132
1194
|
const { claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state");
|
|
1133
1195
|
const { inspectDesktop3pConfigLibrary } = await import("../../claude/desktop-3p");
|
|
1196
|
+
const { resolveClaudeDesktopApplyMode, inspectDesktopFirstParty } = await import("../../claude/desktop-first-party");
|
|
1197
|
+
const { getClaudeInterceptState } = await import("../../claude/intercept/runtime");
|
|
1134
1198
|
const persisted = loadConfig();
|
|
1135
1199
|
const savedFingerprint = persisted.claudeCode?.desktopProfile?.appliedFingerprint ?? null;
|
|
1136
1200
|
const observed = inspectDesktop3pConfigLibrary({ appliedFingerprint: savedFingerprint });
|
|
1137
1201
|
const desiredEnabled = claudeDesktopIntegrationEnabled(persisted);
|
|
1138
|
-
const
|
|
1202
|
+
const gatewayApplied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted";
|
|
1203
|
+
// A gateway profile on disk is what Desktop actually runs, whatever the saved mode says.
|
|
1204
|
+
const mode = gatewayApplied ? "gateway" : resolveClaudeDesktopApplyMode(persisted);
|
|
1205
|
+
const firstPartySeen = inspectDesktopFirstParty(persisted);
|
|
1206
|
+
const intercept = getClaudeInterceptState();
|
|
1207
|
+
const firstParty = {
|
|
1208
|
+
settingsState: firstPartySeen.settings.kind,
|
|
1209
|
+
applied: firstPartySeen.applied,
|
|
1210
|
+
stale: firstPartySeen.stale,
|
|
1211
|
+
interceptEnabled: firstPartySeen.interceptEnabled,
|
|
1212
|
+
interceptRunning: intercept !== null,
|
|
1213
|
+
proxyPort: intercept?.proxyPort ?? firstPartySeen.proxyPort,
|
|
1214
|
+
caCertPath: firstPartySeen.caCertPath,
|
|
1215
|
+
};
|
|
1216
|
+
const applied = mode === "first-party" ? firstPartySeen.applied : gatewayApplied;
|
|
1139
1217
|
// "Needs update" is only meaningful while the integration is wanted. When the
|
|
1140
1218
|
// durable switch is OFF, a leftover drifted profile is residue to clear — not
|
|
1141
1219
|
// a stale apply the operator should refresh.
|
|
1142
|
-
const stale = desiredEnabled && observed.kind === "gateway_drifted";
|
|
1220
|
+
const stale = desiredEnabled && (mode === "first-party" ? firstPartySeen.stale : observed.kind === "gateway_drifted");
|
|
1143
1221
|
const { getDesktopHealth } = await import("../../claude/desktop-health");
|
|
1144
1222
|
const { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy");
|
|
1145
1223
|
const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({
|
|
1146
1224
|
platform: deps.platform ?? process.platform,
|
|
1147
1225
|
});
|
|
1148
|
-
|
|
1226
|
+
// Managed-policy conflicts only matter for the gateway profile; first-party mode never
|
|
1227
|
+
// touches Desktop's own configuration.
|
|
1228
|
+
const policy = claudeDesktopPolicyHealth(mode === "first-party" ? "absent" : policyState);
|
|
1149
1229
|
const health = {
|
|
1150
1230
|
...getDesktopHealth(),
|
|
1151
1231
|
ok: policy.ok,
|
|
@@ -1153,14 +1233,19 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1153
1233
|
policy,
|
|
1154
1234
|
};
|
|
1155
1235
|
const policyConflict = desiredEnabled && !policy.ok;
|
|
1156
|
-
const
|
|
1236
|
+
const firstPartyResidue = mode === "gateway" && (firstPartySeen.applied || firstPartySeen.stale);
|
|
1237
|
+
const baseDrift = desiredEnabled
|
|
1238
|
+
? !applied || stale || firstPartyResidue
|
|
1239
|
+
: applied || gatewayApplied || firstPartySeen.applied || firstPartySeen.stale || observed.kind === "unsafe";
|
|
1157
1240
|
const driftReason = policyConflict
|
|
1158
1241
|
? policy.state === "present" ? "managed_policy_present" : "managed_policy_unknown"
|
|
1159
1242
|
: desiredEnabled
|
|
1160
|
-
? !applied ? "desired_on_not_current" : stale ? "profile_drift" : null
|
|
1161
|
-
:
|
|
1243
|
+
? !applied ? "desired_on_not_current" : stale ? "profile_drift" : firstPartyResidue ? "first_party_residue" : null
|
|
1244
|
+
: gatewayApplied ? "desired_off_gateway_selected" : firstPartySeen.applied || firstPartySeen.stale ? "desired_off_first_party_env" : null;
|
|
1162
1245
|
return jsonResponse({
|
|
1163
1246
|
desiredEnabled,
|
|
1247
|
+
mode,
|
|
1248
|
+
firstParty,
|
|
1164
1249
|
installed: observed.kind !== "not_installed",
|
|
1165
1250
|
observedKind: observed.kind,
|
|
1166
1251
|
applied,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyCompanionSettingsPatch,
|
|
3
|
+
DEFAULT_COMPANION_SETTINGS,
|
|
4
|
+
loadCompanionSettings,
|
|
5
|
+
saveCompanionSettings,
|
|
6
|
+
} from "../../companion/settings";
|
|
7
|
+
import { jsonResponse } from "../auth-cors";
|
|
8
|
+
import { openUrl } from "../../lib/open-url";
|
|
9
|
+
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
10
|
+
import type { ManagementContext } from "./context";
|
|
11
|
+
|
|
12
|
+
let companionPresence: { lastSeenAt: number; kind: "menuBar" | "desktop" } | null = null;
|
|
13
|
+
|
|
14
|
+
export function resetCompanionPresenceForTests(): void {
|
|
15
|
+
companionPresence = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function response(): Response {
|
|
19
|
+
const loaded = loadCompanionSettings();
|
|
20
|
+
return jsonResponse({
|
|
21
|
+
settings: loaded.settings,
|
|
22
|
+
updatedAt: loaded.updatedAt,
|
|
23
|
+
defaults: DEFAULT_COMPANION_SETTINGS,
|
|
24
|
+
companion: companionPresence ?? { lastSeenAt: null },
|
|
25
|
+
...(loaded.corrupt ? { corrupt: true } : {}),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function handleCompanionRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
30
|
+
if (ctx.url.pathname === "/api/companion/open-in-browser" && ctx.req.method === "POST") {
|
|
31
|
+
let body: unknown;
|
|
32
|
+
try {
|
|
33
|
+
body = await readManagementJsonBody(ctx.req);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
rethrowManagementBodyTooLarge(error);
|
|
36
|
+
return jsonResponse({ error: "invalid path" }, 400, ctx.req, ctx.config);
|
|
37
|
+
}
|
|
38
|
+
const path = body && typeof body === "object" && !Array.isArray(body)
|
|
39
|
+
? (body as { path?: unknown }).path
|
|
40
|
+
: undefined;
|
|
41
|
+
if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") || path.length > 512) {
|
|
42
|
+
return jsonResponse({ error: "invalid path" }, 400, ctx.req, ctx.config);
|
|
43
|
+
}
|
|
44
|
+
const url = `http://127.0.0.1:${ctx.config.port}${path}`;
|
|
45
|
+
openUrl(url);
|
|
46
|
+
return jsonResponse({ ok: true, url }, 200, ctx.req, ctx.config);
|
|
47
|
+
}
|
|
48
|
+
if (ctx.url.pathname === "/api/companion/settings" && ctx.req.method === "GET") {
|
|
49
|
+
const userAgent = ctx.req.headers.get("user-agent") ?? "";
|
|
50
|
+
const kind = userAgent.startsWith("OpenCodexMenuBar/") ? "menuBar"
|
|
51
|
+
: userAgent.startsWith("OpenCodexDesktop/") ? "desktop"
|
|
52
|
+
: null;
|
|
53
|
+
if (kind) companionPresence = { lastSeenAt: Date.now(), kind };
|
|
54
|
+
return response();
|
|
55
|
+
}
|
|
56
|
+
if (ctx.url.pathname !== "/api/companion/settings" || ctx.req.method !== "PUT") return null;
|
|
57
|
+
let body: unknown;
|
|
58
|
+
try {
|
|
59
|
+
body = await readManagementJsonBody(ctx.req);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
rethrowManagementBodyTooLarge(error);
|
|
62
|
+
return jsonResponse({ error: "invalid JSON body" }, 400, ctx.req, ctx.config);
|
|
63
|
+
}
|
|
64
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return jsonResponse({ error: "invalid settings body" }, 400, ctx.req, ctx.config);
|
|
65
|
+
const input = body as { reset?: unknown; settings?: unknown };
|
|
66
|
+
if (input.reset === true) {
|
|
67
|
+
saveCompanionSettings(DEFAULT_COMPANION_SETTINGS);
|
|
68
|
+
return response();
|
|
69
|
+
}
|
|
70
|
+
if (!("settings" in input)) return jsonResponse({ error: "provide settings or reset:true" }, 400, ctx.req, ctx.config);
|
|
71
|
+
const current = loadCompanionSettings();
|
|
72
|
+
if (current.corrupt) return jsonResponse({ error: "Companion settings could not be read; use reset:true to replace them explicitly.", code: "companion_settings_corrupt" }, 409, ctx.req, ctx.config);
|
|
73
|
+
const updated = applyCompanionSettingsPatch(current.settings, input.settings);
|
|
74
|
+
if ("error" in updated) return jsonResponse(updated, 400, ctx.req, ctx.config);
|
|
75
|
+
saveCompanionSettings(updated);
|
|
76
|
+
return response();
|
|
77
|
+
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
upsertOAuthProvider,
|
|
23
23
|
} from "../../oauth";
|
|
24
24
|
import { removeCredential } from "../../oauth/store";
|
|
25
|
+
import { getFailureProjection } from "../../usage/failure-projection-cache";
|
|
25
26
|
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
26
27
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
27
28
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
@@ -179,6 +180,24 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
179
180
|
}
|
|
180
181
|
|
|
181
182
|
if (url.pathname === "/api/usage" && req.method === "GET") {
|
|
183
|
+
// A sub-resource on the same route rather than a route of its own. It answers a different
|
|
184
|
+
// question -- which failures keep recurring, rather than what was spent -- and it costs a
|
|
185
|
+
// ledger scan, so it is opt-in: a dashboard asking for the usage summary must not pay for
|
|
186
|
+
// a projection it did not ask for.
|
|
187
|
+
if (url.searchParams.get("failures") === "1") {
|
|
188
|
+
const projection = await getFailureProjection();
|
|
189
|
+
return jsonResponse({
|
|
190
|
+
fingerprintVersion: projection.fingerprintVersion,
|
|
191
|
+
historyIncomplete: projection.historyIncomplete,
|
|
192
|
+
unattributedFailures: projection.unattributedFailures,
|
|
193
|
+
invalidTimestampFailures: projection.invalidTimestampFailures,
|
|
194
|
+
// Every field here is a closed roster member, a count or a timestamp. The scanner's
|
|
195
|
+
// checkpoint identity, the configured provider name, the model and the account label
|
|
196
|
+
// stay internal -- a grouping that promises to carry no content has to keep that
|
|
197
|
+
// promise at its boundary too.
|
|
198
|
+
failures: projection.groups,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
182
201
|
const range = parseRange(url.searchParams.get("range"));
|
|
183
202
|
const surface = parseUsageSurface(url.searchParams.get("surface"));
|
|
184
203
|
let window: UsageTimeWindow | undefined;
|
|
@@ -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") {
|
|
@@ -240,6 +240,12 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
240
240
|
{ method: "GET", path: "/api/storage/trash", module: "server/management/logs-usage-routes", mutates: false },
|
|
241
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." } },
|
|
242
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 },
|
|
243
249
|
{ method: "POST", path: "/api/storage/cleanup", module: "server/management/logs-usage-routes", mutates: true },
|
|
244
250
|
{ method: "POST", path: "/api/storage/cleanup-policy/run", module: "server/management/logs-usage-routes", mutates: true },
|
|
245
251
|
{ method: "POST", path: "/api/storage/cleanup/preview", module: "server/management/logs-usage-routes", mutates: true },
|