@bitkyc08/opencodex 2.12.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/gui/dist/assets/index-BHldBl6_.js +76 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic-output-schema.ts +137 -0
- package/src/adapters/anthropic.ts +15 -0
- package/src/adapters/google-antigravity-replay.ts +269 -5
- package/src/adapters/openai-chat.ts +25 -18
- package/src/adapters/openai-responses.ts +63 -17
- package/src/bridge.ts +11 -5
- package/src/claude/inbound.ts +24 -3
- package/src/cli/account-api.ts +2 -0
- package/src/cli/account-extended.ts +262 -1
- package/src/cli/account.ts +3 -1
- package/src/cli/dispatch.ts +532 -0
- package/src/cli/doctor.ts +26 -21
- package/src/cli/help.ts +9 -278
- package/src/cli/index.ts +45 -506
- package/src/cli/lab.ts +171 -65
- package/src/cli/registry.ts +384 -0
- package/src/cli/root.ts +86 -0
- package/src/cli/status.ts +9 -2
- package/src/cli/v2.ts +37 -2
- package/src/codex/account-label.ts +14 -1
- package/src/codex/account-store.ts +12 -1
- package/src/codex/admission.ts +2 -1
- package/src/codex/auth-api.ts +3 -2
- package/src/codex/catalog/metadata.ts +6 -4
- package/src/codex/catalog/parsing.ts +31 -7
- package/src/codex/catalog/provider-fetch.ts +2 -1
- package/src/codex/catalog/sync.ts +65 -11
- package/src/codex/features.ts +437 -31
- package/src/codex/history-job.ts +6 -11
- package/src/codex/history-provider.ts +10 -9
- package/src/codex/inject.ts +14 -1
- package/src/codex/native-profile-startup.ts +49 -3
- package/src/codex/native-residue.ts +14 -2
- package/src/codex/paths.ts +75 -1
- package/src/codex/reset-credit-recovery.ts +1044 -0
- package/src/codex/shim.ts +876 -16
- package/src/codex/sync.ts +27 -2
- package/src/config.ts +148 -10
- package/src/generated/compatibility-version.json +2572 -0
- package/src/images/loop.ts +1 -1
- package/src/lab/artifacts/sanitize.ts +3 -3
- package/src/lab/automation/budgets.ts +78 -0
- package/src/lab/automation/config-persistence.ts +256 -0
- package/src/lab/automation/constants.ts +39 -0
- package/src/lab/automation/cooldown.ts +103 -0
- package/src/lab/automation/dispatch.ts +211 -0
- package/src/lab/automation/index.ts +13 -0
- package/src/lab/automation/orchestrator.ts +480 -0
- package/src/lab/automation/persistence.ts +512 -0
- package/src/lab/automation/planner.ts +371 -0
- package/src/lab/automation/policy.ts +136 -0
- package/src/lab/automation/queue.ts +191 -0
- package/src/lab/automation/recovery.ts +24 -0
- package/src/lab/automation/route-context.ts +21 -0
- package/src/lab/automation/run-key.ts +44 -0
- package/src/lab/automation/runs-query.ts +34 -0
- package/src/lab/automation/types.ts +160 -0
- package/src/lab/conformance/types.ts +23 -0
- package/src/lab/events/types.ts +2 -1
- package/src/lab/events/validate.ts +2 -1
- package/src/lab/fabric/constants.ts +40 -0
- package/src/lab/fabric/executor.ts +492 -0
- package/src/lab/fabric/index.ts +80 -0
- package/src/lab/fabric/manifest.ts +222 -0
- package/src/lab/fabric/observe.ts +454 -0
- package/src/lab/fabric/patch.ts +79 -0
- package/src/lab/fabric/producer-child.ts +139 -0
- package/src/lab/fabric/producer-isolate.ts +276 -0
- package/src/lab/fabric/producer-protocol.ts +61 -0
- package/src/lab/fabric/scratch.ts +439 -0
- package/src/lab/fabric/subject.ts +106 -0
- package/src/lab/fabric/types.ts +134 -0
- package/src/lab/fabric/verifier.ts +98 -0
- package/src/lab/index.ts +2 -0
- package/src/lab/ledger/store.ts +173 -0
- package/src/lab/live/executor.ts +29 -4
- package/src/lab/live/transport.ts +3 -1
- package/src/lab/observe/from-conformance.ts +12 -57
- package/src/lab/observe/from-live.ts +9 -1
- package/src/lab/paths.ts +74 -9
- package/src/lab/projection/schema.ts +14 -1
- package/src/lab/projection/verdicts.ts +89 -53
- package/src/lab/projection/verification.ts +83 -3
- package/src/lab/query/catalog.ts +34 -5
- package/src/lab/query/freshness.ts +53 -0
- package/src/lab/query/index.ts +11 -0
- package/src/lab/query/latest-observation.ts +59 -0
- package/src/lab/query/passive-production.ts +159 -0
- package/src/lab/subject/installation-salt.ts +24 -4
- package/src/lab/subject/protocol-subject.ts +80 -0
- package/src/lab/subject/route-subject.ts +2 -1
- package/src/lib/fabric-task-execution-authority.ts +7 -0
- package/src/lib/fabric-task-host.ts +29 -0
- package/src/lib/lab-live-route-production.ts +130 -0
- package/src/lib/local-management-capability.ts +100 -0
- package/src/lib/server-resource-ownership.ts +71 -0
- package/src/lib/windows-elevation.ts +8 -1
- package/src/lib/windows-user-principal.ts +62 -2
- package/src/lib/winsw.ts +8 -2
- package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
- package/src/oauth/account-import/index.ts +15 -0
- package/src/oauth/account-import/parser.ts +83 -0
- package/src/oauth/account-import/registry.ts +18 -0
- package/src/oauth/account-import/service.ts +75 -0
- package/src/oauth/account-import/types.ts +91 -0
- package/src/oauth/anthropic.ts +12 -1
- package/src/oauth/callback-server.ts +8 -2
- package/src/oauth/chatgpt.ts +12 -1
- package/src/oauth/google-antigravity.ts +27 -0
- package/src/oauth/health.ts +12 -48
- package/src/oauth/index.ts +122 -0
- package/src/oauth/kimi.ts +16 -2
- package/src/oauth/nous.ts +749 -0
- package/src/oauth/store.ts +56 -3
- package/src/providers/codex-capacity.ts +1 -0
- package/src/providers/registry.ts +161 -16
- package/src/responses/custom-tool-compat.ts +182 -0
- package/src/responses/parser.ts +22 -0
- package/src/responses/reasoning-replay-cache.ts +168 -11
- package/src/responses/schema.ts +1 -1
- package/src/router.ts +5 -20
- package/src/routing/capability.ts +9 -3
- package/src/routing/compatibility/assemble.ts +159 -0
- package/src/routing/compatibility/behavior.ts +224 -0
- package/src/routing/compatibility/catalog.ts +99 -0
- package/src/routing/compatibility/endpoint.ts +52 -0
- package/src/routing/compatibility/policy.ts +181 -0
- package/src/routing/compatibility/reader.ts +110 -0
- package/src/routing/compatibility/subject.ts +191 -0
- package/src/routing/compatibility/types.ts +64 -0
- package/src/routing/compatibility/version.ts +104 -0
- package/src/routing/evaluator.ts +27 -32
- package/src/routing/profile.ts +118 -4
- package/src/routing/trace.ts +82 -28
- package/src/server/auth-cors.ts +7 -0
- package/src/server/background-lifecycle.ts +20 -7
- package/src/server/chat-completions.ts +2 -2
- package/src/server/claude-messages.ts +6 -3
- package/src/server/direct-local-http.ts +343 -0
- package/src/server/index.ts +80 -18
- package/src/server/lifecycle.ts +12 -4
- package/src/server/local-management-read-client.ts +90 -0
- package/src/server/management/agent-settings-routes.ts +27 -3
- package/src/server/management/lab-automation-routes.ts +206 -0
- package/src/server/management/lab-routes.ts +13 -0
- package/src/server/management/logs-usage-routes.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +57 -1
- package/src/server/management/provider-routes.ts +16 -0
- package/src/server/management/routing-profile-routes.ts +12 -24
- package/src/server/management-api.ts +2 -0
- package/src/server/management-auth.ts +68 -3
- package/src/server/proxy-liveness.ts +3 -2
- package/src/server/request-decompress.ts +9 -3
- package/src/server/request-log.ts +53 -4
- package/src/server/responses/collaboration.ts +93 -17
- package/src/server/responses/compact.ts +3 -0
- package/src/server/responses/core.ts +202 -15
- package/src/server/responses/policy-fallback.ts +1 -0
- package/src/server/responses-custom-tool-repair.ts +280 -0
- package/src/server/responses-terminal-repair.ts +342 -0
- package/src/server/startup-action-control.ts +8 -1
- package/src/service.ts +278 -12
- package/src/types.ts +49 -3
- package/src/usage/expected-prices.ts +30 -0
- package/src/usage/log.ts +30 -4
- package/src/usage/summary.ts +153 -2
- package/src/web-search/loop.ts +1 -1
- package/gui/dist/assets/index-Cw1_mxO8.js +0 -70
- package/src/cli/internal-dispatch.ts +0 -20
|
@@ -224,6 +224,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
224
224
|
const {
|
|
225
225
|
isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads,
|
|
226
226
|
getAgentsEnabled, getAgentsMaxDepth, getSubagentDeveloperInstructions,
|
|
227
|
+
getMultiAgentModeHintText,
|
|
227
228
|
} = await import("../../codex/features");
|
|
228
229
|
const enabled = isMultiAgentV2Enabled();
|
|
229
230
|
return jsonResponse({
|
|
@@ -234,6 +235,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
234
235
|
agentsEnabled: getAgentsEnabled(),
|
|
235
236
|
agentsMaxDepth: getAgentsMaxDepth(),
|
|
236
237
|
subagentDeveloperInstructions: getSubagentDeveloperInstructions(),
|
|
238
|
+
multiAgentModeHintText: getMultiAgentModeHintText(),
|
|
237
239
|
// max_depth is V1-only upstream; this is the global-flag statement, derived
|
|
238
240
|
// server-side so no client can present it as an effective V2 limit.
|
|
239
241
|
agentsMaxDepthAppliesWhenV2Disabled: !enabled,
|
|
@@ -247,6 +249,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
247
249
|
agentsEnabled?: unknown;
|
|
248
250
|
agentsMaxDepth?: unknown;
|
|
249
251
|
subagentDeveloperInstructions?: unknown;
|
|
252
|
+
multiAgentModeHintText?: unknown;
|
|
250
253
|
};
|
|
251
254
|
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
252
255
|
const wantsFlag = body.enabled !== undefined;
|
|
@@ -255,8 +258,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
255
258
|
const wantsAgentsEnabled = body.agentsEnabled !== undefined;
|
|
256
259
|
const wantsMaxDepth = body.agentsMaxDepth !== undefined;
|
|
257
260
|
const wantsSubagentInstructions = body.subagentDeveloperInstructions !== undefined;
|
|
258
|
-
|
|
259
|
-
|
|
261
|
+
const wantsModeHintText = body.multiAgentModeHintText !== undefined;
|
|
262
|
+
if (!wantsFlag && !wantsThreads && !wantsMode && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions && !wantsModeHintText) {
|
|
263
|
+
return jsonResponse({ error: "body must set enabled, multiAgentMode, maxConcurrentThreadsPerSession, agentsEnabled, agentsMaxDepth, subagentDeveloperInstructions, and/or multiAgentModeHintText" }, 400);
|
|
260
264
|
}
|
|
261
265
|
if (wantsFlag && typeof body.enabled !== "boolean") return jsonResponse({ error: "body.enabled must be a boolean" }, 400);
|
|
262
266
|
if (wantsMode && body.multiAgentMode !== "v1" && body.multiAgentMode !== "default" && body.multiAgentMode !== "v2") {
|
|
@@ -280,6 +284,14 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
280
284
|
if (wantsSubagentInstructions && body.subagentDeveloperInstructions !== null && typeof body.subagentDeveloperInstructions !== "string") {
|
|
281
285
|
return jsonResponse({ error: "body.subagentDeveloperInstructions must be a string or null" }, 400);
|
|
282
286
|
}
|
|
287
|
+
// null unsets the upstream key (effort-derived policy resumes); an empty/whitespace
|
|
288
|
+
// string is rejected because codex-rs treats any present hint as an override that
|
|
289
|
+
// suppresses even the Ultra-derived Proactive message (Option<String>, no blank
|
|
290
|
+
// special-case in effective_multi_agent_mode).
|
|
291
|
+
if (wantsModeHintText && body.multiAgentModeHintText !== null
|
|
292
|
+
&& (typeof body.multiAgentModeHintText !== "string" || body.multiAgentModeHintText.trim().length === 0)) {
|
|
293
|
+
return jsonResponse({ error: "body.multiAgentModeHintText must be a non-empty string or null" }, 400);
|
|
294
|
+
}
|
|
283
295
|
const mode = wantsMode ? body.multiAgentMode as "v1" | "default" | "v2" : undefined;
|
|
284
296
|
const modeFlag = mode === "v2" ? true : mode === "v1" ? false : undefined;
|
|
285
297
|
if (wantsFlag && modeFlag !== undefined && body.enabled !== modeFlag) {
|
|
@@ -288,8 +300,18 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
288
300
|
const {
|
|
289
301
|
isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads, transitionMultiAgentV2,
|
|
290
302
|
getAgentsEnabled, getAgentsMaxDepth, getSubagentDeveloperInstructions,
|
|
291
|
-
setAgentsEnabled, setAgentsMaxDepth,
|
|
303
|
+
getMultiAgentModeHintText, probeCodexSupportsModeHint, setAgentsEnabled, setAgentsMaxDepth,
|
|
304
|
+
setSubagentDeveloperInstructions, setMultiAgentModeHintText, MODE_HINT_UNSUPPORTED_ERROR,
|
|
292
305
|
} = await import("../../codex/features");
|
|
306
|
+
// Probe the capability before any combined-request mutation. The scalar writer
|
|
307
|
+
// repeats this check, but doing it here prevents an earlier flag/mode/agents
|
|
308
|
+
// write from landing before an unsupported runtime returns 502.
|
|
309
|
+
if (wantsModeHintText && body.multiAgentModeHintText !== null
|
|
310
|
+
&& probeCodexSupportsModeHint() === false) {
|
|
311
|
+
return jsonResponse({
|
|
312
|
+
error: `writing multiAgentModeHintText failed: ${MODE_HINT_UNSUPPORTED_ERROR}`,
|
|
313
|
+
}, 502);
|
|
314
|
+
}
|
|
293
315
|
const warnings: string[] = [];
|
|
294
316
|
const requestedFlag = wantsFlag ? body.enabled as boolean : modeFlag;
|
|
295
317
|
if (requestedFlag !== undefined || wantsThreads) {
|
|
@@ -322,6 +344,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
322
344
|
if (wantsAgentsEnabled) scalarWrites.push({ field: "agentsEnabled", run: () => setAgentsEnabled(body.agentsEnabled as boolean | null) });
|
|
323
345
|
if (wantsMaxDepth) scalarWrites.push({ field: "agentsMaxDepth", run: () => setAgentsMaxDepth(body.agentsMaxDepth as number | null) });
|
|
324
346
|
if (wantsSubagentInstructions) scalarWrites.push({ field: "subagentDeveloperInstructions", run: () => setSubagentDeveloperInstructions(body.subagentDeveloperInstructions as string | null) });
|
|
347
|
+
if (wantsModeHintText) scalarWrites.push({ field: "multiAgentModeHintText", run: () => setMultiAgentModeHintText(body.multiAgentModeHintText as string | null) });
|
|
325
348
|
const landed: string[] = [];
|
|
326
349
|
for (const write of scalarWrites) {
|
|
327
350
|
try {
|
|
@@ -353,6 +376,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
353
376
|
agentsEnabled: getAgentsEnabled(),
|
|
354
377
|
agentsMaxDepth: getAgentsMaxDepth(),
|
|
355
378
|
subagentDeveloperInstructions: getSubagentDeveloperInstructions(),
|
|
379
|
+
multiAgentModeHintText: getMultiAgentModeHintText(),
|
|
356
380
|
agentsMaxDepthAppliesWhenV2Disabled: !enabled,
|
|
357
381
|
warnings,
|
|
358
382
|
catalogRefresh,
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CL-08 Compatibility Lab automation management API.
|
|
3
|
+
*
|
|
4
|
+
* - GET /api/lab/automation
|
|
5
|
+
* - PUT /api/lab/automation
|
|
6
|
+
* - GET /api/lab/automation/runs
|
|
7
|
+
* - POST /api/lab/automation/run
|
|
8
|
+
* - POST /api/lab/automation/runs/:id/cancel
|
|
9
|
+
*
|
|
10
|
+
* Read endpoints never trigger scheduler ticks or evidence collection.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readConfigDiagnostics, getConfigDir } from "../../config";
|
|
14
|
+
import {
|
|
15
|
+
buildLabAutomationStatus,
|
|
16
|
+
cancelLabAutomationRun,
|
|
17
|
+
enqueueManualLabRun,
|
|
18
|
+
reconcileLabAutomationQueue,
|
|
19
|
+
startLabAutomationScheduler,
|
|
20
|
+
stopLabAutomationScheduler,
|
|
21
|
+
} from "../../lab/automation/orchestrator";
|
|
22
|
+
import { planManualLabRun } from "../../lab/automation/planner";
|
|
23
|
+
import {
|
|
24
|
+
loadLabAutomationState,
|
|
25
|
+
normalizeLabAutomationRoutesV1,
|
|
26
|
+
} from "../../lab/automation/persistence";
|
|
27
|
+
import {
|
|
28
|
+
loadLabAutomationConfig,
|
|
29
|
+
saveLabAutomationConfig,
|
|
30
|
+
} from "../../lab/automation/config-persistence";
|
|
31
|
+
import { normalizeLabAutomationPolicyV1 } from "../../lab/automation/policy";
|
|
32
|
+
import { listLabAutomationRuns } from "../../lab/automation/runs-query";
|
|
33
|
+
import type { LabAutomationLayer, LabAutomationPolicyV1 } from "../../lab/automation/types";
|
|
34
|
+
import { LabAutomationError } from "../../lab/automation/types";
|
|
35
|
+
import { jsonResponse } from "../auth-cors";
|
|
36
|
+
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
37
|
+
import type { ManagementContext } from "./context";
|
|
38
|
+
import { isPlainRecord } from "./shared";
|
|
39
|
+
|
|
40
|
+
const AUTOMATION_LAYERS: readonly LabAutomationLayer[] = [
|
|
41
|
+
"protocol_conformance",
|
|
42
|
+
"live_route_compatibility",
|
|
43
|
+
"task_effectiveness",
|
|
44
|
+
];
|
|
45
|
+
const MANUAL_RUN_KEYS = new Set(["evidenceLayer", "scenarioId", "providerName", "modelId"]);
|
|
46
|
+
|
|
47
|
+
function automationErrorResponse(code: string, message: string, status: number, ctx: ManagementContext): Response {
|
|
48
|
+
return jsonResponse({ error: { code, message } }, status, ctx.req, ctx.config);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hasUnknownKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>): boolean {
|
|
52
|
+
return Object.keys(value).some((key) => !allowed.has(key));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseLimit(raw: string | null, ctx: ManagementContext): number | Response {
|
|
56
|
+
if (raw === null) return 50;
|
|
57
|
+
const trimmed = raw.trim();
|
|
58
|
+
if (trimmed.length === 0) return 50;
|
|
59
|
+
const value = Number(trimmed);
|
|
60
|
+
if (!Number.isInteger(value) || value < 1 || value > 100) {
|
|
61
|
+
return automationErrorResponse("invalid_limit", "limit must be an integer from 1 to 100", 400, ctx);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function applySchedulerPolicy(policy: LabAutomationPolicyV1, configDir?: string): void {
|
|
67
|
+
reconcileLabAutomationQueue(configDir);
|
|
68
|
+
if (policy.enabled) {
|
|
69
|
+
startLabAutomationScheduler(configDir);
|
|
70
|
+
} else {
|
|
71
|
+
stopLabAutomationScheduler(configDir);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function handleLabAutomationRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
76
|
+
const { url, req, config } = ctx;
|
|
77
|
+
if (!url.pathname.startsWith("/api/lab/automation")) return null;
|
|
78
|
+
|
|
79
|
+
const configDir = getConfigDir();
|
|
80
|
+
|
|
81
|
+
if (url.pathname === "/api/lab/automation" && req.method === "GET") {
|
|
82
|
+
return jsonResponse(buildLabAutomationStatus(configDir), 200, req, config);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (url.pathname === "/api/lab/automation/runs" && req.method === "GET") {
|
|
86
|
+
const limit = parseLimit(url.searchParams.get("limit"), ctx);
|
|
87
|
+
if (limit instanceof Response) return limit;
|
|
88
|
+
const cursor = url.searchParams.get("cursor")?.trim() || undefined;
|
|
89
|
+
try {
|
|
90
|
+
const status = buildLabAutomationStatus(configDir);
|
|
91
|
+
const runsPage = listLabAutomationRuns(loadLabAutomationState(configDir), limit, cursor);
|
|
92
|
+
return jsonResponse({
|
|
93
|
+
runs: runsPage.items,
|
|
94
|
+
hasMore: runsPage.hasMore,
|
|
95
|
+
...(runsPage.nextCursor ? { nextCursor: runsPage.nextCursor } : {}),
|
|
96
|
+
counters: status.counters,
|
|
97
|
+
}, 200, req, config);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error instanceof LabAutomationError && error.code === "invalid_cursor") {
|
|
100
|
+
return automationErrorResponse(error.code, error.message, 400, ctx);
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const cancelMatch = url.pathname.match(/^\/api\/lab\/automation\/runs\/([^/]+)\/cancel$/);
|
|
107
|
+
if (cancelMatch && req.method === "POST") {
|
|
108
|
+
let runId: string;
|
|
109
|
+
try {
|
|
110
|
+
runId = decodeURIComponent(cancelMatch[1]!);
|
|
111
|
+
} catch {
|
|
112
|
+
return automationErrorResponse("invalid_run_id", "run id is not valid percent-encoding", 400, ctx);
|
|
113
|
+
}
|
|
114
|
+
const cancelled = cancelLabAutomationRun(runId, configDir);
|
|
115
|
+
if (!cancelled) return automationErrorResponse("not_found", "unknown or non-cancellable run", 404, ctx);
|
|
116
|
+
return jsonResponse({ cancelled: true, runId }, 200, req, config);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (url.pathname === "/api/lab/automation/run" && req.method === "POST") {
|
|
120
|
+
try {
|
|
121
|
+
const body = await readManagementJsonBody(req);
|
|
122
|
+
if (!isPlainRecord(body)) return automationErrorResponse("invalid_body", "body must be an object", 400, ctx);
|
|
123
|
+
if (hasUnknownKeys(body, MANUAL_RUN_KEYS)) {
|
|
124
|
+
return automationErrorResponse("invalid_body", "manual run body contains unknown fields", 400, ctx);
|
|
125
|
+
}
|
|
126
|
+
const evidenceLayer = body.evidenceLayer;
|
|
127
|
+
const scenarioId = body.scenarioId;
|
|
128
|
+
if (typeof evidenceLayer !== "string" || !AUTOMATION_LAYERS.includes(evidenceLayer as LabAutomationLayer)) {
|
|
129
|
+
return automationErrorResponse("invalid_layer", "evidenceLayer must be a supported automation layer", 400, ctx);
|
|
130
|
+
}
|
|
131
|
+
if (typeof scenarioId !== "string" || scenarioId.length === 0) {
|
|
132
|
+
return automationErrorResponse("invalid_scenario", "scenarioId is required", 400, ctx);
|
|
133
|
+
}
|
|
134
|
+
let ocxConfig: import("../../types").OcxConfig | undefined;
|
|
135
|
+
try {
|
|
136
|
+
ocxConfig = readConfigDiagnostics().config;
|
|
137
|
+
} catch {
|
|
138
|
+
ocxConfig = config;
|
|
139
|
+
}
|
|
140
|
+
const planned = planManualLabRun({
|
|
141
|
+
evidenceLayer: evidenceLayer as LabAutomationLayer,
|
|
142
|
+
scenarioId,
|
|
143
|
+
providerName: typeof body.providerName === "string" ? body.providerName : undefined,
|
|
144
|
+
modelId: typeof body.modelId === "string" ? body.modelId : undefined,
|
|
145
|
+
config: ocxConfig,
|
|
146
|
+
configDir,
|
|
147
|
+
});
|
|
148
|
+
// This endpoint is intentionally synchronous: completion is the acknowledgement boundary.
|
|
149
|
+
// Existing harness execution limits provide the route-level deadline, and disconnects cancel
|
|
150
|
+
// through Request.signal instead of leaving orphaned provider work running.
|
|
151
|
+
const record = await enqueueManualLabRun(planned, configDir, req.signal);
|
|
152
|
+
if (!record) return automationErrorResponse("enqueue_failed", "manual run could not be enqueued", 500, ctx);
|
|
153
|
+
return jsonResponse({ run: record, trigger: "manual" }, 200, req, config);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
rethrowManagementBodyTooLarge(error);
|
|
156
|
+
if (error instanceof LabAutomationError) {
|
|
157
|
+
return automationErrorResponse(error.code, error.message, 400, ctx);
|
|
158
|
+
}
|
|
159
|
+
return automationErrorResponse("server_error", "manual run failed", 500, ctx);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (url.pathname === "/api/lab/automation" && req.method === "PUT") {
|
|
164
|
+
try {
|
|
165
|
+
const body = await readManagementJsonBody(req);
|
|
166
|
+
if (!isPlainRecord(body)) return automationErrorResponse("invalid_body", "body must be an object", 400, ctx);
|
|
167
|
+
// Only policy/routes are authoritative. Other top-level properties are ignored rather than
|
|
168
|
+
// treated as injectable process-local capabilities (for example a fake routeExecutor).
|
|
169
|
+
let { policy, routes } = loadLabAutomationConfig(configDir);
|
|
170
|
+
if (body.policy !== undefined) {
|
|
171
|
+
if (!isPlainRecord(body.policy)) return automationErrorResponse("invalid_policy", "policy must be an object", 400, ctx);
|
|
172
|
+
const incoming = body.policy as Record<string, unknown>;
|
|
173
|
+
const mergedLayers = incoming.layers === undefined
|
|
174
|
+
? policy.layers
|
|
175
|
+
: isPlainRecord(incoming.layers)
|
|
176
|
+
? { ...policy.layers, ...incoming.layers }
|
|
177
|
+
: incoming.layers;
|
|
178
|
+
policy = normalizeLabAutomationPolicyV1({
|
|
179
|
+
...policy,
|
|
180
|
+
...incoming,
|
|
181
|
+
layers: mergedLayers,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (body.routes !== undefined) {
|
|
185
|
+
if (!isPlainRecord(body.routes)) return automationErrorResponse("invalid_routes", "routes must be an object", 400, ctx);
|
|
186
|
+
routes = normalizeLabAutomationRoutesV1(body.routes);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// One atomic rename publishes policy and routes as a coherent generation. A failed write
|
|
190
|
+
// leaves the previous generation authoritative and scheduler reconciliation is not applied.
|
|
191
|
+
if (body.policy !== undefined || body.routes !== undefined) {
|
|
192
|
+
saveLabAutomationConfig(policy, routes, configDir);
|
|
193
|
+
}
|
|
194
|
+
applySchedulerPolicy(policy, configDir);
|
|
195
|
+
return jsonResponse(buildLabAutomationStatus(configDir), 200, req, config);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
rethrowManagementBodyTooLarge(error);
|
|
198
|
+
if (error instanceof LabAutomationError) {
|
|
199
|
+
return automationErrorResponse(error.code, error.message, 400, ctx);
|
|
200
|
+
}
|
|
201
|
+
return automationErrorResponse("server_error", "automation policy update failed", 500, ctx);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return automationErrorResponse("not_found", "unknown resource", 404, ctx);
|
|
206
|
+
}
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
queryLabSubjectById,
|
|
41
41
|
queryLabSubjects,
|
|
42
42
|
queryLabVerdicts,
|
|
43
|
+
queryPassiveProductionSignals,
|
|
43
44
|
} from "../../lab/query";
|
|
44
45
|
import { jsonResponse } from "../auth-cors";
|
|
45
46
|
import type { ManagementContext } from "./context";
|
|
@@ -194,6 +195,18 @@ export async function handleLabRoutes(ctx: ManagementContext): Promise<Response
|
|
|
194
195
|
return jsonResponse(queryLabStatus(), 200, req, config);
|
|
195
196
|
}
|
|
196
197
|
|
|
198
|
+
if (url.pathname === "/api/lab/production-signals") {
|
|
199
|
+
const subjectId = url.searchParams.get("subjectId")?.trim();
|
|
200
|
+
if (!subjectId) return errorResponse("invalid_subject", "subjectId is required", 400, ctx);
|
|
201
|
+
const limit = parseLimit(url.searchParams.get("limit"), ctx);
|
|
202
|
+
if (limit instanceof Response) return limit;
|
|
203
|
+
try {
|
|
204
|
+
return jsonResponse(queryPassiveProductionSignals(subjectId, limit), 200, req, config);
|
|
205
|
+
} catch {
|
|
206
|
+
return errorResponse("invalid_subject", "subjectId must be an exact Lab route subject id", 400, ctx);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
197
210
|
if (url.pathname === "/api/lab/catalog") {
|
|
198
211
|
const layerRaw = url.searchParams.get("layer");
|
|
199
212
|
const layerParsed = layerRaw ? parseLayer(layerRaw, ctx) : undefined;
|
|
@@ -29,7 +29,7 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/ke
|
|
|
29
29
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
30
30
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
31
31
|
import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
32
|
-
import { clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports, supportsPerAccountQuota } from "../../providers/quota";
|
|
32
|
+
import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports, supportsPerAccountQuota } from "../../providers/quota";
|
|
33
33
|
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
34
34
|
import { clearThreadAccountMap } from "../../codex/routing";
|
|
35
35
|
import {
|
|
@@ -69,6 +69,13 @@ import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, C
|
|
|
69
69
|
import type { ManagementContext } from "./context";
|
|
70
70
|
import { readManagementJsonBody, readManagementJsonBodyOr, rethrowManagementBodyTooLarge } from "./body";
|
|
71
71
|
import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
|
|
72
|
+
import { ACCOUNT_IMPORT_DEADLINE_MS, ACCOUNT_IMPORT_MAX_REQUEST_BYTES } from "../../oauth/account-import";
|
|
73
|
+
import { readBoundedJsonRequestBody } from "../request-decompress";
|
|
74
|
+
|
|
75
|
+
// ACCOUNT_IMPORT_DEADLINE_MS is the shared CLI/server import window. Individual
|
|
76
|
+
// provider requests keep their own shorter timeouts; this is only the server-side
|
|
77
|
+
// backstop for the admitted batch. A disconnected request aborts immediately
|
|
78
|
+
// through req.signal below.
|
|
72
79
|
|
|
73
80
|
/**
|
|
74
81
|
* Parses a bounded JSON object body, or null. Malformed JSON is swallowed; an
|
|
@@ -384,6 +391,55 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
384
391
|
return jsonResponse({ ok: true, cleared });
|
|
385
392
|
}
|
|
386
393
|
|
|
394
|
+
if (url.pathname === "/api/oauth/accounts/import" && req.method === "POST") {
|
|
395
|
+
const controller = new AbortController();
|
|
396
|
+
const abortRequest = () => controller.abort();
|
|
397
|
+
if (req.signal.aborted) abortRequest();
|
|
398
|
+
else req.signal.addEventListener("abort", abortRequest, { once: true });
|
|
399
|
+
const deadline = setTimeout(abortRequest, ACCOUNT_IMPORT_DEADLINE_MS);
|
|
400
|
+
try {
|
|
401
|
+
let rawBody: unknown;
|
|
402
|
+
try {
|
|
403
|
+
rawBody = await readBoundedJsonRequestBody(
|
|
404
|
+
req,
|
|
405
|
+
ACCOUNT_IMPORT_MAX_REQUEST_BYTES,
|
|
406
|
+
undefined,
|
|
407
|
+
{ signal: controller.signal },
|
|
408
|
+
);
|
|
409
|
+
} catch {
|
|
410
|
+
if (controller.signal.aborted) return jsonResponse({ code: "import_cancelled" }, 408);
|
|
411
|
+
return jsonResponse({ code: "invalid_document" }, 400);
|
|
412
|
+
}
|
|
413
|
+
if (!isPlainRecord(rawBody)) return jsonResponse({ code: "invalid_document" }, 400);
|
|
414
|
+
const provider = typeof rawBody.provider === "string" ? rawBody.provider : "";
|
|
415
|
+
const format = typeof rawBody.format === "string" ? rawBody.format : "";
|
|
416
|
+
const { importAccounts } = await import("../../oauth/account-import");
|
|
417
|
+
const imported = await importAccounts({
|
|
418
|
+
provider,
|
|
419
|
+
format,
|
|
420
|
+
document: rawBody.document,
|
|
421
|
+
signal: controller.signal,
|
|
422
|
+
});
|
|
423
|
+
const changed = imported.ok
|
|
424
|
+
? imported.result.importedCount > 0 || imported.result.updatedCount > 0
|
|
425
|
+
: imported.changed === true;
|
|
426
|
+
if (changed) {
|
|
427
|
+
reconcileLiveStateStores();
|
|
428
|
+
const { clearModelCache } = await import("../../codex/model-cache");
|
|
429
|
+
const { clearGatherRoutedModelsInflight } = await import("../../codex/catalog");
|
|
430
|
+
clearModelCache(provider);
|
|
431
|
+
clearGatherRoutedModelsInflight();
|
|
432
|
+
clearProviderQuotaCache();
|
|
433
|
+
clearAccountQuotaCache(provider);
|
|
434
|
+
}
|
|
435
|
+
if (!imported.ok) return jsonResponse({ code: imported.code }, imported.status);
|
|
436
|
+
return jsonResponse(imported.result);
|
|
437
|
+
} finally {
|
|
438
|
+
clearTimeout(deadline);
|
|
439
|
+
req.signal.removeEventListener("abort", abortRequest);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
387
443
|
if (url.pathname === "/api/oauth/accounts/alias" && req.method === "PUT") {
|
|
388
444
|
const body = await readManagementJsonBodyOr(req, {}) as { provider?: unknown; accountId?: unknown; alias?: unknown };
|
|
389
445
|
const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
hasOwnProvider,
|
|
9
9
|
isValidProviderName,
|
|
10
10
|
multiAgentGuidanceEnabled,
|
|
11
|
+
nonBlankStringArrayConfigError,
|
|
12
|
+
normalizeNonBlankStringArray,
|
|
11
13
|
providerBaseUrlConfigError,
|
|
12
14
|
providerHeadersConfigError,
|
|
13
15
|
saveConfigPreservingClaudeCode,
|
|
@@ -205,6 +207,19 @@ function applyProviderPatchFields(
|
|
|
205
207
|
}
|
|
206
208
|
touched = true;
|
|
207
209
|
}
|
|
210
|
+
if (Object.hasOwn(rawBody, "noStructuredOutputModels")) {
|
|
211
|
+
const value = rawBody.noStructuredOutputModels;
|
|
212
|
+
if (value === null) {
|
|
213
|
+
delete next.noStructuredOutputModels;
|
|
214
|
+
} else {
|
|
215
|
+
const error = nonBlankStringArrayConfigError(value, "noStructuredOutputModels");
|
|
216
|
+
if (error) return { error };
|
|
217
|
+
const models = normalizeNonBlankStringArray(value as string[]);
|
|
218
|
+
if (models.length > 0) next.noStructuredOutputModels = models;
|
|
219
|
+
else delete next.noStructuredOutputModels;
|
|
220
|
+
}
|
|
221
|
+
touched = true;
|
|
222
|
+
}
|
|
208
223
|
|
|
209
224
|
// headers is the one object-valued field in the mask. PATCH semantics merge it
|
|
210
225
|
// shallowly into the existing block so a single fingerprint header can be added
|
|
@@ -291,6 +306,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
291
306
|
models: p.models ?? [],
|
|
292
307
|
contextWindow: p.contextWindow,
|
|
293
308
|
modelContextWindows: p.modelContextWindows,
|
|
309
|
+
noStructuredOutputModels: p.noStructuredOutputModels,
|
|
294
310
|
authMode: p.authMode,
|
|
295
311
|
apiKeyTransport: p.apiKeyTransport,
|
|
296
312
|
disabled: p.disabled === true,
|
|
@@ -16,10 +16,9 @@ import {
|
|
|
16
16
|
routingProfileIssues,
|
|
17
17
|
} from "../../routing/profile";
|
|
18
18
|
import { evaluatePolicyProfile, type PolicyCandidateEvidence, type PolicyRequestEvidence } from "../../routing/evaluator";
|
|
19
|
-
import {
|
|
20
|
-
import { policyCandidateHealthEvidence } from "../../routing/health";
|
|
19
|
+
import { assemblePolicyCandidateEvidence } from "../../routing/compatibility/assemble";
|
|
21
20
|
import { quotaEvidenceForCandidate } from "../../routing/quota";
|
|
22
|
-
import {
|
|
21
|
+
import { routedProviderConfig } from "../../router";
|
|
23
22
|
import { saveConfigPreservingClaudeCode } from "../../config";
|
|
24
23
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
25
24
|
import { isPlainRecord } from "./shared";
|
|
@@ -41,6 +40,7 @@ function profileDto(config: Parameters<typeof getRoutingProfile>[0], id: string)
|
|
|
41
40
|
optimize: profile.optimize,
|
|
42
41
|
limits: profile.limits,
|
|
43
42
|
unknownEvidence: profile.unknownEvidence,
|
|
43
|
+
...(profile.compatibility ? { compatibility: profile.compatibility } : {}),
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -98,26 +98,11 @@ function parseCandidateEvidence(raw: unknown): PolicyCandidateEvidence[] | null
|
|
|
98
98
|
function assembleCandidateEvidence(
|
|
99
99
|
config: OcxConfig,
|
|
100
100
|
profile: NonNullable<ReturnType<typeof getRoutingProfile>>,
|
|
101
|
+
now: number,
|
|
101
102
|
): PolicyCandidateEvidence[] {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// evaluate mode can surface the profile limit even without a usage estimate.
|
|
106
|
-
return profile.candidates.map(candidate => ({
|
|
107
|
-
provider: candidate.provider,
|
|
108
|
-
model: candidate.model,
|
|
109
|
-
capability: candidateCapabilityEvidence(config, candidate.provider, candidate.model),
|
|
110
|
-
health: policyCandidateHealthEvidence(config, candidate),
|
|
111
|
-
quota: quotaEvidenceForCandidate({
|
|
112
|
-
provider: candidate.provider,
|
|
113
|
-
model: candidate.model,
|
|
114
|
-
}),
|
|
115
|
-
cost: costEvidenceForCandidate({
|
|
116
|
-
provider: candidate.provider,
|
|
117
|
-
model: candidate.model,
|
|
118
|
-
limitUsd: profile.limits.maxEstimatedCostUsd,
|
|
119
|
-
}),
|
|
120
|
-
}));
|
|
103
|
+
return assemblePolicyCandidateEvidence(config, profile, now, {
|
|
104
|
+
routedProviderConfig,
|
|
105
|
+
});
|
|
121
106
|
}
|
|
122
107
|
|
|
123
108
|
function storedProfile(
|
|
@@ -370,13 +355,16 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis
|
|
|
370
355
|
if (!ok) {
|
|
371
356
|
return jsonResponse({ error: { code: "invalid_evidence", message: "evidence must be an object" } }, 400, req, config);
|
|
372
357
|
}
|
|
358
|
+
// One clock read for both assembly and evaluation keeps freshness, health,
|
|
359
|
+
// and trace timestamps mutually consistent with the production router.
|
|
360
|
+
const now = Date.now();
|
|
373
361
|
const candidateEvidence = body.candidates === undefined
|
|
374
|
-
? assembleCandidateEvidence(config, resolvedProfile)
|
|
362
|
+
? assembleCandidateEvidence(config, resolvedProfile, now)
|
|
375
363
|
: parseCandidateEvidence(body.candidates);
|
|
376
364
|
if (candidateEvidence === null) {
|
|
377
365
|
return jsonResponse({ error: { code: "invalid_candidates", message: "candidates must be an array of evidence objects" } }, 400, req, config);
|
|
378
366
|
}
|
|
379
|
-
const result = evaluatePolicyProfile(config, profile, evidence, candidateEvidence);
|
|
367
|
+
const result = evaluatePolicyProfile(config, profile, evidence, candidateEvidence, now);
|
|
380
368
|
return jsonResponse(result, 200, req, config);
|
|
381
369
|
}
|
|
382
370
|
|
|
@@ -69,6 +69,7 @@ import { handleOauthAccountRoutes } from "./management/oauth-account-routes";
|
|
|
69
69
|
import { handleComboRoutes } from "./management/combo-routes";
|
|
70
70
|
import { handleSystemRoutes } from "./management/system-routes";
|
|
71
71
|
import { handleLabRoutes } from "./management/lab-routes";
|
|
72
|
+
import { handleLabAutomationRoutes } from "./management/lab-automation-routes";
|
|
72
73
|
import { handleSidebarRoutes } from "./management/sidebar-routes";
|
|
73
74
|
import { handleIntegrationRoutes } from "./management/integration-routes";
|
|
74
75
|
import { handleNativeIntegrationRoutes } from "./management/native-integration-routes";
|
|
@@ -188,6 +189,7 @@ export async function handleManagementAPI(
|
|
|
188
189
|
?? (await handleOauthAccountRoutes(ctx))
|
|
189
190
|
?? (await handleComboRoutes(ctx))
|
|
190
191
|
?? (await handleSystemRoutes(ctx))
|
|
192
|
+
?? (await handleLabAutomationRoutes(ctx))
|
|
191
193
|
?? (await handleLabRoutes(ctx))
|
|
192
194
|
?? (await handleSidebarRoutes(ctx));
|
|
193
195
|
} catch (error) {
|
|
@@ -13,6 +13,14 @@ import {
|
|
|
13
13
|
} from "node:fs";
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
15
|
import { adminApiTokenFilePath } from "../lib/admin-secrets";
|
|
16
|
+
import {
|
|
17
|
+
LOCAL_MANAGEMENT_CAPABILITY_HEADER,
|
|
18
|
+
LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER,
|
|
19
|
+
LOCAL_MANAGEMENT_EXPECTED_PID_HEADER,
|
|
20
|
+
LOCAL_MANAGEMENT_NONCE_HEADER,
|
|
21
|
+
parseExpectedLocalManagementPid,
|
|
22
|
+
verifyLocalManagementReadCapability,
|
|
23
|
+
} from "../lib/local-management-capability";
|
|
16
24
|
import {
|
|
17
25
|
SYSTEM_RESTART_CAPABILITY_HEADER,
|
|
18
26
|
SYSTEM_RESTART_EXPECTED_PID_HEADER,
|
|
@@ -34,6 +42,9 @@ import {
|
|
|
34
42
|
|
|
35
43
|
const GUI_SESSION_TTL_MS = 5 * 60_000;
|
|
36
44
|
const GUI_SESSION_LIMIT = 128;
|
|
45
|
+
const LOCAL_READ_REPLAY_LIMIT = 256;
|
|
46
|
+
const consumedLocalReadCapabilities = new Map<string, number>();
|
|
47
|
+
const admittedLocalReadRequests = new WeakSet<Request>();
|
|
37
48
|
|
|
38
49
|
interface GuiSessionRecord {
|
|
39
50
|
csrfToken: string;
|
|
@@ -253,10 +264,15 @@ export function issueGuiSession(
|
|
|
253
264
|
* minted for a browser, and it only authorizes a mutation after the origin and the
|
|
254
265
|
* per-session CSRF token match. Consent-bearing routes must key off this value
|
|
255
266
|
* rather than off request headers, which the token holder can forge freely.
|
|
256
|
-
*
|
|
257
|
-
*
|
|
267
|
+
* The capability principals are process-scoped HMACs bound to the current process
|
|
268
|
+
* PID and listening port. Local reads are accepted only for two exact GET paths;
|
|
269
|
+
* restart remains a separate wire contract for its exact POST.
|
|
258
270
|
*/
|
|
259
|
-
export type ManagementPrincipal =
|
|
271
|
+
export type ManagementPrincipal =
|
|
272
|
+
| "admin-token"
|
|
273
|
+
| "gui-session"
|
|
274
|
+
| "local-read-capability"
|
|
275
|
+
| "system-restart-capability";
|
|
260
276
|
|
|
261
277
|
export interface LocalManagementAuthContext {
|
|
262
278
|
attestationSecret: string;
|
|
@@ -291,6 +307,53 @@ function hasSystemRestartCapability(
|
|
|
291
307
|
);
|
|
292
308
|
}
|
|
293
309
|
|
|
310
|
+
function hasLocalReadCapability(
|
|
311
|
+
req: Request,
|
|
312
|
+
local: LocalManagementAuthContext | undefined,
|
|
313
|
+
): boolean {
|
|
314
|
+
// requireManagementAuth and managementPrincipal inspect the same Request in
|
|
315
|
+
// sequence. Preserve that one admission without accepting a replayed request.
|
|
316
|
+
if (admittedLocalReadRequests.has(req)) return true;
|
|
317
|
+
if (!local || req.method !== "GET") return false;
|
|
318
|
+
let url: URL;
|
|
319
|
+
try {
|
|
320
|
+
url = new URL(req.url);
|
|
321
|
+
} catch {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
// Do not let a future query-bearing variant silently inherit this narrow grant.
|
|
325
|
+
if (url.search !== "") return false;
|
|
326
|
+
const expectedPid = parseExpectedLocalManagementPid(
|
|
327
|
+
req.headers.get(LOCAL_MANAGEMENT_EXPECTED_PID_HEADER),
|
|
328
|
+
);
|
|
329
|
+
if (expectedPid.kind !== "present" || expectedPid.pid !== local.pid) return false;
|
|
330
|
+
const expiresAtRaw = req.headers.get(LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER);
|
|
331
|
+
if (!expiresAtRaw || !/^[1-9]\d*$/.test(expiresAtRaw)) return false;
|
|
332
|
+
const expiresAt = Number(expiresAtRaw);
|
|
333
|
+
if (!Number.isSafeInteger(expiresAt)) return false;
|
|
334
|
+
const capability = req.headers.get(LOCAL_MANAGEMENT_CAPABILITY_HEADER);
|
|
335
|
+
const now = Date.now();
|
|
336
|
+
if (!verifyLocalManagementReadCapability(
|
|
337
|
+
local.attestationSecret,
|
|
338
|
+
req.headers.get(LOCAL_MANAGEMENT_NONCE_HEADER),
|
|
339
|
+
req.method,
|
|
340
|
+
url.pathname,
|
|
341
|
+
local.pid,
|
|
342
|
+
local.port,
|
|
343
|
+
expiresAt,
|
|
344
|
+
capability,
|
|
345
|
+
now,
|
|
346
|
+
)) return false;
|
|
347
|
+
for (const [consumed, retainedUntil] of consumedLocalReadCapabilities) {
|
|
348
|
+
if (retainedUntil <= now) consumedLocalReadCapabilities.delete(consumed);
|
|
349
|
+
}
|
|
350
|
+
if (!capability || consumedLocalReadCapabilities.has(capability)) return false;
|
|
351
|
+
if (consumedLocalReadCapabilities.size >= LOCAL_READ_REPLAY_LIMIT) return false;
|
|
352
|
+
consumedLocalReadCapabilities.set(capability, expiresAt);
|
|
353
|
+
admittedLocalReadRequests.add(req);
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
|
|
294
357
|
/**
|
|
295
358
|
* The principal for a request that already passed `requireManagementAuth`. Kept as a
|
|
296
359
|
* separate resolution (rather than a changed return type) so every existing caller
|
|
@@ -305,6 +368,7 @@ export function managementPrincipal(
|
|
|
305
368
|
local?: LocalManagementAuthContext,
|
|
306
369
|
): ManagementPrincipal | null {
|
|
307
370
|
if (hasSystemRestartCapability(req, local)) return "system-restart-capability";
|
|
371
|
+
if (hasLocalReadCapability(req, local)) return "local-read-capability";
|
|
308
372
|
if (!state.available) return null;
|
|
309
373
|
const actual = req.headers.get("x-opencodex-api-key")?.trim()
|
|
310
374
|
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
|
|
@@ -322,6 +386,7 @@ export function requireManagementAuth(
|
|
|
322
386
|
local?: LocalManagementAuthContext,
|
|
323
387
|
): Response | null {
|
|
324
388
|
if (hasSystemRestartCapability(req, local)) return null;
|
|
389
|
+
if (hasLocalReadCapability(req, local)) return null;
|
|
325
390
|
if (!state.available) {
|
|
326
391
|
return Response.json({
|
|
327
392
|
error: "management API unavailable",
|