@bitkyc08/opencodex 2.65.0 → 2.66.0-preview.20260925
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/README.md +14 -0
- package/gui/dist/assets/App-BVjA0T6O.js +51 -0
- package/gui/dist/assets/App-gkoJIEOV.css +1 -0
- package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-BCFtiWwD.js} +1 -1
- package/gui/dist/assets/index-ComVsdSk.css +1 -0
- package/gui/dist/assets/index-vO4dgfiU.js +86 -0
- package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-C4FiNaxk.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -2
- package/src/adapters/anthropic/beta-allowlist.ts +80 -0
- package/src/adapters/anthropic/passthrough.ts +221 -0
- package/src/adapters/anthropic-image-codec.ts +56 -19
- package/src/adapters/anthropic-image-normalize.ts +20 -10
- package/src/adapters/anthropic.ts +55 -24
- package/src/adapters/coding-agent/protocol.ts +29 -19
- package/src/adapters/cursor/live-transport.ts +18 -3
- package/src/adapters/cursor/native-exec-shell.ts +18 -63
- package/src/adapters/cursor/native-exec.ts +5 -2
- package/src/adapters/cursor/native-foreground-shell.ts +44 -0
- package/src/adapters/devin/cloud-direct/chat.ts +7 -1
- package/src/adapters/exec-tool-result-normalize.ts +4 -1
- package/src/adapters/google.ts +12 -0
- package/src/adapters/ollama-native.ts +32 -1
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
- package/src/adapters/openai-chat.ts +6 -4
- package/src/adapters/openai-responses/passthrough.ts +1 -0
- package/src/adapters/openai-responses/reasoning.ts +45 -1
- package/src/bridge/response-json.ts +10 -2
- package/src/chat/outbound.ts +104 -52
- package/src/claude/claude-code-block.ts +16 -0
- package/src/claude/context-windows.ts +52 -4
- package/src/claude/desktop-first-party.ts +25 -8
- package/src/claude/inbound.ts +22 -0
- package/src/claude/intercept/connect-proxy.ts +37 -4
- package/src/claude/intercept/proxy-auth.ts +166 -0
- package/src/claude/intercept/runtime.ts +21 -0
- package/src/claude/intercept/settings.ts +33 -12
- package/src/claude/outbound.ts +45 -27
- package/src/cli/account-extended.ts +47 -15
- package/src/cli/account-target.ts +115 -0
- package/src/cli/account.ts +20 -28
- package/src/cli/agent.ts +10 -1
- package/src/cli/api-protocols.ts +206 -0
- package/src/cli/capabilities.ts +86 -0
- package/src/cli/combo.ts +2 -2
- package/src/cli/connect.ts +127 -3
- package/src/cli/dispatch.ts +8 -0
- package/src/cli/doctor.ts +33 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/link.ts +288 -0
- package/src/cli/registry.ts +23 -0
- package/src/cli/runtime-api.ts +79 -0
- package/src/cli/system-command.ts +21 -2
- package/src/client/connect.ts +125 -23
- package/src/client/hub-relay.ts +12 -8
- package/src/client/link-join.ts +323 -0
- package/src/client/link-relay.ts +241 -0
- package/src/client/link-state.ts +110 -0
- package/src/client/link-teardown.ts +63 -0
- package/src/client/link-tunnel.ts +465 -0
- package/src/client/machine-listener.ts +14 -5
- package/src/client/runtime.ts +91 -42
- package/src/client/state.ts +4 -0
- package/src/codex/catalog/build-entries.ts +4 -0
- package/src/codex/catalog/derive-entry.ts +5 -0
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/catalog/retained-sync.ts +4 -1
- package/src/codex/desktop-switches.ts +88 -8
- package/src/codex/inject/plan.ts +16 -3
- package/src/codex/inject.ts +3 -0
- package/src/codex/internal/catalog-writer.ts +13 -2
- package/src/combos/failover.ts +3 -2
- package/src/combos/index.ts +12 -0
- package/src/combos/jev.ts +646 -0
- package/src/combos/request.ts +5 -0
- package/src/combos/resolve.ts +17 -15
- package/src/combos/types.ts +47 -3
- package/src/config/atomic-write.ts +3 -0
- package/src/config/live-reconcile.ts +20 -4
- package/src/config/persist-unlocked.ts +20 -7
- package/src/config/schema/config-schema.ts +16 -0
- package/src/config/schema/leaf-validators.ts +38 -1
- package/src/generated/compatibility-version.json +416 -128
- package/src/integrations/omp-yaml-source.ts +109 -4
- package/src/lab/automation/orchestrator.ts +60 -4
- package/src/lab/events/limits.ts +1 -1
- package/src/lib/bounded-body.ts +55 -38
- package/src/lib/config-ownership.ts +11 -22
- package/src/lib/local-desktop-snapshot-capability.ts +56 -0
- package/src/lib/local-management-capability.ts +25 -3
- package/src/lib/optional-shutdown-hooks.ts +17 -0
- package/src/lib/state-store-registrations.ts +2 -2
- package/src/lib/windows-elevation.ts +62 -25
- package/src/lib/windows-secret-acl.ts +114 -23
- package/src/link/admission-wait.ts +73 -0
- package/src/link/compensation.ts +100 -0
- package/src/link/fingerprint.ts +21 -0
- package/src/link/paths.ts +19 -0
- package/src/link/ports.ts +12 -0
- package/src/link/routes.ts +32 -0
- package/src/link/ssh-argv.ts +170 -0
- package/src/link/ssh-config.ts +152 -0
- package/src/link/ssh-runner.ts +161 -0
- package/src/link/status-projection.ts +96 -0
- package/src/link/store.ts +139 -0
- package/src/link/supervisor.ts +404 -0
- package/src/link/tunnel-state.ts +91 -0
- package/src/protocols/baseline.ts +88 -0
- package/src/protocols/codecs/chat.ts +17 -0
- package/src/protocols/codecs/messages.ts +18 -0
- package/src/protocols/codecs/responses.ts +17 -0
- package/src/protocols/contract.ts +185 -0
- package/src/protocols/dto.ts +267 -0
- package/src/protocols/encoders/adapter-events.ts +876 -0
- package/src/protocols/encoders/chat.ts +243 -0
- package/src/protocols/encoders/messages.ts +462 -0
- package/src/protocols/envelope.ts +43 -0
- package/src/protocols/features.ts +295 -0
- package/src/protocols/guard.ts +33 -0
- package/src/protocols/opaque-state.ts +132 -0
- package/src/protocols/path.ts +39 -0
- package/src/protocols/plan-snapshot.ts +226 -0
- package/src/protocols/plan.ts +192 -0
- package/src/protocols/provider-summary.ts +101 -0
- package/src/protocols/settings.ts +118 -0
- package/src/protocols/shadow-plan.ts +36 -0
- package/src/protocols/shadow.ts +62 -0
- package/src/protocols/trace.ts +335 -0
- package/src/providers/model-rename-migration.ts +3 -3
- package/src/providers/registry/entries-core.ts +6 -1
- package/src/providers/registry/entries-extended.ts +15 -0
- package/src/providers/registry/model-ids.ts +1 -0
- package/src/providers/registry/types.ts +6 -0
- package/src/providers/registry.ts +1 -1
- package/src/remote-control/workspace-hub.ts +5 -4
- package/src/responses/apply-patch-envelope.ts +6 -1
- package/src/responses/code-mode-shell-input.ts +4 -2
- package/src/responses/freeform-wrapper-scan.ts +51 -24
- package/src/responses/progressive-freeform-input.ts +14 -20
- package/src/responses/spill-store.ts +187 -33
- package/src/responses/state/snapshot-select.ts +49 -0
- package/src/responses/state/spill-inspect.ts +46 -0
- package/src/responses/state/spill-queue.ts +16 -0
- package/src/responses/state.ts +51 -27
- package/src/server/audio-client.ts +8 -3
- package/src/server/audio-upstream.ts +9 -4
- package/src/server/auth-cors.ts +25 -8
- package/src/server/chat-completions.ts +71 -11
- package/src/server/chat-native-eligibility.ts +74 -0
- package/src/server/chat-native.ts +139 -74
- package/src/server/claude-messages.ts +140 -22
- package/src/server/hub-usage.ts +4 -3
- package/src/server/index/link-listener.ts +195 -0
- package/src/server/index/optional-listeners.ts +115 -0
- package/src/server/index/serve-options.ts +44 -10
- package/src/server/index.ts +10 -9
- package/src/server/inference/attempt.ts +51 -0
- package/src/server/inference/client-encoder-delivery.ts +235 -0
- package/src/server/inference/client-wire-log.ts +49 -0
- package/src/server/inference/client-wire.ts +69 -0
- package/src/server/inference/context.ts +15 -0
- package/src/server/inference/final-log.ts +37 -0
- package/src/server/local-desktop-snapshot-auth.ts +57 -0
- package/src/server/management/agent-settings-routes.ts +15 -13
- package/src/server/management/api-access.ts +11 -1
- package/src/server/management/config-routes.ts +3 -6
- package/src/server/management/context.ts +25 -0
- package/src/server/management/link-routes.ts +535 -0
- package/src/server/management/logs-usage-routes.ts +31 -1
- package/src/server/management/native-integration-routes.ts +6 -11
- package/src/server/management/oauth-account-routes.ts +42 -16
- package/src/server/management/protocol-routes.ts +159 -0
- package/src/server/management/protocol-settings-patch.ts +145 -0
- package/src/server/management/provider-patch-transaction.ts +61 -0
- package/src/server/management/provider-routes.ts +39 -18
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sidebar-routes.ts +8 -3
- package/src/server/management/system-routes.ts +18 -1
- package/src/server/management/usage-aggregate-cache.ts +206 -6
- package/src/server/management-api.ts +26 -1
- package/src/server/management-auth.ts +19 -5
- package/src/server/messages-native-eligibility.ts +161 -0
- package/src/server/messages-native-oauth.ts +149 -0
- package/src/server/messages-native.ts +790 -0
- package/src/server/relay.ts +9 -0
- package/src/server/request-log-filter.ts +92 -0
- package/src/server/request-log.ts +31 -70
- package/src/server/responses/adapter-delivery.ts +43 -16
- package/src/server/responses/agent-task-recovery.ts +90 -22
- package/src/server/responses/codex-ws-exchange.ts +4 -0
- package/src/server/responses/codex-ws-wire.ts +10 -1
- package/src/server/responses/core-combo-native.ts +323 -0
- package/src/server/responses/core-combo.ts +224 -13
- package/src/server/responses/core-options.ts +22 -0
- package/src/server/responses/core.ts +14 -26
- package/src/server/responses/request-sidecar-auth.ts +8 -3
- package/src/server/responses/run-turn-execution.ts +257 -63
- package/src/server/responses/sidecar-execution.ts +7 -4
- package/src/server/system-env.ts +7 -0
- package/src/service/diagnostics.ts +7 -1
- package/src/service/launchd.ts +26 -37
- package/src/service/windows-ops.ts +24 -20
- package/src/types/config.ts +58 -1
- package/src/types.ts +2 -0
- package/src/update/transactional-install.d.mts +0 -1
- package/src/update/transactional-install.mjs +17 -17
- package/src/usage/jev-stats.ts +495 -0
- package/src/usage/log.ts +24 -3
- package/src/web-search/run-turn-loop.ts +568 -0
- package/gui/dist/assets/App-8NMiZxT0.css +0 -1
- package/gui/dist/assets/App-DGLte6IR.js +0 -50
- package/gui/dist/assets/index--EWgGQvZ.css +0 -1
- package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
setDebugSettings,
|
|
60
60
|
type DebugFlag,
|
|
61
61
|
} from "../../lib/debug-settings";
|
|
62
|
-
import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
|
|
62
|
+
import type { OcxApiKeyEntry, OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
|
|
63
63
|
import { drainAndShutdown } from "../lifecycle";
|
|
64
64
|
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
|
|
65
65
|
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
|
|
@@ -147,6 +147,45 @@ function validateKeyName(
|
|
|
147
147
|
return { value };
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
export type IssuedApiKey = Pick<OcxApiKeyEntry, "id" | "name" | "key" | "createdAt">;
|
|
151
|
+
|
|
152
|
+
/** Shared one-time data-key issuance used by the dashboard and link transactions. */
|
|
153
|
+
export function issueApiKeyInProcess(config: OcxConfig, name: string): IssuedApiKey {
|
|
154
|
+
const checked = validateKeyName(name, { required: true });
|
|
155
|
+
if ("error" in checked) throw new Error(checked.error);
|
|
156
|
+
const entry: IssuedApiKey = {
|
|
157
|
+
id: randomUUID(),
|
|
158
|
+
name: checked.value,
|
|
159
|
+
key: `ocx_data_${randomBytes(20).toString("hex")}`,
|
|
160
|
+
createdAt: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
const previous = config.apiKeys;
|
|
163
|
+
config.apiKeys = [...(previous ?? []), entry];
|
|
164
|
+
try {
|
|
165
|
+
saveConfigPreservingClaudeCode(config);
|
|
166
|
+
reconcileLiveStateStores();
|
|
167
|
+
} catch (error) {
|
|
168
|
+
config.apiKeys = previous;
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
return entry;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Revoke and persist one data key; callers can retain their own record on false. */
|
|
175
|
+
export function revokeApiKeyInProcess(config: OcxConfig, id: string): boolean {
|
|
176
|
+
const before = config.apiKeys ?? [];
|
|
177
|
+
if (!before.some(key => key.id === id)) return false;
|
|
178
|
+
config.apiKeys = before.filter(key => key.id !== id);
|
|
179
|
+
try {
|
|
180
|
+
saveConfigPreservingClaudeCode(config);
|
|
181
|
+
reconcileLiveStateStores();
|
|
182
|
+
} catch (error) {
|
|
183
|
+
config.apiKeys = before;
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
150
189
|
function metaMuseConsentRequired(provider: string, principal: ManagementContext["principal"]): Response | null {
|
|
151
190
|
if (provider !== "meta-muse" || principal === "gui-session") return null;
|
|
152
191
|
return jsonResponse({
|
|
@@ -955,16 +994,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
955
994
|
const nameField = validateKeyName(body.name, { required: false });
|
|
956
995
|
if ("error" in nameField) return jsonResponse({ error: nameField.error }, 400, req, config);
|
|
957
996
|
const name = nameField.value || "default";
|
|
958
|
-
|
|
959
|
-
// provider API key into the input, which was never needed for uniqueness and
|
|
960
|
-
// made this secret's safety argument depend on string concatenation rather
|
|
961
|
-
// than the RNG. 20 bytes is the same 40 hex characters as before, so nothing
|
|
962
|
-
// that pattern-matches the key shape changes.
|
|
963
|
-
const key = "ocx_data_" + randomBytes(20).toString("hex");
|
|
964
|
-
const entry = { id: randomUUID(), name, key, createdAt: new Date().toISOString() };
|
|
965
|
-
config.apiKeys = [...(config.apiKeys ?? []), entry];
|
|
966
|
-
saveConfigPreservingClaudeCode(config);
|
|
967
|
-
reconcileLiveStateStores();
|
|
997
|
+
const entry = issueApiKeyInProcess(config, name); // shared helper uses randomBytes(20)
|
|
968
998
|
return jsonResponse({ id: entry.id, name: entry.name, key: entry.key, createdAt: entry.createdAt }, 201, req, config);
|
|
969
999
|
}
|
|
970
1000
|
|
|
@@ -1021,12 +1051,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
1021
1051
|
const body = await readJsonBody(req);
|
|
1022
1052
|
if (!body) return jsonResponse({ error: "invalid body" }, 400, req, config);
|
|
1023
1053
|
if (typeof body.id !== "string" || !body.id) return jsonResponse({ error: "id required" }, 400, req, config);
|
|
1024
|
-
const before = (config.apiKeys ?? []).length;
|
|
1025
|
-
config.apiKeys = (config.apiKeys ?? []).filter(k => k.id !== body.id);
|
|
1026
1054
|
// A stale id must not read as a successful revocation.
|
|
1027
|
-
if (config.
|
|
1028
|
-
saveConfigPreservingClaudeCode(config);
|
|
1029
|
-
reconcileLiveStateStores();
|
|
1055
|
+
if (!revokeApiKeyInProcess(config, body.id)) return jsonResponse({ error: "key not found" }, 404, req, config);
|
|
1030
1056
|
return jsonResponse({ success: true }, 200, req, config);
|
|
1031
1057
|
}
|
|
1032
1058
|
return null;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol vocabulary and request-path preview for the dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Loaded on demand from src/server/management-api.ts, like the other optional namespaces:
|
|
5
|
+
* the planner reaches the router and the ingress eligibility rules, and a static import
|
|
6
|
+
* would put them on every dashboard request.
|
|
7
|
+
*
|
|
8
|
+
* GET (optionally with `?provider=<name>`, src/protocols/provider-summary.ts) and the plan
|
|
9
|
+
* preview are read-only. The preview is computed from config alone
|
|
10
|
+
* (src/protocols/plan-snapshot.ts): it sends nothing upstream, advances no combo state, and
|
|
11
|
+
* never logs its input. PATCH /api/protocols/settings is the one writer; it validates in
|
|
12
|
+
* src/server/management/protocol-settings-patch.ts, persists through the locked
|
|
13
|
+
* saveConfigPreservingClaudeCode, and answers with the fresh GET shape. Authentication is
|
|
14
|
+
* inherited from the management chain.
|
|
15
|
+
*/
|
|
16
|
+
import { jsonResponse } from "../auth-cors";
|
|
17
|
+
import { isProtocol, PROTOCOL_CONTRACT_VERSION } from "../../protocols/contract";
|
|
18
|
+
import { isProtocolFeature, PROTOCOL_FEATURES, type ProtocolFeature } from "../../protocols/features";
|
|
19
|
+
import { previewProtocolPlan, type ProtocolPlanRequest } from "../../protocols/plan-snapshot";
|
|
20
|
+
import { buildProtocolProviderSummary } from "../../protocols/provider-summary";
|
|
21
|
+
import { protocolPolicyRevision, resolveApiSurfaceSettings, resolveProtocolSettings } from "../../protocols/settings";
|
|
22
|
+
import type { OcxConfig } from "../../types";
|
|
23
|
+
import type { ManagementContext } from "./context";
|
|
24
|
+
import { readManagementJsonBodyOr } from "./body";
|
|
25
|
+
import {
|
|
26
|
+
applyProtocolSettingsPatch,
|
|
27
|
+
parseProtocolSettingsPatch,
|
|
28
|
+
restoreProtocolSettings,
|
|
29
|
+
snapshotProtocolSettings,
|
|
30
|
+
} from "./protocol-settings-patch";
|
|
31
|
+
|
|
32
|
+
export const PROTOCOL_PLAN_LIMITS = { modelLength: 200, features: 24 } as const;
|
|
33
|
+
export const PROTOCOL_PROVIDER_QUERY_LIMIT = 200;
|
|
34
|
+
|
|
35
|
+
const PLAN_BODY_KEYS = new Set(["model", "inbound", "features"]);
|
|
36
|
+
const INVALID_BODY = Symbol("invalid-body");
|
|
37
|
+
|
|
38
|
+
type ParsedPlanBody = { ok: true; request: ProtocolPlanRequest } | { ok: false; code: string; message: string };
|
|
39
|
+
|
|
40
|
+
function invalid(code: string, message: string): ParsedPlanBody {
|
|
41
|
+
return { ok: false, code, message };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Validate a plan request body. Messages name the field, never echo its value. */
|
|
45
|
+
export function parseProtocolPlanBody(body: unknown): ParsedPlanBody {
|
|
46
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return invalid("invalid_body", "body must be a JSON object");
|
|
47
|
+
const record = body as Record<string, unknown>;
|
|
48
|
+
for (const key of Object.keys(record)) {
|
|
49
|
+
if (!PLAN_BODY_KEYS.has(key)) return invalid("unknown_field", "body accepts only model, inbound and features");
|
|
50
|
+
}
|
|
51
|
+
const model = typeof record.model === "string" ? record.model.trim() : "";
|
|
52
|
+
if (!model || model.length > PROTOCOL_PLAN_LIMITS.modelLength || /[\u0000-\u001f\u007f]/.test(model)) {
|
|
53
|
+
return invalid("invalid_model", `model must be a non-empty string of at most ${PROTOCOL_PLAN_LIMITS.modelLength} characters`);
|
|
54
|
+
}
|
|
55
|
+
if (!isProtocol(record.inbound)) return invalid("invalid_inbound", "inbound must be responses, chat or messages");
|
|
56
|
+
let features: ProtocolFeature[] = [];
|
|
57
|
+
if (record.features !== undefined) {
|
|
58
|
+
if (!Array.isArray(record.features) || record.features.length > PROTOCOL_PLAN_LIMITS.features) {
|
|
59
|
+
return invalid("invalid_features", `features must be an array of at most ${PROTOCOL_PLAN_LIMITS.features} entries`);
|
|
60
|
+
}
|
|
61
|
+
if (!record.features.every(isProtocolFeature)) return invalid("invalid_features", "features contains an unknown feature");
|
|
62
|
+
features = [...new Set(record.features)];
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, request: { model, inbound: record.inbound, features } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function protocolInfo(config: OcxConfig) {
|
|
68
|
+
return {
|
|
69
|
+
schemaVersion: 1,
|
|
70
|
+
contractVersion: PROTOCOL_CONTRACT_VERSION,
|
|
71
|
+
policyRevision: protocolPolicyRevision(config),
|
|
72
|
+
surfaces: resolveApiSurfaceSettings(config),
|
|
73
|
+
settings: resolveProtocolSettings(config),
|
|
74
|
+
features: PROTOCOL_FEATURES,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `GET /api/protocols?provider=<name>`: the usual body plus the provider's wire summary. The
|
|
80
|
+
* name is bounded and must name a configured provider; neither error echoes it back.
|
|
81
|
+
*/
|
|
82
|
+
function protocolInfoForProvider(ctx: ManagementContext, values: string[]): Response {
|
|
83
|
+
const { req, config } = ctx;
|
|
84
|
+
const name = values.length === 1 ? values[0]!.trim() : "";
|
|
85
|
+
if (!name || name.length > PROTOCOL_PROVIDER_QUERY_LIMIT || /[\u0000-\u001f\u007f]/.test(name)) {
|
|
86
|
+
const message = `provider must be one non-empty name of at most ${PROTOCOL_PROVIDER_QUERY_LIMIT} characters`;
|
|
87
|
+
return jsonResponse({ error: { code: "invalid_provider", message } }, 400, req, config);
|
|
88
|
+
}
|
|
89
|
+
const provider = buildProtocolProviderSummary(config, name);
|
|
90
|
+
if (!provider) return jsonResponse({ error: { code: "unknown_provider", message: "no provider with that name" } }, 404, req, config);
|
|
91
|
+
return jsonResponse({ ...protocolInfo(config), provider }, 200, req, config);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Only SQLITE_BUSY is contention worth retrying; any other lock failure repeats forever. */
|
|
95
|
+
function isConfigLockContention(error: unknown): boolean {
|
|
96
|
+
if (!error || typeof error !== "object") return false;
|
|
97
|
+
if ((error as { code?: unknown }).code !== "CONFIG_MUTATION_LOCK_UNAVAILABLE") return false;
|
|
98
|
+
return (error as { cause?: { code?: unknown } }).cause?.code === "SQLITE_BUSY";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function patchProtocolSettings(ctx: ManagementContext): Promise<Response> {
|
|
102
|
+
const { req, config } = ctx;
|
|
103
|
+
const body = await readManagementJsonBodyOr(req, INVALID_BODY);
|
|
104
|
+
const parsed = body === INVALID_BODY
|
|
105
|
+
? { ok: false as const, code: "invalid_json", message: "body must be valid JSON" }
|
|
106
|
+
: parseProtocolSettingsPatch(body);
|
|
107
|
+
if (!parsed.ok) return jsonResponse({ error: { code: parsed.code, message: parsed.message } }, 400, req, config);
|
|
108
|
+
|
|
109
|
+
const snapshot = snapshotProtocolSettings(config);
|
|
110
|
+
const applied = applyProtocolSettingsPatch(config, parsed.patch);
|
|
111
|
+
if (!applied.ok) {
|
|
112
|
+
restoreProtocolSettings(config, snapshot);
|
|
113
|
+
return jsonResponse({ error: { code: applied.code, message: applied.message } }, 400, req, config);
|
|
114
|
+
}
|
|
115
|
+
// `deps.` first: route tests with an in-memory fixture must never write the real config.
|
|
116
|
+
const persist = ctx.deps.saveConfigPreservingClaudeCode
|
|
117
|
+
?? (await import("../../config")).saveConfigPreservingClaudeCode;
|
|
118
|
+
try {
|
|
119
|
+
persist(config);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
// Undo in memory too: a live config that serves a state the file does not hold would
|
|
122
|
+
// reopen (or keep closed) the surface only until the next restart.
|
|
123
|
+
restoreProtocolSettings(config, snapshot);
|
|
124
|
+
return isConfigLockContention(error)
|
|
125
|
+
? jsonResponse({ error: { code: "config_busy", message: "Another process is saving the configuration. Try again in a moment." } }, 409, req, config)
|
|
126
|
+
: jsonResponse({ error: { code: "write_failed", message: "The configuration could not be saved." } }, 500, req, config);
|
|
127
|
+
}
|
|
128
|
+
// Closing Messages also turned the Claude integration off; prune its agent definitions the
|
|
129
|
+
// way the Claude page toggle does.
|
|
130
|
+
if (applied.claudeCodeChanged) await ctx.syncClaudeAgentDefsBestEffort?.();
|
|
131
|
+
return jsonResponse(protocolInfo(config), 200, req, config);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function handleProtocolRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
135
|
+
const { url, req, config } = ctx;
|
|
136
|
+
|
|
137
|
+
if (url.pathname === "/api/protocols") {
|
|
138
|
+
if (req.method !== "GET") return null;
|
|
139
|
+
if (!url.searchParams.has("provider")) return jsonResponse(protocolInfo(config), 200, req, config);
|
|
140
|
+
return protocolInfoForProvider(ctx, url.searchParams.getAll("provider"));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (url.pathname === "/api/protocols/settings") {
|
|
144
|
+
if (req.method !== "PATCH") return null;
|
|
145
|
+
return patchProtocolSettings(ctx);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (url.pathname === "/api/protocols/plan") {
|
|
149
|
+
if (req.method !== "POST") return null;
|
|
150
|
+
const body = await readManagementJsonBodyOr(req, INVALID_BODY);
|
|
151
|
+
const parsed = body === INVALID_BODY ? invalid("invalid_json", "body must be valid JSON") : parseProtocolPlanBody(body);
|
|
152
|
+
if (!parsed.ok) {
|
|
153
|
+
return jsonResponse({ error: { code: parsed.code, message: parsed.message } }, 400, req, config);
|
|
154
|
+
}
|
|
155
|
+
return jsonResponse(previewProtocolPlan(config, parsed.request), 200, req, config);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation and in-memory application of `PATCH /api/protocols/settings`.
|
|
3
|
+
*
|
|
4
|
+
* Pure with respect to I/O: the route persists through `saveConfigPreservingClaudeCode` and
|
|
5
|
+
* restores the snapshot taken here when the save fails, so a refused write never leaves the
|
|
6
|
+
* live config serving a state the file does not hold.
|
|
7
|
+
*
|
|
8
|
+
* The one asymmetric rule is the Messages surface. Closing it writes
|
|
9
|
+
* `apiSurfaces.messages.enabled = false` AND `claudeCode.enabled = false` in the same save:
|
|
10
|
+
* a binary older than `apiSurfaces` reads only `claudeCode.enabled`, so a rollback after a
|
|
11
|
+
* close must still find the endpoint closed. Opening writes only the explicit surface value;
|
|
12
|
+
* an older binary then keeps reading `claudeCode.enabled`, which errs closed.
|
|
13
|
+
*/
|
|
14
|
+
import { commitClaudeCodeBlock } from "../../claude/claude-code-block";
|
|
15
|
+
import type { ProtocolRolloutSettings, UnrepresentablePolicy } from "../../protocols/settings";
|
|
16
|
+
import type { OcxConfig } from "../../types";
|
|
17
|
+
|
|
18
|
+
export interface ProtocolSettingsPatch {
|
|
19
|
+
messagesEnabled?: boolean;
|
|
20
|
+
unrepresentable?: UnrepresentablePolicy;
|
|
21
|
+
rollout?: Partial<ProtocolRolloutSettings>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ParsedProtocolSettingsPatch =
|
|
25
|
+
| { ok: true; patch: ProtocolSettingsPatch }
|
|
26
|
+
| { ok: false; code: string; message: string };
|
|
27
|
+
|
|
28
|
+
const PATCH_KEYS = new Set(["messagesEnabled", "unrepresentable", "rollout"]);
|
|
29
|
+
export const PROTOCOL_ROLLOUT_KEYS = [
|
|
30
|
+
"nativeChatCombos",
|
|
31
|
+
"managedMessagesNative",
|
|
32
|
+
"managedMessagesNativeOAuth",
|
|
33
|
+
"directEncoders",
|
|
34
|
+
"shadowPlan",
|
|
35
|
+
] as const satisfies readonly (keyof ProtocolRolloutSettings)[];
|
|
36
|
+
const ROLLOUT_KEYS = new Set<string>(PROTOCOL_ROLLOUT_KEYS);
|
|
37
|
+
|
|
38
|
+
type Rec = Record<string, unknown>;
|
|
39
|
+
function isRec(value: unknown): value is Rec {
|
|
40
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function invalid(code: string, message: string): ParsedProtocolSettingsPatch {
|
|
44
|
+
return { ok: false, code, message };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Strict: unknown keys and wrong types are refused; messages name the field, never the value. */
|
|
48
|
+
export function parseProtocolSettingsPatch(body: unknown): ParsedProtocolSettingsPatch {
|
|
49
|
+
if (!isRec(body)) return invalid("invalid_body", "body must be a JSON object");
|
|
50
|
+
const keys = Object.keys(body);
|
|
51
|
+
if (keys.length === 0) return invalid("empty_body", "body must set messagesEnabled, unrepresentable or rollout");
|
|
52
|
+
for (const key of keys) {
|
|
53
|
+
if (!PATCH_KEYS.has(key)) return invalid("unknown_field", "body accepts only messagesEnabled, unrepresentable and rollout");
|
|
54
|
+
}
|
|
55
|
+
const patch: ProtocolSettingsPatch = {};
|
|
56
|
+
if (body.messagesEnabled !== undefined) {
|
|
57
|
+
if (typeof body.messagesEnabled !== "boolean") return invalid("invalid_messages_enabled", "messagesEnabled must be a boolean");
|
|
58
|
+
patch.messagesEnabled = body.messagesEnabled;
|
|
59
|
+
}
|
|
60
|
+
if (body.unrepresentable !== undefined) {
|
|
61
|
+
if (body.unrepresentable !== "legacy" && body.unrepresentable !== "reject") {
|
|
62
|
+
return invalid("invalid_unrepresentable", "unrepresentable must be \"legacy\" or \"reject\"");
|
|
63
|
+
}
|
|
64
|
+
patch.unrepresentable = body.unrepresentable;
|
|
65
|
+
}
|
|
66
|
+
if (body.rollout !== undefined) {
|
|
67
|
+
if (!isRec(body.rollout)) return invalid("invalid_rollout", "rollout must be an object");
|
|
68
|
+
const rollout: Partial<ProtocolRolloutSettings> = {};
|
|
69
|
+
for (const [key, value] of Object.entries(body.rollout)) {
|
|
70
|
+
if (!ROLLOUT_KEYS.has(key)) return invalid("unknown_rollout_field", `rollout accepts only ${PROTOCOL_ROLLOUT_KEYS.join(", ")}`);
|
|
71
|
+
if (typeof value !== "boolean") return invalid("invalid_rollout", "rollout values must be booleans");
|
|
72
|
+
rollout[key as keyof ProtocolRolloutSettings] = value;
|
|
73
|
+
}
|
|
74
|
+
patch.rollout = rollout;
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, patch };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The config subtrees a patch may touch, captured so a failed save can be undone. */
|
|
80
|
+
export interface ProtocolSettingsSnapshot {
|
|
81
|
+
apiSurfaces: unknown;
|
|
82
|
+
protocols: unknown;
|
|
83
|
+
claudeCode: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function snapshotProtocolSettings(config: OcxConfig): ProtocolSettingsSnapshot {
|
|
87
|
+
return {
|
|
88
|
+
apiSurfaces: structuredClone(config.apiSurfaces),
|
|
89
|
+
protocols: structuredClone(config.protocols),
|
|
90
|
+
claudeCode: structuredClone(config.claudeCode),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function restoreProtocolSettings(config: OcxConfig, snapshot: ProtocolSettingsSnapshot): void {
|
|
95
|
+
const target = config as unknown as Rec;
|
|
96
|
+
for (const key of ["apiSurfaces", "protocols", "claudeCode"] as const) {
|
|
97
|
+
if (snapshot[key] === undefined) delete target[key];
|
|
98
|
+
else target[key] = snapshot[key];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type ApplyProtocolSettingsResult =
|
|
103
|
+
| { ok: true; claudeCodeChanged: boolean }
|
|
104
|
+
| { ok: false; code: string; message: string };
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Apply a validated patch to the live config. Checks that depend on the merged state run
|
|
108
|
+
* before anything is written, so a refusal leaves the config untouched.
|
|
109
|
+
*/
|
|
110
|
+
export function applyProtocolSettingsPatch(config: OcxConfig, patch: ProtocolSettingsPatch): ApplyProtocolSettingsResult {
|
|
111
|
+
const protocols: Rec = isRec(config.protocols) ? { ...config.protocols } : {};
|
|
112
|
+
if (patch.rollout) {
|
|
113
|
+
const rollout: Rec = { ...(isRec(protocols.rollout) ? protocols.rollout : {}), ...patch.rollout };
|
|
114
|
+
// The OAuth extension is meaningless without the key-auth lane it extends; the resolver
|
|
115
|
+
// would silently read it as off, so refuse turning it on alone instead of storing a switch
|
|
116
|
+
// that does nothing. Turning the key-auth lane off leaves the extension inert, not wrong.
|
|
117
|
+
if (patch.rollout.managedMessagesNativeOAuth === true && rollout.managedMessagesNative !== true) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
code: "rollout_dependency",
|
|
121
|
+
message: "rollout.managedMessagesNativeOAuth requires rollout.managedMessagesNative",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
protocols.rollout = rollout;
|
|
125
|
+
}
|
|
126
|
+
if (patch.unrepresentable !== undefined) protocols.unrepresentable = patch.unrepresentable;
|
|
127
|
+
if (patch.unrepresentable !== undefined || patch.rollout) {
|
|
128
|
+
config.protocols = protocols as OcxConfig["protocols"];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let claudeCodeChanged = false;
|
|
132
|
+
if (patch.messagesEnabled !== undefined) {
|
|
133
|
+
// A malformed block is replaced, not merged: the explicit value is what the operator asked for.
|
|
134
|
+
const surfaces: Rec = isRec(config.apiSurfaces) ? { ...config.apiSurfaces } : {};
|
|
135
|
+
const messages: Rec = isRec(surfaces.messages) ? { ...surfaces.messages } : {};
|
|
136
|
+
messages.enabled = patch.messagesEnabled;
|
|
137
|
+
surfaces.messages = messages;
|
|
138
|
+
config.apiSurfaces = surfaces as OcxConfig["apiSurfaces"];
|
|
139
|
+
if (!patch.messagesEnabled && config.claudeCode?.enabled !== false) {
|
|
140
|
+
commitClaudeCodeBlock(config, { ...(config.claudeCode ?? {}), enabled: false });
|
|
141
|
+
claudeCodeChanged = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, claudeCodeChanged };
|
|
145
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { OcxConfig } from "../../types";
|
|
2
|
+
import { withConfigMutationLockSync } from "../../config/mutation-lock";
|
|
3
|
+
import { captureConfigTopLevelRollback } from "../../config/rebase-provenance";
|
|
4
|
+
import { ConfigWritePublishedError } from "../../config/persist-unlocked";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Persistence rebases nested records and arrays in place before writing. Keep their
|
|
8
|
+
* identities and descriptors so a failed write also restores references held by routing.
|
|
9
|
+
* Only plain config containers are traversed; opaque runtime values and accessors are
|
|
10
|
+
* retained as descriptors, never cloned or invoked while taking the snapshot.
|
|
11
|
+
*/
|
|
12
|
+
function captureConfigGraphRollback(config: OcxConfig): () => void {
|
|
13
|
+
const snapshots = new Map<object, PropertyDescriptorMap>();
|
|
14
|
+
const visit = (value: unknown): void => {
|
|
15
|
+
if (value === null || typeof value !== "object" || snapshots.has(value)) return;
|
|
16
|
+
const prototype = Object.getPrototypeOf(value);
|
|
17
|
+
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return;
|
|
18
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
19
|
+
snapshots.set(value, descriptors);
|
|
20
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
21
|
+
const descriptor = descriptors[key as keyof typeof descriptors]!;
|
|
22
|
+
if ("value" in descriptor) visit(descriptor.value);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
visit(config);
|
|
26
|
+
const restoreProvenance = captureConfigTopLevelRollback(config, []);
|
|
27
|
+
return () => {
|
|
28
|
+
for (const [value, descriptors] of snapshots) {
|
|
29
|
+
const keys = Reflect.ownKeys(value);
|
|
30
|
+
const originalKeys = Reflect.ownKeys(descriptors);
|
|
31
|
+
// Re-defining a deleted provider appends it, changing route tie-breaking.
|
|
32
|
+
// Rebuild record properties in snapshot order; array indices/length retain
|
|
33
|
+
// their descriptor-based restoration and every container keeps its identity.
|
|
34
|
+
const reordered = !Array.isArray(value)
|
|
35
|
+
&& (keys.length !== originalKeys.length || keys.some((key, index) => key !== originalKeys[index]));
|
|
36
|
+
for (const key of keys) {
|
|
37
|
+
if (reordered || !Object.hasOwn(descriptors, key)) Reflect.deleteProperty(value, key);
|
|
38
|
+
}
|
|
39
|
+
Object.defineProperties(value, descriptors);
|
|
40
|
+
}
|
|
41
|
+
restoreProvenance();
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** No await may separate mutation, persistence, and rollback inside this lock. */
|
|
46
|
+
export function commitProviderPatch(
|
|
47
|
+
config: OcxConfig,
|
|
48
|
+
mutate: () => void,
|
|
49
|
+
save: (config: OcxConfig) => void,
|
|
50
|
+
): void {
|
|
51
|
+
withConfigMutationLockSync(() => {
|
|
52
|
+
const rollback = captureConfigGraphRollback(config);
|
|
53
|
+
try {
|
|
54
|
+
mutate();
|
|
55
|
+
save(config);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!(error instanceof ConfigWritePublishedError)) rollback();
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
submitManualLoginCode,
|
|
37
37
|
upsertOAuthProvider,
|
|
38
38
|
} from "../../oauth";
|
|
39
|
+
import { commitProviderPatch } from "./provider-patch-transaction";
|
|
39
40
|
import { captureConfigTopLevelRollback } from "../../config/rebase-provenance";
|
|
40
41
|
import { canonicalAutoReviewModelKey, mergeModelPinnedEfforts, modelPinnedEffortsConfigError, pinnedReasoningEffortConfigError } from "../../config/provider-validation";
|
|
41
42
|
import { replaceProviderAccountSet } from "../../oauth/store";
|
|
@@ -73,6 +74,7 @@ import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaRepo
|
|
|
73
74
|
import { getCachedProviderRoutingQuota } from "../../providers/quota-routing-cache";
|
|
74
75
|
import { PROVIDER_QUOTA_MAX_AGE_MS, type ProviderRoutingQuota } from "../../providers/quota-types";
|
|
75
76
|
import { cachedProviderQuotaIsExhausted } from "../../combos/resolve";
|
|
77
|
+
import { resolveJevDecision } from "../../combos/jev";
|
|
76
78
|
import { clearKeyCooldowns, forgetApiKeyRotationCursor } from "../../providers/key-failover";
|
|
77
79
|
import { providerRequestPacingStatus } from "../../providers/request-pacing";
|
|
78
80
|
import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
@@ -1423,9 +1425,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1423
1425
|
if (!provider || !isCanonicalOpenAiForwardProvider(provider)) {
|
|
1424
1426
|
return jsonResponse({ error: "provider openai must be the canonical built-in provider" }, 400);
|
|
1425
1427
|
}
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1428
|
+
commitProviderPatch(config, () => {
|
|
1429
|
+
config.providers.openai = { ...provider, codexAccountMode: mode };
|
|
1430
|
+
}, deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode);
|
|
1429
1431
|
reconcileLiveStateStores();
|
|
1430
1432
|
(deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
|
|
1431
1433
|
(deps.clearThreadAccountMap ?? clearThreadAccountMap)();
|
|
@@ -1450,9 +1452,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1450
1452
|
if (config.providers[name]!.disabled) {
|
|
1451
1453
|
return jsonResponse({ error: "cannot set a disabled provider as default", code: "default_provider_disabled" }, 400);
|
|
1452
1454
|
}
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1455
|
+
commitProviderPatch(config, () => {
|
|
1456
|
+
config.defaultProvider = name;
|
|
1457
|
+
}, deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode);
|
|
1456
1458
|
reconcileLiveStateStores();
|
|
1457
1459
|
return jsonResponse({ success: true, name, defaultProvider: name });
|
|
1458
1460
|
}
|
|
@@ -1557,19 +1559,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1557
1559
|
const validation = validateConfigCandidate({ ...config, providers: { ...config.providers, [name]: candidate } });
|
|
1558
1560
|
if (!validation.ok) { replayError = validation.error; return; }
|
|
1559
1561
|
}
|
|
1560
|
-
|
|
1561
|
-
const rollback = pinsTouched ? captureConfigTopLevelRollback(config, []) : undefined;
|
|
1562
|
-
try {
|
|
1562
|
+
commitProviderPatch(config, () => {
|
|
1563
1563
|
config.providers[name] = candidate;
|
|
1564
|
-
|
|
1565
|
-
} catch (error) {
|
|
1566
|
-
if (rollback) {
|
|
1567
|
-
if (previous) Object.defineProperty(config.providers, name, previous);
|
|
1568
|
-
else delete config.providers[name];
|
|
1569
|
-
rollback();
|
|
1570
|
-
}
|
|
1571
|
-
throw error;
|
|
1572
|
-
}
|
|
1564
|
+
}, deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode);
|
|
1573
1565
|
});
|
|
1574
1566
|
if (replayError !== undefined) return jsonResponse({ error: replayError }, 409);
|
|
1575
1567
|
reconcileLiveStateStores();
|
|
@@ -1611,6 +1603,35 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1611
1603
|
message: "Passthrough provider is configured (forwards your Codex login; no upstream /models).",
|
|
1612
1604
|
});
|
|
1613
1605
|
}
|
|
1606
|
+
if (name === "jev" && providerMatchesRegistryTransport(name, prov)) {
|
|
1607
|
+
const probe = { targetKey: "jev/probe", effort: null } as const;
|
|
1608
|
+
const decision = await resolveJevDecision({
|
|
1609
|
+
body: { input: "Verify the configured TypeSafe JEV decision service." },
|
|
1610
|
+
candidates: [{
|
|
1611
|
+
key: probe.targetKey,
|
|
1612
|
+
provider: "jev",
|
|
1613
|
+
model: "jev-latest",
|
|
1614
|
+
reasoningEfforts: [],
|
|
1615
|
+
}],
|
|
1616
|
+
fallback: probe,
|
|
1617
|
+
config,
|
|
1618
|
+
signal: req.signal,
|
|
1619
|
+
});
|
|
1620
|
+
if (decision.gate === "apply") {
|
|
1621
|
+
return jsonResponse({
|
|
1622
|
+
ok: true,
|
|
1623
|
+
latencyMs: decision.latencyMs,
|
|
1624
|
+
message: "Connected. TypeSafe JEV answered a decision probe.",
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
return jsonResponse({
|
|
1628
|
+
ok: false,
|
|
1629
|
+
latencyMs: decision.latencyMs,
|
|
1630
|
+
error: decision.gate === "missing_key"
|
|
1631
|
+
? "TypeSafe JEV API key is not configured"
|
|
1632
|
+
: `TypeSafe JEV decision probe failed (${decision.gate})`,
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1614
1635
|
if (prov.liveModels === false) {
|
|
1615
1636
|
// A static catalog has no live discovery endpoint to test. This is neither
|
|
1616
1637
|
// positive connectivity evidence nor an outage, and it must stay before
|
|
@@ -337,6 +337,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
337
337
|
// server/management/quota-reset-routes
|
|
338
338
|
{ method: "GET", path: "/api/quota-resets", module: "server/management/quota-reset-routes", mutates: false, mechanism: "negated-guard" },
|
|
339
339
|
// server/management/workflow-budget-routes
|
|
340
|
+
{ method: "GET", path: "/api/protocols", module: "server/management/protocol-routes", mutates: false },
|
|
341
|
+
{ method: "POST", path: "/api/protocols/plan", module: "server/management/protocol-routes", mutates: false },
|
|
342
|
+
{ method: "PATCH", path: "/api/protocols/settings", module: "server/management/protocol-routes", mutates: true },
|
|
340
343
|
{ method: "GET", path: "/api/workflow-budget", module: "server/management/workflow-budget-routes", mutates: false, exempt: { reason: "deferred-verb", why: "Reading a root's live budget is owed a CLI verb -- an operator staring at a 429 is usually already in a terminal -- but the ledger is process memory with no local transport to read it through, so the verb has to be an HTTP call the CLI does not yet make.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md" } },
|
|
341
344
|
{ method: "POST", path: "/api/workflow-budget/clear", module: "server/management/workflow-budget-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Clearing one root is owed the same verb as the read above and for the same reason. It is deliberately not shipped as a verb in this work-phase: the read comes first, because an operator who cannot see which ceiling fired has no basis for deciding to forgive it.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md" } },
|
|
342
345
|
// server/management/request-history-routes
|
|
@@ -363,6 +366,15 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
363
366
|
{ method: "POST", path: "/api/storage/codex-logs/protect", module: "server/management/storage-log-guard-routes", mutates: true },
|
|
364
367
|
{ method: "POST", path: "/api/storage/codex-logs/repair", module: "server/management/storage-log-guard-routes", mutates: true },
|
|
365
368
|
{ method: "POST", path: "/api/storage/codex-logs/unprotect", module: "server/management/storage-log-guard-routes", mutates: true },
|
|
369
|
+
// server/management/link-routes
|
|
370
|
+
{ method: "GET", path: "/api/link/status", module: "server/management/link-routes", mutates: false },
|
|
371
|
+
{ method: "GET", path: "/api/link/candidates", module: "server/management/link-routes", mutates: false, exempt: { reason: "session-only", why: "SSH candidates are a dashboard pairing surface and are withheld from admin-token and Tailscale identity sessions." } },
|
|
372
|
+
{ method: "POST", path: "/api/link/probe", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "SSH probing and host-key presentation are part of the interactive pairing consent flow." } },
|
|
373
|
+
{ method: "POST", path: "/api/link/confirm-host", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Persisting a host key requires the paired dashboard session that saw the fingerprint." } },
|
|
374
|
+
{ method: "POST", path: "/api/link/join", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Joining a confirmed Home issues a link credential and restarts this standalone runtime as a client." } },
|
|
375
|
+
{ method: "POST", path: "/api/link/apply", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Applying a link issues a data key and starts a remote tunnel, so it requires the paired dashboard session." } },
|
|
376
|
+
{ method: "DELETE", path: "/api/link/{id}", module: "server/management/link-routes", mutates: true, mechanism: "regex" },
|
|
377
|
+
{ method: "POST", path: "/api/link/issue", module: "server/management/link-routes", mutates: true },
|
|
366
378
|
// server/management/remote-workspace-routes
|
|
367
379
|
{ method: "GET", path: "/api/remote-workspace", module: "server/management/remote-workspace-routes", mutates: false, exempt: { reason: "deferred-verb", why: "The first Remote Workspace slice exposes Hub status through the authenticated dashboard; a distinct CLI Hub-status verb is still owed and must not be confused with the Executor-local status command.", owner: "remote-workspace-cli-followup", ownerDoc: "docs-site/src/content/docs/reference/management-api.md" } },
|
|
368
380
|
{ method: "GET", path: "/api/remote-workspace/runtimes", module: "server/management/remote-workspace-routes", mutates: false, exempt: { reason: "deferred-verb", why: "The first Remote Workspace slice exposes Hub runtime availability through the authenticated dashboard; a distinct CLI Hub-status verb is still owed.", owner: "remote-workspace-cli-followup", ownerDoc: "docs-site/src/content/docs/reference/management-api.md" } },
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* gate accepts only after matching origin and the per-session CSRF token.
|
|
25
25
|
*/
|
|
26
26
|
import { jsonResponse } from "../auth-cors";
|
|
27
|
+
import { verifyLocalDesktopSnapshotBody } from "../local-desktop-snapshot-auth";
|
|
27
28
|
import { agentDrivenMarkers } from "../../cli/agent-driven";
|
|
28
29
|
import type { ManagementContext } from "./context";
|
|
29
30
|
|
|
@@ -102,8 +103,8 @@ export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Respo
|
|
|
102
103
|
if (req.headers.has("origin")) {
|
|
103
104
|
return Response.json({ error: "desktop snapshot does not accept browser-origin requests" }, { status: 403 });
|
|
104
105
|
}
|
|
105
|
-
if (ctx.principal !== "admin-token") {
|
|
106
|
-
return jsonResponse({ error: "desktop snapshot requires admin token" }, 403, req, ctx.config);
|
|
106
|
+
if (ctx.principal !== "admin-token" && ctx.principal !== "local-desktop-snapshot-capability") {
|
|
107
|
+
return jsonResponse({ error: "desktop snapshot requires admin token or snapshot capability" }, 403, req, ctx.config);
|
|
107
108
|
}
|
|
108
109
|
if (req.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() !== "application/json") {
|
|
109
110
|
return jsonResponse({ error: "invalid desktop snapshot" }, 400, req, ctx.config);
|
|
@@ -127,7 +128,11 @@ export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Respo
|
|
|
127
128
|
bytes.set(part.value, used);
|
|
128
129
|
used += part.value.length;
|
|
129
130
|
}
|
|
130
|
-
const
|
|
131
|
+
const body = bytes.subarray(0, used);
|
|
132
|
+
if (ctx.principal === "local-desktop-snapshot-capability" && !verifyLocalDesktopSnapshotBody(req, body)) {
|
|
133
|
+
return jsonResponse({ error: "invalid desktop snapshot capability body" }, 403, req, ctx.config);
|
|
134
|
+
}
|
|
135
|
+
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(body);
|
|
131
136
|
const { desktopBadgeStore } = await import("../../update/desktop-badge");
|
|
132
137
|
if (!desktopBadgeStore.put(JSON.parse(decoded))) {
|
|
133
138
|
return jsonResponse({ error: "invalid desktop snapshot" }, 400, req, ctx.config);
|
|
@@ -26,7 +26,7 @@ import { selectEagerPath } from "../../lib/bun-stream-caps";
|
|
|
26
26
|
import { reportedBunRuntimeSource } from "../../lib/bun-runtime";
|
|
27
27
|
import { getActiveTurnCount, isDraining } from "../lifecycle";
|
|
28
28
|
import { getActiveMemoryWatchdog, observedMemoryCounter } from "../memory-watchdog";
|
|
29
|
-
import { responseStateMetrics } from "../../responses/state";
|
|
29
|
+
import { inspectResponseSpillStorage, responseStateMetrics, type ResponseSpillDirInspection } from "../../responses/state";
|
|
30
30
|
import { appOwnedBytesSnapshot } from "../../lib/app-owned-memory";
|
|
31
31
|
import { readWindowsReplaceRetryCounters } from "../../lib/windows-atomic-replace";
|
|
32
32
|
import {
|
|
@@ -48,6 +48,18 @@ import type { ManagementContext } from "./context";
|
|
|
48
48
|
import { acceptSystemRestart } from "./system-restart";
|
|
49
49
|
|
|
50
50
|
const ENDPOINT_SAMPLE_LIMIT = 60;
|
|
51
|
+
// The spill report reads the snapshot and walks the spill directory synchronously,
|
|
52
|
+
// so a polled memory endpoint reuses one result per window instead of rescanning.
|
|
53
|
+
const RESPONSE_SPILL_REPORT_TTL_MS = 30_000;
|
|
54
|
+
let responseSpillReport: { at: number; value: ResponseSpillDirInspection } | null = null;
|
|
55
|
+
|
|
56
|
+
function cachedResponseSpillReport(): ResponseSpillDirInspection {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
if (!responseSpillReport || now - responseSpillReport.at >= RESPONSE_SPILL_REPORT_TTL_MS) {
|
|
59
|
+
responseSpillReport = { at: now, value: inspectResponseSpillStorage() };
|
|
60
|
+
}
|
|
61
|
+
return responseSpillReport.value;
|
|
62
|
+
}
|
|
51
63
|
|
|
52
64
|
export async function handleSystemRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
53
65
|
const { req, url, config, version } = ctx;
|
|
@@ -121,6 +133,11 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
121
133
|
observedMetric: observed.observedMetric,
|
|
122
134
|
jscHeap,
|
|
123
135
|
responseState: responseStateMetrics(),
|
|
136
|
+
// Dry-run spill-directory counters: owned vs orphan-candidate bytes, same
|
|
137
|
+
// predicate the startup reclaim uses. Sibling of responseState, not a field
|
|
138
|
+
// in it — that block's key count is pinned. Still scalar-only. Cached for
|
|
139
|
+
// RESPONSE_SPILL_REPORT_TTL_MS.
|
|
140
|
+
responseSpill: cachedResponseSpillReport(),
|
|
124
141
|
appOwnedBytes: appOwnedBytesSnapshot(),
|
|
125
142
|
inspectionCounters: getInspectionCounters(),
|
|
126
143
|
streamMode,
|