@bitkyc08/opencodex 2.10.0 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +77 -0
- package/README.md +4 -10
- package/bin/ocx.mjs +71 -18
- package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
- package/gui/dist/assets/index-ChZQsmBY.js +70 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +8 -4
- package/src/adapters/anthropic.ts +208 -14
- package/src/adapters/base.ts +16 -5
- package/src/adapters/cursor/effort-map.ts +3 -2
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/live-transport.ts +105 -95
- package/src/adapters/cursor/native-exec.ts +32 -6
- package/src/adapters/cursor/protobuf-request.ts +20 -15
- package/src/adapters/cursor/request-builder.ts +21 -7
- package/src/adapters/cursor/types.ts +7 -0
- package/src/adapters/google-antigravity-replay.ts +237 -21
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +50 -9
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +10 -2
- package/src/adapters/kiro-tools.ts +10 -1
- package/src/adapters/kiro.ts +37 -11
- package/src/adapters/openai-chat.ts +284 -83
- package/src/adapters/openai-responses.ts +182 -24
- package/src/bridge.ts +177 -7
- package/src/chat/outbound.ts +78 -23
- package/src/claude/agents-inject.ts +27 -5
- package/src/claude/inbound.ts +11 -1
- package/src/claude/model-info.ts +13 -10
- package/src/claude/outbound.ts +17 -0
- package/src/cli/account-api.ts +24 -0
- package/src/cli/account-auth.ts +31 -6
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +5 -0
- package/src/cli/claude.ts +2 -1
- package/src/cli/doctor.ts +93 -22
- package/src/cli/export-command.ts +26 -12
- package/src/cli/help.ts +8 -6
- package/src/cli/index.ts +56 -22
- package/src/cli/integrations.ts +84 -1
- package/src/cli/observe.ts +54 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +18 -1
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +6 -3
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +1 -1
- package/src/cli/v2.ts +36 -9
- package/src/clients/config-export.ts +687 -10
- package/src/codex/account-lifecycle.ts +30 -5
- package/src/codex/account-usability.ts +22 -2
- package/src/codex/admission.ts +255 -0
- package/src/codex/auth-api.ts +427 -140
- package/src/codex/auth-context.ts +155 -30
- package/src/codex/autostart-health.ts +8 -1
- package/src/codex/catalog/account-models.ts +62 -0
- package/src/codex/catalog/aggregation.ts +14 -1
- package/src/codex/catalog/bundled.ts +282 -32
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/metadata.ts +51 -6
- package/src/codex/catalog/parsing.ts +6 -3
- package/src/codex/catalog/provider-fetch.ts +576 -41
- package/src/codex/catalog/sync.ts +505 -66
- package/src/codex/catalog-admission.ts +197 -0
- package/src/codex/catalog-write-serialization.ts +241 -0
- package/src/codex/catalog.ts +2 -1
- package/src/codex/codex-write-lock.ts +372 -0
- package/src/codex/convergence-types.ts +593 -0
- package/src/codex/convergence.ts +441 -0
- package/src/codex/desired-state.ts +177 -0
- package/src/codex/features.ts +52 -8
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +257 -0
- package/src/codex/history-lock.ts +241 -0
- package/src/codex/history-migration-guardian.ts +18 -5
- package/src/codex/history-provider.ts +9 -2
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +176 -0
- package/src/codex/inject-coordination.ts +245 -0
- package/src/codex/inject.ts +605 -124
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +10 -1
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/management-convergence.ts +114 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +167 -0
- package/src/codex/native-main-lock-file.ts +158 -0
- package/src/codex/native-main-owner.ts +315 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1512 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +340 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +557 -0
- package/src/codex/project-config-warnings.ts +18 -4
- package/src/codex/prompt-journal.ts +311 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +224 -0
- package/src/codex/quota.ts +86 -3
- package/src/codex/routing.ts +299 -62
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +39 -13
- package/src/codex/subagent-model-fallback.ts +73 -12
- package/src/codex/transition-state.ts +604 -0
- package/src/codex/upstream-host-health.ts +70 -0
- package/src/codex/user-identity.ts +266 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/config.ts +562 -26
- package/src/generated/jawcode-model-metadata.ts +2 -2
- package/src/grok/inject.ts +15 -4
- package/src/grok/inspect.ts +45 -0
- package/src/images/loop.ts +113 -20
- package/src/integrations/config-io.ts +151 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/native/ownership-preflight.ts +165 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +101 -0
- package/src/integrations/serialize.ts +235 -0
- package/src/integrations/state.ts +290 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer.ts +492 -0
- package/src/lib/bounded-body.ts +46 -8
- package/src/lib/bun-runtime.ts +110 -1
- package/src/lib/bun-stream-caps.ts +2 -1
- package/src/lib/redact.ts +407 -2
- package/src/lib/shadow-call.ts +24 -0
- package/src/lib/translator-budget.ts +10 -0
- package/src/lib/upstream-reachability.ts +91 -0
- package/src/lib/upstream-retry.ts +154 -2
- package/src/lib/windows-secret-acl.ts +212 -11
- package/src/lib/winsw.ts +9 -3
- package/src/oauth/index.ts +61 -3
- package/src/oauth/key-providers.ts +4 -0
- package/src/oauth/kiro.ts +50 -6
- package/src/oauth/store.ts +31 -6
- package/src/oauth/token-guardian.ts +9 -3
- package/src/providers/codex-capacity.ts +288 -0
- package/src/providers/derive.ts +33 -1
- package/src/providers/free-directory.ts +3 -1
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/openai-sidecar.ts +64 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/quota.ts +334 -26
- package/src/providers/registry.ts +284 -16
- package/src/providers/xai-transport.ts +11 -4
- package/src/responses/compaction.ts +8 -1
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/parser.ts +44 -2
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +105 -0
- package/src/responses/spill-store.ts +45 -8
- package/src/responses/state.ts +161 -17
- package/src/router.ts +302 -16
- package/src/routing/analytics.ts +377 -0
- package/src/routing/capability.ts +204 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +444 -0
- package/src/routing/health.ts +401 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +590 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile.ts +423 -0
- package/src/routing/quota.ts +124 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +686 -0
- package/src/server/auth-cors.ts +46 -6
- package/src/server/chat-completions.ts +28 -13
- package/src/server/claude-messages.ts +23 -15
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +10 -1
- package/src/server/index.ts +238 -52
- package/src/server/lifecycle.ts +228 -9
- package/src/server/live.ts +14 -3
- package/src/server/management/agent-settings-routes.ts +64 -14
- package/src/server/management/combo-routes.ts +5 -5
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/context.ts +42 -2
- package/src/server/management/integration-routes.ts +538 -0
- package/src/server/management/logs-usage-routes.ts +1 -1
- package/src/server/management/model-routes.ts +32 -113
- package/src/server/management/model-rows.ts +117 -0
- package/src/server/management/native-integration-routes.ts +587 -0
- package/src/server/management/oauth-account-routes.ts +1 -1
- package/src/server/management/provider-routes.ts +218 -117
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +177 -0
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +276 -24
- package/src/server/management/system-routes.ts +4 -0
- package/src/server/management-api.ts +84 -9
- package/src/server/management-auth.ts +43 -5
- package/src/server/relay-eager.ts +82 -42
- package/src/server/relay.ts +120 -6
- package/src/server/request-log.ts +26 -6
- package/src/server/responses/collaboration.ts +63 -8
- package/src/server/responses/compact.ts +272 -41
- package/src/server/responses/core.ts +730 -132
- package/src/server/responses/fetch-helpers.ts +15 -1
- package/src/server/responses-item-id-repair.ts +32 -3
- package/src/server/responses-json-events.ts +52 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/search.ts +51 -6
- package/src/server/sse-payload-rewrite.ts +89 -12
- package/src/server/startup-health-cache.ts +7 -1
- package/src/server/ws-bridge.ts +11 -17
- package/src/service-manager-probe.ts +297 -0
- package/src/service.ts +222 -32
- package/src/tray/windows-tray.ps1 +9 -0
- package/src/tray/windows.ts +15 -7
- package/src/types.ts +194 -14
- package/src/update/index.ts +13 -13
- package/src/update/job.ts +24 -21
- package/src/update/notify.ts +7 -3
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +129 -10
- package/src/usage/log.ts +50 -15
- package/src/usage/summary.ts +4 -4
- package/src/vision/index.ts +6 -1
- package/src/web-search/loop.ts +161 -34
- package/gui/dist/assets/index-OY43ubAq.css +0 -1
- package/gui/dist/assets/index-YwNnKZcL.js +0 -67
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
|
@@ -23,39 +23,116 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { spawn } from "node:child_process";
|
|
25
25
|
import {
|
|
26
|
+
beginShutdownDrain,
|
|
26
27
|
drainAndShutdown,
|
|
27
28
|
getActiveTurnCount,
|
|
28
29
|
getServerListenPort,
|
|
29
30
|
isDraining,
|
|
31
|
+
isShutdownDraining,
|
|
30
32
|
markRecyclingForExit,
|
|
31
|
-
|
|
33
|
+
stopServerListener,
|
|
32
34
|
} from "../lifecycle";
|
|
33
35
|
import { isServiceViable } from "../../service";
|
|
34
36
|
import { readRuntimePort } from "../../config";
|
|
37
|
+
import { withProcessRuntimeProvenance } from "../../lib/bun-runtime";
|
|
38
|
+
import { findLiveProxy } from "../proxy-liveness";
|
|
35
39
|
|
|
36
40
|
/** Fixed v1 drain window for the memory-card action (not config-driven). */
|
|
37
41
|
export const MEMORY_DRAIN_RESTART_MS = 60_000;
|
|
42
|
+
// Ordinary pinned-port start can spend 60s reclaiming a Windows ghost listener
|
|
43
|
+
// and another 5s settling it. Keep one polling/scheduler margin beyond that.
|
|
44
|
+
export const REPLACEMENT_READY_TIMEOUT_MS = 70_000;
|
|
45
|
+
export const DEADLINE_LISTENER_STOP_TIMEOUT_MS = 5_000;
|
|
46
|
+
const REPLACEMENT_READY_POLL_MS = 150;
|
|
47
|
+
|
|
48
|
+
export interface ReplacementReadinessIo {
|
|
49
|
+
findLive?: typeof findLiveProxy;
|
|
50
|
+
now?: () => number;
|
|
51
|
+
sleep?: (ms: number) => Promise<void>;
|
|
52
|
+
}
|
|
38
53
|
|
|
39
54
|
export interface SystemRestartIo {
|
|
40
55
|
drainAndShutdown?: typeof drainAndShutdown;
|
|
41
56
|
/** True when a background service can actually respawn this process after exit(1). */
|
|
42
57
|
isServiceViable?: () => boolean;
|
|
43
58
|
isSupervisedServiceChild?: () => boolean;
|
|
44
|
-
/**
|
|
45
|
-
spawnStart?: (port?: number) => void | Promise<void>;
|
|
59
|
+
/** Ordinary start; deadline handoff may defer health until parent exit releases OS locks. */
|
|
60
|
+
spawnStart?: (port?: number, waitForHealthBeforeParentExit?: boolean) => void | Promise<void>;
|
|
61
|
+
/** Idempotent listener close; must settle before an ordinary start is spawned. */
|
|
62
|
+
stopListener?: () => void | Promise<void>;
|
|
46
63
|
markRecycling?: () => void;
|
|
47
64
|
exitProcess?: (code: number) => void;
|
|
48
65
|
schedule?: (fn: () => void | Promise<void>, ms: number) => void;
|
|
66
|
+
scheduleDeadline?: (fn: () => void, ms: number) => () => void;
|
|
49
67
|
isDraining?: () => boolean;
|
|
68
|
+
isShutdownDraining?: () => boolean;
|
|
69
|
+
beginShutdownDrain?: () => boolean;
|
|
50
70
|
setDraining?: (value: boolean) => void;
|
|
51
71
|
getActiveTurnCount?: () => number;
|
|
52
72
|
listenPort?: () => number | undefined;
|
|
73
|
+
now?: () => number;
|
|
53
74
|
}
|
|
54
75
|
|
|
55
76
|
let restartIo: SystemRestartIo = {};
|
|
56
77
|
/** Prevents double-scheduling in the 200ms window before drainAndShutdown sets draining. */
|
|
57
78
|
let restartAccepted = false;
|
|
58
79
|
|
|
80
|
+
type RestartDrainOutcome = "completed" | "rejected" | "deadline";
|
|
81
|
+
type BoundedSettlementOutcome = "completed" | "rejected" | "deadline";
|
|
82
|
+
|
|
83
|
+
function waitForRestartDrain(
|
|
84
|
+
drainPromise: Promise<void>,
|
|
85
|
+
deadlineMs: number,
|
|
86
|
+
now: () => number,
|
|
87
|
+
scheduleDeadline: NonNullable<SystemRestartIo["scheduleDeadline"]>,
|
|
88
|
+
): Promise<RestartDrainOutcome> {
|
|
89
|
+
const remainingMs = Math.max(0, deadlineMs - now());
|
|
90
|
+
if (remainingMs === 0) {
|
|
91
|
+
// Observe any late rejection even though orchestration is already terminal.
|
|
92
|
+
void drainPromise.catch(() => {});
|
|
93
|
+
return Promise.resolve("deadline");
|
|
94
|
+
}
|
|
95
|
+
return new Promise<RestartDrainOutcome>((resolve) => {
|
|
96
|
+
let settled = false;
|
|
97
|
+
let cancelDeadline: (() => void) | undefined;
|
|
98
|
+
const finish = (outcome: RestartDrainOutcome) => {
|
|
99
|
+
if (settled) return;
|
|
100
|
+
settled = true;
|
|
101
|
+
cancelDeadline?.();
|
|
102
|
+
resolve(outcome);
|
|
103
|
+
};
|
|
104
|
+
cancelDeadline = scheduleDeadline(() => finish("deadline"), remainingMs);
|
|
105
|
+
if (settled) cancelDeadline();
|
|
106
|
+
void drainPromise.then(
|
|
107
|
+
() => finish("completed"),
|
|
108
|
+
() => finish("rejected"),
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function waitForBoundedSettlement(
|
|
114
|
+
promise: Promise<void>,
|
|
115
|
+
timeoutMs: number,
|
|
116
|
+
scheduleDeadline: NonNullable<SystemRestartIo["scheduleDeadline"]>,
|
|
117
|
+
): Promise<BoundedSettlementOutcome> {
|
|
118
|
+
return new Promise<BoundedSettlementOutcome>((resolve) => {
|
|
119
|
+
let settled = false;
|
|
120
|
+
let cancelDeadline: (() => void) | undefined;
|
|
121
|
+
const finish = (outcome: BoundedSettlementOutcome) => {
|
|
122
|
+
if (settled) return;
|
|
123
|
+
settled = true;
|
|
124
|
+
cancelDeadline?.();
|
|
125
|
+
resolve(outcome);
|
|
126
|
+
};
|
|
127
|
+
cancelDeadline = scheduleDeadline(() => finish("deadline"), timeoutMs);
|
|
128
|
+
if (settled) cancelDeadline();
|
|
129
|
+
void promise.then(
|
|
130
|
+
() => finish("completed"),
|
|
131
|
+
() => finish("rejected"),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
59
136
|
/** Test seam — reset between tests. */
|
|
60
137
|
export function setSystemRestartIoForTests(io: SystemRestartIo = {}): void {
|
|
61
138
|
restartIo = io;
|
|
@@ -86,10 +163,62 @@ function spawnFailureCode(err: unknown): string {
|
|
|
86
163
|
return "spawn_failed";
|
|
87
164
|
}
|
|
88
165
|
|
|
89
|
-
function
|
|
166
|
+
function handoffError(code: string): NodeJS.ErrnoException {
|
|
167
|
+
const error = new Error(code) as NodeJS.ErrnoException;
|
|
168
|
+
error.code = code;
|
|
169
|
+
return error;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function waitForReplacementReady(
|
|
173
|
+
expectedPid: number | undefined,
|
|
174
|
+
parentPid: number,
|
|
175
|
+
expectedPort: number | undefined,
|
|
176
|
+
io: ReplacementReadinessIo = {},
|
|
177
|
+
): Promise<boolean> {
|
|
178
|
+
const findLive = io.findLive ?? findLiveProxy;
|
|
179
|
+
const now = io.now ?? Date.now;
|
|
180
|
+
const sleep = io.sleep ?? Bun.sleep;
|
|
181
|
+
const deadline = now() + REPLACEMENT_READY_TIMEOUT_MS;
|
|
182
|
+
while (now() < deadline) {
|
|
183
|
+
try {
|
|
184
|
+
const live = await findLive({
|
|
185
|
+
deadlineAt: deadline,
|
|
186
|
+
nowFn: now,
|
|
187
|
+
sleepFn: sleep,
|
|
188
|
+
});
|
|
189
|
+
// A probe that began within budget can still return after it. Never accept
|
|
190
|
+
// delayed health as proof once the shared absolute handoff budget expired.
|
|
191
|
+
if (now() >= deadline) return false;
|
|
192
|
+
if (
|
|
193
|
+
live
|
|
194
|
+
&& live.pid !== null
|
|
195
|
+
&& live.pid !== parentPid
|
|
196
|
+
&& (expectedPid === undefined || live.pid === expectedPid)
|
|
197
|
+
&& (expectedPort === undefined || live.port === expectedPort)
|
|
198
|
+
) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// A not-yet-bound replacement is indistinguishable from a transient
|
|
203
|
+
// liveness failure here; keep polling inside the one bounded window.
|
|
204
|
+
}
|
|
205
|
+
const remainingMs = deadline - now();
|
|
206
|
+
if (remainingMs <= 0) break;
|
|
207
|
+
await sleep(Math.min(REPLACEMENT_READY_POLL_MS, remainingMs));
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function spawnDetachedStart(
|
|
213
|
+
port?: number,
|
|
214
|
+
waitForHealthBeforeParentExit = true,
|
|
215
|
+
): Promise<void> {
|
|
90
216
|
const args = [process.argv[1], "start"];
|
|
91
|
-
|
|
92
|
-
|
|
217
|
+
const expectedPort = typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535
|
|
218
|
+
? Math.trunc(port)
|
|
219
|
+
: undefined;
|
|
220
|
+
if (expectedPort !== undefined) {
|
|
221
|
+
args.push("--port", String(expectedPort));
|
|
93
222
|
}
|
|
94
223
|
return new Promise<void>((resolve, reject) => {
|
|
95
224
|
let child: ReturnType<typeof spawn>;
|
|
@@ -98,30 +227,113 @@ function spawnDetachedStart(port?: number): Promise<void> {
|
|
|
98
227
|
detached: true,
|
|
99
228
|
stdio: "ignore",
|
|
100
229
|
windowsHide: true,
|
|
101
|
-
env: { ...process.env, OCX_SERVICE: "1" },
|
|
230
|
+
env: withProcessRuntimeProvenance({ ...process.env, OCX_SERVICE: "1" }),
|
|
102
231
|
});
|
|
103
232
|
} catch (err) {
|
|
104
233
|
reject(err);
|
|
105
234
|
return;
|
|
106
235
|
}
|
|
107
236
|
let settled = false;
|
|
108
|
-
const
|
|
237
|
+
const cleanup = () => {
|
|
238
|
+
child.off("error", onError);
|
|
239
|
+
child.off("exit", onExit);
|
|
240
|
+
child.off("spawn", onSpawn);
|
|
241
|
+
};
|
|
242
|
+
const finish = (error?: unknown) => {
|
|
109
243
|
if (settled) return;
|
|
110
244
|
settled = true;
|
|
111
|
-
|
|
245
|
+
cleanup();
|
|
246
|
+
if (error !== undefined) {
|
|
247
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
248
|
+
try { child.kill(); } catch { /* best-effort failed-start cleanup */ }
|
|
249
|
+
}
|
|
250
|
+
try { child.unref(); } catch { /* best-effort */ }
|
|
251
|
+
reject(error);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
child.unref();
|
|
255
|
+
resolve();
|
|
112
256
|
};
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
child.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
257
|
+
const onError = (err: Error) => { finish(err); };
|
|
258
|
+
const onExit = () => { finish(handoffError("child_exit")); };
|
|
259
|
+
const onSpawn = () => {
|
|
260
|
+
if (!waitForHealthBeforeParentExit) {
|
|
261
|
+
// A deadline may have been caused by native-main ownership cleanup.
|
|
262
|
+
// Let the ordinary child survive parent exit, which releases those OS locks.
|
|
263
|
+
finish();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
void waitForReplacementReady(child.pid, process.pid, expectedPort).then(
|
|
267
|
+
ready => {
|
|
268
|
+
if (!ready) {
|
|
269
|
+
console.warn(
|
|
270
|
+
"Drain-and-restart replacement is still alive after the readiness window; allowing it to continue after parent exit",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
// Never kill a live ordinary start at its valid reclaim boundary. Parent
|
|
274
|
+
// exit is the final resource release the child may still be waiting for.
|
|
275
|
+
finish();
|
|
276
|
+
},
|
|
277
|
+
err => finish(err),
|
|
278
|
+
);
|
|
279
|
+
};
|
|
280
|
+
child.once("error", onError);
|
|
281
|
+
child.once("exit", onExit);
|
|
282
|
+
child.once("spawn", onSpawn);
|
|
122
283
|
});
|
|
123
284
|
}
|
|
124
285
|
|
|
286
|
+
async function completeDeferredParentExitHandoff(
|
|
287
|
+
io: SystemRestartIo,
|
|
288
|
+
exitProcess: (code: number) => void,
|
|
289
|
+
port: number | undefined,
|
|
290
|
+
phase: "deadline" | "listener-stop fallback",
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
try {
|
|
293
|
+
await (io.spawnStart ?? spawnDetachedStart)(port, false);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
console.warn(
|
|
296
|
+
`Drain-and-restart ${phase} spawn failed (${spawnFailureCode(err)}); exiting without replacement`,
|
|
297
|
+
);
|
|
298
|
+
delete process.env.OCX_SERVICE;
|
|
299
|
+
exitProcess(1);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
(io.markRecycling ?? markRecyclingForExit)();
|
|
303
|
+
exitProcess(0);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function completeDeadlineRestartHandoff(
|
|
307
|
+
io: SystemRestartIo,
|
|
308
|
+
exitProcess: (code: number) => void,
|
|
309
|
+
port: number | undefined,
|
|
310
|
+
scheduleDeadline: NonNullable<SystemRestartIo["scheduleDeadline"]>,
|
|
311
|
+
): Promise<void> {
|
|
312
|
+
const supervised = (io.isSupervisedServiceChild ?? (() => isSupervisedServiceChild(io)))();
|
|
313
|
+
if (supervised) {
|
|
314
|
+
// Failure-only supervisors ignore exit(0); intentional non-zero triggers respawn.
|
|
315
|
+
exitProcess(1);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const stopPromise = Promise.resolve().then(
|
|
320
|
+
() => (io.stopListener ?? (() => stopServerListener()))(),
|
|
321
|
+
);
|
|
322
|
+
const stopOutcome = await waitForBoundedSettlement(
|
|
323
|
+
stopPromise,
|
|
324
|
+
DEADLINE_LISTENER_STOP_TIMEOUT_MS,
|
|
325
|
+
scheduleDeadline,
|
|
326
|
+
);
|
|
327
|
+
if (stopOutcome === "rejected") {
|
|
328
|
+
console.warn("Drain-and-restart deadline listener stop failed; continuing parent-exit handoff");
|
|
329
|
+
} else if (stopOutcome === "deadline") {
|
|
330
|
+
console.warn("Drain-and-restart deadline listener stop timed out; continuing parent-exit handoff");
|
|
331
|
+
}
|
|
332
|
+
// The ordinary child must survive parent exit: a failed/pending socket close
|
|
333
|
+
// or overdue cleanup is completed by process teardown, without a hidden mode.
|
|
334
|
+
await completeDeferredParentExitHandoff(io, exitProcess, port, "deadline");
|
|
335
|
+
}
|
|
336
|
+
|
|
125
337
|
/**
|
|
126
338
|
* Accept a drain-and-restart request. Returns immediately; the drain +
|
|
127
339
|
* respawn runs on a short timer so the HTTP response can flush first.
|
|
@@ -133,27 +345,67 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
|
|
|
133
345
|
activeTurnCount: number;
|
|
134
346
|
drainTimeoutMs: number;
|
|
135
347
|
} {
|
|
136
|
-
const
|
|
348
|
+
const shutdownActive = io.isShutdownDraining
|
|
349
|
+
?? io.isDraining
|
|
350
|
+
?? isShutdownDraining;
|
|
351
|
+
const alreadyDraining = restartAccepted || shutdownActive();
|
|
137
352
|
const activeTurnCount = (io.getActiveTurnCount ?? getActiveTurnCount)();
|
|
138
353
|
const schedule = io.schedule ?? ((fn, ms) => { setTimeout(() => { void fn(); }, ms); });
|
|
139
354
|
|
|
140
355
|
if (!alreadyDraining) {
|
|
141
356
|
restartAccepted = true;
|
|
357
|
+
const now = io.now ?? Date.now;
|
|
358
|
+
const restartDeadlineMs = now() + MEMORY_DRAIN_RESTART_MS;
|
|
142
359
|
// Reject new data-plane traffic immediately (503), before the 200ms response-flush delay.
|
|
143
|
-
(io.
|
|
360
|
+
if (io.beginShutdownDrain) io.beginShutdownDrain();
|
|
361
|
+
else if (io.setDraining) io.setDraining(true);
|
|
362
|
+
else beginShutdownDrain();
|
|
144
363
|
schedule(async () => {
|
|
364
|
+
// Preserve the live binding before drainAndShutdown (or its deadline race)
|
|
365
|
+
// closes the listener and makes both the server ref and runtime metadata stale.
|
|
366
|
+
const restartPort = (io.listenPort ?? resolveListenPort)();
|
|
145
367
|
const drain = io.drainAndShutdown ?? drainAndShutdown;
|
|
146
|
-
|
|
368
|
+
const remainingMs = Math.max(0, restartDeadlineMs - now());
|
|
369
|
+
const drainPromise = Promise.resolve().then(() => drain(undefined, remainingMs));
|
|
370
|
+
const scheduleDeadline = io.scheduleDeadline ?? ((fn, ms) => {
|
|
371
|
+
const timer = setTimeout(fn, ms);
|
|
372
|
+
return () => clearTimeout(timer);
|
|
373
|
+
});
|
|
374
|
+
const drainOutcome = await waitForRestartDrain(drainPromise, restartDeadlineMs, now, scheduleDeadline);
|
|
375
|
+
const exitProcess = io.exitProcess ?? ((code: number) => { process.exit(code); });
|
|
376
|
+
if (drainOutcome === "deadline") {
|
|
377
|
+
console.warn("Drain-and-restart deadline expired; forcing terminal restart handoff");
|
|
378
|
+
await completeDeadlineRestartHandoff(io, exitProcess, restartPort, scheduleDeadline);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (drainOutcome === "rejected") {
|
|
382
|
+
// drainAndShutdown stops the listener in finally. Even if ancillary cleanup
|
|
383
|
+
// rejects, an accepted restart must still reach replacement or terminal exit.
|
|
384
|
+
console.warn("Drain-and-restart cleanup failed; continuing terminal restart handoff");
|
|
385
|
+
}
|
|
147
386
|
const supervised = (io.isSupervisedServiceChild ?? (() => isSupervisedServiceChild(io)))();
|
|
148
387
|
if (supervised) {
|
|
149
388
|
// Failure-only supervisors ignore exit(0); intentional non-zero triggers respawn.
|
|
150
389
|
(io.exitProcess ?? ((code: number) => { process.exit(code); }))(1);
|
|
151
390
|
return;
|
|
152
391
|
}
|
|
153
|
-
const port = (io.listenPort ?? resolveListenPort)();
|
|
154
|
-
const exitProcess = io.exitProcess ?? ((code: number) => { process.exit(code); });
|
|
155
392
|
try {
|
|
156
|
-
await (io.
|
|
393
|
+
await (io.stopListener ?? (() => stopServerListener()))();
|
|
394
|
+
} catch {
|
|
395
|
+
console.warn("Drain-and-restart listener stop failed; continuing parent-exit handoff");
|
|
396
|
+
await completeDeferredParentExitHandoff(
|
|
397
|
+
io,
|
|
398
|
+
exitProcess,
|
|
399
|
+
restartPort,
|
|
400
|
+
"listener-stop fallback",
|
|
401
|
+
);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
// A rejected drain has uncertain cleanup ownership, so it uses the same
|
|
406
|
+
// parent-exit handoff as a deadline. Only a fully completed drain waits
|
|
407
|
+
// for replacement health in the old process.
|
|
408
|
+
await (io.spawnStart ?? spawnDetachedStart)(restartPort, drainOutcome === "completed");
|
|
157
409
|
} catch (err) {
|
|
158
410
|
console.warn(
|
|
159
411
|
`⚠️ Drain-and-restart spawn failed (${spawnFailureCode(err)}); exiting without replacement`,
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* dashboard drain-and-restart confirm UX — never request bodies or IDs.
|
|
23
23
|
*/
|
|
24
24
|
import { selectEagerPath } from "../../lib/bun-stream-caps";
|
|
25
|
+
import { reportedBunRuntimeSource } from "../../lib/bun-runtime";
|
|
25
26
|
import { getActiveTurnCount, isDraining } from "../lifecycle";
|
|
26
27
|
import { getActiveMemoryWatchdog, observedMemoryCounter } from "../memory-watchdog";
|
|
27
28
|
import { responseStateMetrics } from "../../responses/state";
|
|
@@ -78,6 +79,9 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
78
79
|
pid: process.pid,
|
|
79
80
|
bunVersion: Bun.version,
|
|
80
81
|
bunRevision: Bun.revision,
|
|
82
|
+
// Recorded at launch, not resolved now: absent means "this service predates the
|
|
83
|
+
// marker", which callers must report as unknown rather than guess.
|
|
84
|
+
bunRuntimeSource: reportedBunRuntimeSource(),
|
|
81
85
|
platform: process.platform,
|
|
82
86
|
uptimeSeconds: process.uptime(),
|
|
83
87
|
rss: usage.rss,
|
|
@@ -59,6 +59,9 @@ import { applySystemEnvToggle } from "./system-env";
|
|
|
59
59
|
import type { ManagementApiDeps } from "./management/context";
|
|
60
60
|
import { handleConfigRoutes } from "./management/config-routes";
|
|
61
61
|
import { handleLogsUsageRoutes } from "./management/logs-usage-routes";
|
|
62
|
+
import { handleRequestHistoryRoutes } from "./management/request-history-routes";
|
|
63
|
+
import { handleRoutingAnalyticsRoutes } from "./management/routing-analytics-routes";
|
|
64
|
+
import { handleRoutingProfileRoutes } from "./management/routing-profile-routes";
|
|
62
65
|
import { handleProviderRoutes } from "./management/provider-routes";
|
|
63
66
|
import { handleModelRoutes } from "./management/model-routes";
|
|
64
67
|
import { handleAgentSettingsRoutes } from "./management/agent-settings-routes";
|
|
@@ -66,10 +69,14 @@ import { handleOauthAccountRoutes } from "./management/oauth-account-routes";
|
|
|
66
69
|
import { handleComboRoutes } from "./management/combo-routes";
|
|
67
70
|
import { handleSystemRoutes } from "./management/system-routes";
|
|
68
71
|
import { handleSidebarRoutes } from "./management/sidebar-routes";
|
|
72
|
+
import { handleIntegrationRoutes } from "./management/integration-routes";
|
|
73
|
+
import { handleNativeIntegrationRoutes } from "./management/native-integration-routes";
|
|
69
74
|
import type { ManagementContext } from "./management/context";
|
|
75
|
+
import type { ManagementPrincipal } from "./management-auth";
|
|
70
76
|
export type { ManagementApiDeps } from "./management/context";
|
|
71
77
|
import { fetchAllModels } from "./management/shared";
|
|
72
78
|
import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch";
|
|
79
|
+
import type { CatalogDisposition, ConvergeCodex } from "../codex/convergence-types";
|
|
73
80
|
import { managementBodyTooLargeResponse } from "./management/body";
|
|
74
81
|
|
|
75
82
|
// installed npm version instead of a stale hardcode.
|
|
@@ -81,7 +88,40 @@ export const VERSION = (() => {
|
|
|
81
88
|
}
|
|
82
89
|
})();
|
|
83
90
|
|
|
84
|
-
|
|
91
|
+
function isCatalogDisposition(value: unknown): value is CatalogDisposition {
|
|
92
|
+
if (!value || typeof value !== "object" || !("status" in value)) return false;
|
|
93
|
+
const disposition = value as Record<string, unknown>;
|
|
94
|
+
if (disposition.status === "committed") {
|
|
95
|
+
return typeof disposition.changed === "boolean"
|
|
96
|
+
&& typeof disposition.degraded === "boolean"
|
|
97
|
+
&& Array.isArray(disposition.notices)
|
|
98
|
+
&& disposition.notices.every(notice => notice === "provider-auth" || notice === "provider-network" || notice === "fallback");
|
|
99
|
+
}
|
|
100
|
+
if (disposition.status === "skipped") {
|
|
101
|
+
return ["not-requested", "catalog-unavailable", "busy", "stale", "refused"].includes(String(disposition.reason))
|
|
102
|
+
&& typeof disposition.retryable === "boolean";
|
|
103
|
+
}
|
|
104
|
+
if (disposition.status === "failed") {
|
|
105
|
+
return ["provider-auth", "provider-network", "disk"].includes(String(disposition.reason))
|
|
106
|
+
&& (disposition.phase === "gather" || disposition.phase === "commit")
|
|
107
|
+
&& typeof disposition.retryable === "boolean"
|
|
108
|
+
&& typeof disposition.partialWrite === "boolean";
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const managementConvergenceBindings = new WeakMap<object, Readonly<{
|
|
114
|
+
factory: (config: Readonly<OcxConfig>) => ConvergeCodex;
|
|
115
|
+
converge: ConvergeCodex;
|
|
116
|
+
}>>();
|
|
117
|
+
|
|
118
|
+
export async function handleManagementAPI(
|
|
119
|
+
req: Request,
|
|
120
|
+
url: URL,
|
|
121
|
+
config: OcxConfig,
|
|
122
|
+
deps: ManagementApiDeps = {},
|
|
123
|
+
principal?: ManagementPrincipal,
|
|
124
|
+
): Promise<Response | null> {
|
|
85
125
|
if (!isAllowedManagementOrigin(req, config)) {
|
|
86
126
|
return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
|
|
87
127
|
}
|
|
@@ -93,13 +133,38 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
93
133
|
return jsonResponse({ error: "request body too large" }, 413, req, config);
|
|
94
134
|
}
|
|
95
135
|
}
|
|
96
|
-
async function
|
|
97
|
-
|
|
136
|
+
async function convergeCodexCatalog(): Promise<CatalogDisposition> {
|
|
137
|
+
let convergenceInvoked = false;
|
|
138
|
+
let managementConvergeCodex: ConvergeCodex | undefined;
|
|
98
139
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
140
|
+
if (!managementConvergeCodex) {
|
|
141
|
+
const factory = deps.createManagementConvergeCodex
|
|
142
|
+
?? (await import("../codex/management-convergence")).createManagementConvergeCodex;
|
|
143
|
+
if (typeof factory !== "function") throw new TypeError("Catalog convergence factory is unavailable.");
|
|
144
|
+
let binding = managementConvergenceBindings.get(config);
|
|
145
|
+
if (!binding || binding.factory !== factory) {
|
|
146
|
+
const created = factory(config);
|
|
147
|
+
if (typeof created !== "function") throw new TypeError("Catalog convergence factory returned no function.");
|
|
148
|
+
binding = { factory, converge: created };
|
|
149
|
+
managementConvergenceBindings.set(config, binding);
|
|
150
|
+
}
|
|
151
|
+
managementConvergeCodex = binding.converge;
|
|
152
|
+
}
|
|
153
|
+
const { createCatalogConvergeRequest } = await import("../codex/catalog-admission");
|
|
154
|
+
convergenceInvoked = true;
|
|
155
|
+
const outcome = await managementConvergeCodex(createCatalogConvergeRequest({ deadlineMs: 1_000 }));
|
|
156
|
+
if (!outcome || outcome.kind !== "catalog-only" || !isCatalogDisposition(outcome.catalogRefresh)) {
|
|
157
|
+
throw new TypeError("Catalog convergence returned an invalid outcome.");
|
|
158
|
+
}
|
|
159
|
+
return outcome.catalogRefresh;
|
|
101
160
|
} catch {
|
|
102
|
-
|
|
161
|
+
return {
|
|
162
|
+
status: "failed",
|
|
163
|
+
reason: "disk",
|
|
164
|
+
phase: convergenceInvoked ? "commit" : "gather",
|
|
165
|
+
retryable: false,
|
|
166
|
+
partialWrite: convergenceInvoked,
|
|
167
|
+
};
|
|
103
168
|
}
|
|
104
169
|
}
|
|
105
170
|
|
|
@@ -124,13 +189,18 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
124
189
|
}
|
|
125
190
|
} catch { /* best-effort */ }
|
|
126
191
|
}
|
|
127
|
-
const ctx: ManagementContext = { req, url, config, deps,
|
|
192
|
+
const ctx: ManagementContext = { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort };
|
|
128
193
|
let routed: Response | null;
|
|
129
194
|
try {
|
|
130
195
|
routed = (await handleConfigRoutes(ctx))
|
|
131
196
|
?? (await handleLogsUsageRoutes(ctx))
|
|
197
|
+
?? (await handleRequestHistoryRoutes(ctx))
|
|
198
|
+
?? (await handleRoutingAnalyticsRoutes(ctx))
|
|
199
|
+
?? (await handleRoutingProfileRoutes(ctx))
|
|
132
200
|
?? (await handleProviderRoutes(ctx))
|
|
133
201
|
?? (await handleModelRoutes(ctx))
|
|
202
|
+
?? (await handleIntegrationRoutes(ctx))
|
|
203
|
+
?? (await handleNativeIntegrationRoutes(ctx))
|
|
134
204
|
?? (await handleAgentSettingsRoutes(ctx))
|
|
135
205
|
?? (await handleOauthAccountRoutes(ctx))
|
|
136
206
|
?? (await handleComboRoutes(ctx))
|
|
@@ -154,7 +224,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
154
224
|
if (routed) return routed;
|
|
155
225
|
|
|
156
226
|
if (url.pathname === "/api/stop" && req.method === "POST") {
|
|
157
|
-
const {
|
|
227
|
+
const { restoreNativeCodexAsync } = await import("../codex/inject");
|
|
158
228
|
const { stopServiceIfInstalled, isServiceOwnershipError } = await import("../service");
|
|
159
229
|
try {
|
|
160
230
|
stopServiceIfInstalled();
|
|
@@ -167,7 +237,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
167
237
|
}
|
|
168
238
|
throw err;
|
|
169
239
|
}
|
|
170
|
-
const restore =
|
|
240
|
+
const restore = await restoreNativeCodexAsync();
|
|
171
241
|
// Both managed configs come down together on an explicit teardown. The daemon's own
|
|
172
242
|
// syncCleanup skips this when OCX_SERVICE is set (so a crash/respawn keeps the fence),
|
|
173
243
|
// which is exactly why an intentional stop has to do it here.
|
|
@@ -183,6 +253,11 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
183
253
|
: { success: false, message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.${grokNote}` });
|
|
184
254
|
}
|
|
185
255
|
|
|
256
|
+
if (url.pathname.startsWith("/api/native-main-profiles")) {
|
|
257
|
+
const { handleNativeProfileAPI } = await import("../codex/native-profile-api");
|
|
258
|
+
return handleNativeProfileAPI(req, url, config, deps.nativeProfileApi);
|
|
259
|
+
}
|
|
260
|
+
|
|
186
261
|
if (url.pathname.startsWith("/api/codex-auth/")) {
|
|
187
262
|
const { handleCodexAuthAPI } = await import("../codex/auth-api");
|
|
188
263
|
const { ConfigMutationLockError } = await import("../config");
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "node:fs";
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
15
|
import { adminApiTokenFilePath } from "../lib/admin-secrets";
|
|
16
|
-
import { forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
|
|
16
|
+
import { forgetEphemeralSecretPath, forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
|
|
17
17
|
import type { OcxConfig } from "../types";
|
|
18
18
|
import {
|
|
19
19
|
isAllowedManagementOrigin,
|
|
@@ -95,12 +95,17 @@ function readExistingToken(path: string): string {
|
|
|
95
95
|
export function removeManagementTokenPathBestEffort(
|
|
96
96
|
path: string,
|
|
97
97
|
remove: (path: string) => void = unlinkSync,
|
|
98
|
+
options?: { ephemeral?: boolean },
|
|
98
99
|
): void {
|
|
100
|
+
// Temps get the full ephemeral release (success + both timeout namespaces);
|
|
101
|
+
// stable token paths drop only the success memo — destination-keyed timeout
|
|
102
|
+
// memos are intentional anti-restall state.
|
|
103
|
+
const forget = options?.ephemeral ? forgetEphemeralSecretPath : forgetHardenedSecretPath;
|
|
99
104
|
try {
|
|
100
105
|
remove(path);
|
|
101
|
-
|
|
106
|
+
forget(path);
|
|
102
107
|
} catch (error) {
|
|
103
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
108
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") forget(path);
|
|
104
109
|
/* other failures retain fail-closed state for the caller */
|
|
105
110
|
}
|
|
106
111
|
}
|
|
@@ -120,7 +125,8 @@ function createTokenFile(path: string): string {
|
|
|
120
125
|
chmodSync(temporary, 0o600);
|
|
121
126
|
let temporaryHardened: { ok: boolean };
|
|
122
127
|
try {
|
|
123
|
-
|
|
128
|
+
// Destination-keyed timeout memo (the final token path), not the temp.
|
|
129
|
+
temporaryHardened = hardenSecretPath(temporary, { required: true, timeoutMemoKey: path });
|
|
124
130
|
} catch {
|
|
125
131
|
temporaryHardened = { ok: false };
|
|
126
132
|
}
|
|
@@ -155,7 +161,7 @@ function createTokenFile(path: string): string {
|
|
|
155
161
|
if (fd !== null) {
|
|
156
162
|
try { closeSync(fd); } catch { /* best effort */ }
|
|
157
163
|
}
|
|
158
|
-
removeManagementTokenPathBestEffort(temporary);
|
|
164
|
+
removeManagementTokenPathBestEffort(temporary, unlinkSync, { ephemeral: true });
|
|
159
165
|
}
|
|
160
166
|
}
|
|
161
167
|
|
|
@@ -231,6 +237,38 @@ export function issueGuiSession(
|
|
|
231
237
|
return { token, ...session };
|
|
232
238
|
}
|
|
233
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Which credential actually authorized a management request.
|
|
242
|
+
*
|
|
243
|
+
* `admin-token` is the raw token from disk/env: anything running as the user can
|
|
244
|
+
* read it, including a coding agent. `gui-session` is a session token this process
|
|
245
|
+
* minted for a browser, and it only authorizes a mutation after the origin and the
|
|
246
|
+
* per-session CSRF token match. Consent-bearing routes must key off this value
|
|
247
|
+
* rather than off request headers, which the token holder can forge freely.
|
|
248
|
+
*/
|
|
249
|
+
export type ManagementPrincipal = "admin-token" | "gui-session";
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The principal for a request that already passed `requireManagementAuth`. Kept as a
|
|
253
|
+
* separate resolution (rather than a changed return type) so every existing caller
|
|
254
|
+
* keeps its `Response | null` contract; the value is derived from the same session
|
|
255
|
+
* table and the same CSRF comparison the gate uses, so the two cannot disagree.
|
|
256
|
+
*/
|
|
257
|
+
export function managementPrincipal(
|
|
258
|
+
req: Request,
|
|
259
|
+
state: ManagementAuthState,
|
|
260
|
+
config?: OcxConfig,
|
|
261
|
+
): ManagementPrincipal | null {
|
|
262
|
+
if (!state.available) return null;
|
|
263
|
+
const actual = req.headers.get("x-opencodex-api-key")?.trim()
|
|
264
|
+
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
|
|
265
|
+
if (!actual) return null;
|
|
266
|
+
if (equalSecret(actual, state.token)) return "admin-token";
|
|
267
|
+
if (!config) return null;
|
|
268
|
+
removeExpiredSessions(state);
|
|
269
|
+
return state.sessions.has(actual) ? "gui-session" : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
234
272
|
export function requireManagementAuth(
|
|
235
273
|
req: Request,
|
|
236
274
|
state: ManagementAuthState,
|