@bitkyc08/opencodex 2.10.2 → 2.11.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 +31 -0
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/index-Bk-PN-70.css +1 -0
- package/gui/dist/assets/index-BynIEIV-.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/live-transport.ts +11 -0
- package/src/adapters/cursor/native-exec-fs.ts +9 -6
- package/src/adapters/cursor/native-exec.ts +4 -2
- package/src/adapters/cursor/protobuf-events.ts +176 -4
- package/src/adapters/cursor/request-builder.ts +15 -4
- package/src/adapters/cursor/tool-definitions.ts +118 -2
- package/src/adapters/google.ts +15 -5
- package/src/adapters/openai-chat.ts +24 -2
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +9 -5
- package/src/chat/outbound.ts +4 -3
- package/src/claude/desktop-3p.ts +222 -2
- package/src/claude/outbound.ts +15 -6
- package/src/cli/account-api.ts +4 -0
- package/src/cli/account-extended.ts +112 -0
- package/src/cli/account.ts +23 -6
- package/src/cli/claude-desktop.ts +26 -3
- package/src/cli/config-command.ts +9 -0
- package/src/cli/help.ts +18 -2
- package/src/cli/index.ts +277 -55
- package/src/cli/models.ts +5 -1
- package/src/cli/provider.ts +8 -2
- package/src/cli/ready.ts +301 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/clients/config-export.ts +12 -19
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/auth-api.ts +83 -0
- package/src/codex/auth-context.ts +5 -2
- package/src/codex/catalog/provider-fetch.ts +11 -0
- package/src/codex/catalog/sync.ts +23 -1
- package/src/codex/codex-write-lock.ts +16 -4
- package/src/codex/desired-state.ts +37 -4
- package/src/codex/history-job.ts +15 -5
- package/src/codex/history-provider.ts +31 -14
- package/src/codex/history-worker.ts +28 -4
- package/src/codex/inject-coordination.ts +13 -1
- package/src/codex/inject.ts +360 -66
- package/src/codex/internal/history-writer.ts +1 -1
- package/src/codex/native-main-lock-file.ts +5 -1
- package/src/codex/native-main-owner.ts +17 -3
- package/src/codex/native-profile-manager.ts +19 -0
- package/src/codex/native-profile-startup.ts +8 -0
- package/src/codex/native-residue.ts +140 -27
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/refresh.ts +7 -0
- package/src/codex/routing.ts +177 -36
- package/src/codex/subagent-model-fallback.ts +34 -4
- package/src/codex/sync.ts +61 -0
- package/src/codex/upstream-host-health.ts +329 -31
- package/src/combos/request.ts +2 -0
- package/src/config.ts +221 -2
- package/src/images/loop.ts +1 -1
- package/src/integrations/native/ownership-preflight.ts +39 -2
- package/src/lib/bun-stream-caps.ts +3 -3
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/windows-secret-acl.ts +141 -39
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +18 -2
- package/src/oauth/key-providers.ts +12 -0
- package/src/providers/derive.ts +54 -2
- package/src/providers/free-directory.ts +6 -5
- package/src/providers/model-discovery.ts +9 -3
- package/src/providers/quota.ts +592 -0
- package/src/providers/registry.ts +316 -13
- package/src/responses/parser.ts +26 -10
- package/src/responses/reasoning-replay-cache.ts +1 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +2 -1
- package/src/server/auth-cors.ts +44 -13
- package/src/server/chat-completions.ts +0 -4
- package/src/server/claude-messages.ts +73 -15
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/index.ts +328 -111
- package/src/server/lifecycle.ts +36 -0
- package/src/server/management/agent-settings-routes.ts +147 -56
- package/src/server/management/config-routes.ts +7 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/native-integration-routes.ts +199 -20
- package/src/server/management/provider-routes.ts +41 -0
- package/src/server/management/routing-profile-routes.ts +234 -5
- package/src/server/management/system-restart.ts +12 -10
- package/src/server/management/system-routes.ts +20 -0
- package/src/server/management-auth.ts +51 -3
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +129 -4
- package/src/server/readiness.ts +99 -0
- package/src/server/relay.ts +113 -97
- package/src/server/request-log.ts +10 -4
- package/src/server/responses/compact.ts +107 -12
- package/src/server/responses/core.ts +220 -39
- package/src/server/responses-item-id-repair.ts +22 -3
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +25 -14
- package/src/server/ws-bridge.ts +27 -22
- package/src/service-manager-probe.ts +520 -10
- package/src/service.ts +134 -2
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +74 -9
- package/src/types.ts +68 -2
- package/src/update/index.ts +12 -0
- package/src/update/job.ts +392 -18
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/usage/log.ts +1 -1
- package/src/vision/index.ts +77 -2
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/parse.ts +4 -1
- package/gui/dist/assets/index-BKVqyYqT.js +0 -70
- package/gui/dist/assets/index-Ca_3269W.css +0 -1
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Routing-profile management API (RI-04).
|
|
3
3
|
*
|
|
4
|
-
* - `GET
|
|
5
|
-
* - `
|
|
4
|
+
* - `GET /api/routing-profiles` - normalized profiles with revisions
|
|
5
|
+
* - `PUT /api/routing-profiles` - create or replace one validated profile
|
|
6
|
+
* - `DELETE /api/routing-profiles?id=<id>` - remove one profile
|
|
7
|
+
* - `POST /api/routing-profiles/dry-run` - deterministic dry-run evaluation
|
|
6
8
|
* (never dispatches an upstream request)
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
getRoutingProfile,
|
|
13
|
+
listRoutingProfileIds,
|
|
14
|
+
normalizeRoutingProfile,
|
|
15
|
+
policyPublicModelId,
|
|
16
|
+
routingProfileIssues,
|
|
17
|
+
} from "../../routing/profile";
|
|
10
18
|
import { evaluatePolicyProfile, type PolicyCandidateEvidence, type PolicyRequestEvidence } from "../../routing/evaluator";
|
|
11
19
|
import { candidateCapabilityEvidence } from "../../routing/capability";
|
|
12
20
|
import { policyCandidateHealthEvidence } from "../../routing/health";
|
|
@@ -16,17 +24,20 @@ import { providerCodexAccountMode } from "../../providers/registry";
|
|
|
16
24
|
import { getEffectiveActiveCodexAccountId } from "../../codex/routing";
|
|
17
25
|
import { getAccountSet } from "../../oauth/store";
|
|
18
26
|
import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
27
|
+
import { saveConfigPreservingClaudeCode } from "../../config";
|
|
28
|
+
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
19
29
|
import { isPlainRecord } from "./shared";
|
|
20
30
|
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
21
31
|
import { jsonResponse } from "../auth-cors";
|
|
22
32
|
import type { ManagementContext } from "./context";
|
|
23
|
-
import type { OcxConfig } from "../../types";
|
|
33
|
+
import type { OcxConfig, OcxRoutingProfileConfig } from "../../types";
|
|
24
34
|
|
|
25
35
|
function profileDto(config: Parameters<typeof getRoutingProfile>[0], id: string): Record<string, unknown> | null {
|
|
26
36
|
const profile = getRoutingProfile(config, id);
|
|
27
37
|
if (!profile) return null;
|
|
28
38
|
return {
|
|
29
39
|
id,
|
|
40
|
+
alias: profile.alias,
|
|
30
41
|
model: policyPublicModelId(id, profile),
|
|
31
42
|
revision: profile.revision,
|
|
32
43
|
candidates: profile.candidates,
|
|
@@ -131,8 +142,112 @@ function assembleCandidateEvidence(
|
|
|
131
142
|
}));
|
|
132
143
|
}
|
|
133
144
|
|
|
145
|
+
function storedProfile(
|
|
146
|
+
id: string,
|
|
147
|
+
raw: OcxRoutingProfileConfig,
|
|
148
|
+
): OcxRoutingProfileConfig {
|
|
149
|
+
const normalized = normalizeRoutingProfile(id, raw);
|
|
150
|
+
const {
|
|
151
|
+
id: _id,
|
|
152
|
+
revision: _revision,
|
|
153
|
+
alias,
|
|
154
|
+
...profile
|
|
155
|
+
} = normalized;
|
|
156
|
+
return alias === null ? profile : { ...profile, alias };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Rewrite config references from one public model id to another (alias change
|
|
161
|
+
* on update). Mirrors the /api/combos migration: model-valued config that
|
|
162
|
+
* still names the old alias must follow it, or requests fall through to
|
|
163
|
+
* ordinary routing and send the obsolete alias upstream.
|
|
164
|
+
*/
|
|
165
|
+
/**
|
|
166
|
+
* Detect a modelMap key collision that the alias migration would silently
|
|
167
|
+
* resolve by dropping one mapping: the map already contains the new public
|
|
168
|
+
* model as a key with a different target than the old-alias key's target.
|
|
169
|
+
*/
|
|
170
|
+
function modelMapMigrationCollision(
|
|
171
|
+
config: OcxConfig,
|
|
172
|
+
oldPublicModel: string,
|
|
173
|
+
newPublicModel: string,
|
|
174
|
+
): string | null {
|
|
175
|
+
const map = config.claudeCode?.modelMap;
|
|
176
|
+
if (!map) return null;
|
|
177
|
+
if (oldPublicModel === newPublicModel) return null;
|
|
178
|
+
const oldTarget = map[oldPublicModel];
|
|
179
|
+
if (oldTarget === undefined) return null;
|
|
180
|
+
const newTarget = map[newPublicModel];
|
|
181
|
+
if (newTarget === undefined) return null;
|
|
182
|
+
if (oldTarget === newTarget) return null;
|
|
183
|
+
return `modelMap already maps \"${newPublicModel}\" to \"${newTarget}\"; renaming \"${oldPublicModel}\" (→ \"${newTarget}\") would drop one mapping. Resolve the conflict and retry.`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Rewrite config references from one public model id to another (alias change
|
|
188
|
+
* on update). Mirrors the /api/combos migration: model-valued config that
|
|
189
|
+
* still names the old alias must follow it, or requests fall through to
|
|
190
|
+
* ordinary routing and send the obsolete alias upstream.
|
|
191
|
+
*/
|
|
192
|
+
function migrateProfileModelReferences(
|
|
193
|
+
config: OcxConfig,
|
|
194
|
+
oldPublicModel: string,
|
|
195
|
+
newPublicModel: string,
|
|
196
|
+
): boolean {
|
|
197
|
+
if (oldPublicModel === newPublicModel) return false;
|
|
198
|
+
const migrateReference = (model: string): string => (
|
|
199
|
+
model === oldPublicModel ? newPublicModel : model
|
|
200
|
+
);
|
|
201
|
+
let shouldSyncClaudeAgentDefs = false;
|
|
202
|
+
const migrateAgentReference = (model: string): string => {
|
|
203
|
+
const migrated = migrateReference(model);
|
|
204
|
+
if (migrated !== model) shouldSyncClaudeAgentDefs = true;
|
|
205
|
+
return migrated;
|
|
206
|
+
};
|
|
207
|
+
const migrateReferences = (models: string[]): string[] => [
|
|
208
|
+
...new Set(models.map(migrateReference)),
|
|
209
|
+
];
|
|
210
|
+
if (config.disabledModels) {
|
|
211
|
+
config.disabledModels = migrateReferences(config.disabledModels);
|
|
212
|
+
}
|
|
213
|
+
if (config.subagentModels) {
|
|
214
|
+
config.subagentModels = [...new Set(config.subagentModels.map(migrateAgentReference))];
|
|
215
|
+
}
|
|
216
|
+
if (config.subagentModelFallback) {
|
|
217
|
+
config.subagentModelFallback = [...new Set(config.subagentModelFallback.map(migrateAgentReference))];
|
|
218
|
+
}
|
|
219
|
+
if (config.injectionModel && config.injectionModel === oldPublicModel) {
|
|
220
|
+
config.injectionModel = newPublicModel;
|
|
221
|
+
}
|
|
222
|
+
if (config.shadowCallIntercept?.model && config.shadowCallIntercept.model === oldPublicModel) {
|
|
223
|
+
config.shadowCallIntercept = { ...config.shadowCallIntercept, model: newPublicModel };
|
|
224
|
+
}
|
|
225
|
+
if (config.claudeCode) {
|
|
226
|
+
const claudeCode = { ...config.claudeCode };
|
|
227
|
+
for (const field of ["model", "smallFastModel"] as const) {
|
|
228
|
+
if (claudeCode[field]) claudeCode[field] = migrateAgentReference(claudeCode[field]);
|
|
229
|
+
}
|
|
230
|
+
if (claudeCode.tierModels) {
|
|
231
|
+
claudeCode.tierModels = Object.fromEntries(
|
|
232
|
+
Object.entries(claudeCode.tierModels).map(([tier, model]) => [tier, migrateAgentReference(model)]),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (claudeCode.modelMap) {
|
|
236
|
+
// Keys are the inbound ids matched for reroute (src/claude/inbound.ts);
|
|
237
|
+
// an old-alias key must follow the rename or that request stops intercepting.
|
|
238
|
+
claudeCode.modelMap = Object.fromEntries(
|
|
239
|
+
Object.entries(claudeCode.modelMap).map(([source, model]) => [
|
|
240
|
+
migrateAgentReference(source),
|
|
241
|
+
migrateAgentReference(model),
|
|
242
|
+
]),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
config.claudeCode = claudeCode;
|
|
246
|
+
}
|
|
247
|
+
return shouldSyncClaudeAgentDefs;
|
|
248
|
+
}
|
|
134
249
|
export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
135
|
-
const { req, url, config } = ctx;
|
|
250
|
+
const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
|
|
136
251
|
|
|
137
252
|
if (url.pathname === "/api/routing-profiles" && req.method === "GET") {
|
|
138
253
|
const profiles = listRoutingProfileIds(config).map(id => profileDto(config, id)).filter(
|
|
@@ -141,6 +256,120 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis
|
|
|
141
256
|
return jsonResponse({ profiles }, 200, req, config);
|
|
142
257
|
}
|
|
143
258
|
|
|
259
|
+
if (url.pathname === "/api/routing-profiles" && req.method === "PUT") {
|
|
260
|
+
let rawBody: unknown;
|
|
261
|
+
try {
|
|
262
|
+
rawBody = await readManagementJsonBody(req);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
rethrowManagementBodyTooLarge(error);
|
|
265
|
+
return jsonResponse({ error: "invalid JSON body" }, 400, req, config);
|
|
266
|
+
}
|
|
267
|
+
if (!isPlainRecord(rawBody)) {
|
|
268
|
+
return jsonResponse({ error: "request body must be an object" }, 400, req, config);
|
|
269
|
+
}
|
|
270
|
+
const body = rawBody as Record<string, unknown>;
|
|
271
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
272
|
+
if (!id) {
|
|
273
|
+
return jsonResponse({ error: { code: "missing_profile_id", message: "id is required" } }, 400, req, config);
|
|
274
|
+
}
|
|
275
|
+
const mode = body.mode === "create" || body.mode === "update" ? body.mode : null;
|
|
276
|
+
if (!mode) {
|
|
277
|
+
return jsonResponse({ error: { code: "invalid_profile_mode", message: "mode must be create or update" } }, 400, req, config);
|
|
278
|
+
}
|
|
279
|
+
const exists = Object.hasOwn(config.routingProfiles ?? {}, id);
|
|
280
|
+
if (mode === "create" && exists) {
|
|
281
|
+
return jsonResponse({ error: { code: "profile_exists", message: `routing profile already exists: ${id}` } }, 409, req, config);
|
|
282
|
+
}
|
|
283
|
+
if (mode === "update" && !exists) {
|
|
284
|
+
return jsonResponse({ error: { code: "unknown_profile", message: `unknown routing profile: ${id}` } }, 404, req, config);
|
|
285
|
+
}
|
|
286
|
+
if (mode === "update") {
|
|
287
|
+
const expectedRevision = typeof body.expectedRevision === "string" && body.expectedRevision.trim()
|
|
288
|
+
? body.expectedRevision.trim()
|
|
289
|
+
: undefined;
|
|
290
|
+
if (expectedRevision) {
|
|
291
|
+
const current = getRoutingProfile(config, id);
|
|
292
|
+
if (current && current.revision !== expectedRevision) {
|
|
293
|
+
return jsonResponse({
|
|
294
|
+
error: {
|
|
295
|
+
code: "profile_revision_conflict",
|
|
296
|
+
message: `routing profile ${id} changed since it was loaded; reload and retry`,
|
|
297
|
+
currentRevision: current.revision,
|
|
298
|
+
},
|
|
299
|
+
}, 409, req, config);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const issues = routingProfileIssues(id, body.profile, config, { excludeProfileId: id });
|
|
304
|
+
if (issues.length > 0) {
|
|
305
|
+
return jsonResponse({
|
|
306
|
+
error: {
|
|
307
|
+
code: "invalid_profile",
|
|
308
|
+
message: issues[0]!.message,
|
|
309
|
+
issues,
|
|
310
|
+
},
|
|
311
|
+
}, 400, req, config);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const previousProfile = mode === "update" ? getRoutingProfile(config, id) : undefined;
|
|
315
|
+
if (mode === "update" && previousProfile) {
|
|
316
|
+
const oldPublicModel = policyPublicModelId(id, previousProfile);
|
|
317
|
+
const newProfile = normalizeRoutingProfile(id, body.profile as OcxRoutingProfileConfig);
|
|
318
|
+
const newPublicModel = policyPublicModelId(id, newProfile);
|
|
319
|
+
const collision = modelMapMigrationCollision(config, oldPublicModel, newPublicModel);
|
|
320
|
+
if (collision) {
|
|
321
|
+
return jsonResponse({
|
|
322
|
+
error: { code: "alias_reference_conflict", message: collision },
|
|
323
|
+
}, 409, req, config);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const nextProfiles = { ...(config.routingProfiles ?? {}) };
|
|
327
|
+
nextProfiles[id] = storedProfile(id, body.profile as OcxRoutingProfileConfig);
|
|
328
|
+
config.routingProfiles = nextProfiles;
|
|
329
|
+
// An alias change on update renames the public model id; rewrite config
|
|
330
|
+
// references (disabledModels, subagentModels, injectionModel,
|
|
331
|
+
// shadowCallIntercept, claudeCode) so they follow the new alias.
|
|
332
|
+
let shouldSyncClaudeAgentDefs = false;
|
|
333
|
+
if (previousProfile) {
|
|
334
|
+
const oldPublicModel = policyPublicModelId(id, previousProfile);
|
|
335
|
+
const newPublicModel = policyPublicModelId(id, getRoutingProfile(config, id)!);
|
|
336
|
+
shouldSyncClaudeAgentDefs = migrateProfileModelReferences(config, oldPublicModel, newPublicModel);
|
|
337
|
+
}
|
|
338
|
+
const save = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
|
|
339
|
+
save(config);
|
|
340
|
+
reconcileLiveStateStores();
|
|
341
|
+
const catalogRefresh = await convergeCodexCatalog();
|
|
342
|
+
if (shouldSyncClaudeAgentDefs) await syncClaudeAgentDefsBestEffort();
|
|
343
|
+
const profile = profileDto(config, id)!;
|
|
344
|
+
return jsonResponse({
|
|
345
|
+
success: true,
|
|
346
|
+
id,
|
|
347
|
+
model: profile.model,
|
|
348
|
+
profile,
|
|
349
|
+
catalogRefresh,
|
|
350
|
+
}, 200, req, config);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (url.pathname === "/api/routing-profiles" && req.method === "DELETE") {
|
|
354
|
+
const id = url.searchParams.get("id")?.trim();
|
|
355
|
+
if (!id) {
|
|
356
|
+
return jsonResponse({ error: "id query param is required" }, 400, req, config);
|
|
357
|
+
}
|
|
358
|
+
if (!Object.hasOwn(config.routingProfiles ?? {}, id)) {
|
|
359
|
+
return jsonResponse({ error: "unknown routing profile" }, 404, req, config);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const nextProfiles = { ...(config.routingProfiles ?? {}) };
|
|
363
|
+
delete nextProfiles[id];
|
|
364
|
+
if (Object.keys(nextProfiles).length > 0) config.routingProfiles = nextProfiles;
|
|
365
|
+
else delete config.routingProfiles;
|
|
366
|
+
const saveConfigPreservingClaudeCodeSafe = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
|
|
367
|
+
saveConfigPreservingClaudeCodeSafe(config);
|
|
368
|
+
reconcileLiveStateStores();
|
|
369
|
+
const catalogRefresh = await convergeCodexCatalog();
|
|
370
|
+
return jsonResponse({ success: true, id, catalogRefresh }, 200, req, config);
|
|
371
|
+
}
|
|
372
|
+
|
|
144
373
|
if (url.pathname === "/api/routing-profiles/dry-run" && req.method === "POST") {
|
|
145
374
|
let rawBody: unknown;
|
|
146
375
|
try { rawBody = await readManagementJsonBody(req); } catch (error) {
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* - If detached spawn fails (sync throw or pre-start `error`): exit(1) without
|
|
18
18
|
* markRecycling — after drain the listen socket is already closed, so a latch
|
|
19
19
|
* reset cannot recover serving. Clear inherited `OCX_SERVICE` so exit cleanup
|
|
20
|
-
* can restore Codex/Grok fences
|
|
21
|
-
*
|
|
20
|
+
* can restore Codex/Grok fences when a stale service marker has no viable
|
|
21
|
+
* supervisor. Log only a stable errno code — never the raw message
|
|
22
22
|
* (paths in ENOENT often include the OS username).
|
|
23
23
|
*/
|
|
24
24
|
import { spawn } from "node:child_process";
|
|
@@ -35,13 +35,13 @@ import {
|
|
|
35
35
|
import { isServiceViable } from "../../service";
|
|
36
36
|
import { readRuntimePort } from "../../config";
|
|
37
37
|
import { withProcessRuntimeProvenance } from "../../lib/bun-runtime";
|
|
38
|
+
import {
|
|
39
|
+
MEMORY_DRAIN_RESTART_MS,
|
|
40
|
+
REPLACEMENT_READY_TIMEOUT_MS,
|
|
41
|
+
} from "../../lib/system-restart-contract";
|
|
38
42
|
import { findLiveProxy } from "../proxy-liveness";
|
|
39
43
|
|
|
40
|
-
|
|
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;
|
|
44
|
+
export { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../../lib/system-restart-contract";
|
|
45
45
|
export const DEADLINE_LISTENER_STOP_TIMEOUT_MS = 5_000;
|
|
46
46
|
const REPLACEMENT_READY_POLL_MS = 150;
|
|
47
47
|
|
|
@@ -223,11 +223,13 @@ function spawnDetachedStart(
|
|
|
223
223
|
return new Promise<void>((resolve, reject) => {
|
|
224
224
|
let child: ReturnType<typeof spawn>;
|
|
225
225
|
try {
|
|
226
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
227
|
+
delete env.OCX_SERVICE;
|
|
226
228
|
child = spawn(process.execPath, args, {
|
|
227
229
|
detached: true,
|
|
228
230
|
stdio: "ignore",
|
|
229
231
|
windowsHide: true,
|
|
230
|
-
env: withProcessRuntimeProvenance(
|
|
232
|
+
env: withProcessRuntimeProvenance(env),
|
|
231
233
|
});
|
|
232
234
|
} catch (err) {
|
|
233
235
|
reject(err);
|
|
@@ -411,8 +413,8 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
|
|
|
411
413
|
`⚠️ Drain-and-restart spawn failed (${spawnFailureCode(err)}); exiting without replacement`,
|
|
412
414
|
);
|
|
413
415
|
// Listen socket is already stopped; do not markRecycling — no child to inherit fences.
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
+
// No replacement inherited the routing. Clear a stale service marker so
|
|
417
|
+
// this unsupervised parent restores clients after the failed handoff.
|
|
416
418
|
delete process.env.OCX_SERVICE;
|
|
417
419
|
exitProcess(1);
|
|
418
420
|
return;
|
|
@@ -27,6 +27,10 @@ import { getActiveTurnCount, isDraining } from "../lifecycle";
|
|
|
27
27
|
import { getActiveMemoryWatchdog, observedMemoryCounter } from "../memory-watchdog";
|
|
28
28
|
import { responseStateMetrics } from "../../responses/state";
|
|
29
29
|
import { appOwnedBytesSnapshot } from "../../lib/app-owned-memory";
|
|
30
|
+
import {
|
|
31
|
+
SYSTEM_RESTART_EXPECTED_PID_HEADER,
|
|
32
|
+
parseExpectedSystemRestartPid,
|
|
33
|
+
} from "../../lib/system-restart-contract";
|
|
30
34
|
import { jsonResponse } from "../auth-cors";
|
|
31
35
|
import { getInspectionCounters } from "../relay";
|
|
32
36
|
import type { ManagementContext } from "./context";
|
|
@@ -104,6 +108,22 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
if (url.pathname === "/api/system/restart" && req.method === "POST") {
|
|
111
|
+
const expectedPid = parseExpectedSystemRestartPid(
|
|
112
|
+
req.headers.get(SYSTEM_RESTART_EXPECTED_PID_HEADER),
|
|
113
|
+
);
|
|
114
|
+
if (expectedPid.kind === "invalid") {
|
|
115
|
+
return jsonResponse({
|
|
116
|
+
success: false,
|
|
117
|
+
error: "Invalid restart target identity.",
|
|
118
|
+
}, 400, req, config);
|
|
119
|
+
}
|
|
120
|
+
if (expectedPid.kind === "present" && expectedPid.pid !== process.pid) {
|
|
121
|
+
return jsonResponse({
|
|
122
|
+
success: false,
|
|
123
|
+
error: "Restart target identity changed.",
|
|
124
|
+
}, 409, req, config);
|
|
125
|
+
}
|
|
126
|
+
|
|
107
127
|
// Longer informed drain than /api/stop; does not tear down Codex/Grok injection.
|
|
108
128
|
const result = acceptSystemRestart();
|
|
109
129
|
return jsonResponse({
|
|
@@ -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
|
+
SYSTEM_RESTART_CAPABILITY_HEADER,
|
|
18
|
+
SYSTEM_RESTART_EXPECTED_PID_HEADER,
|
|
19
|
+
SYSTEM_RESTART_NONCE_HEADER,
|
|
20
|
+
SYSTEM_RESTART_PATH,
|
|
21
|
+
parseExpectedSystemRestartPid,
|
|
22
|
+
verifySystemRestartCapability,
|
|
23
|
+
} from "../lib/system-restart-contract";
|
|
16
24
|
import { forgetEphemeralSecretPath, forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
|
|
17
25
|
import type { OcxConfig } from "../types";
|
|
18
26
|
import {
|
|
@@ -245,20 +253,58 @@ export function issueGuiSession(
|
|
|
245
253
|
* minted for a browser, and it only authorizes a mutation after the origin and the
|
|
246
254
|
* per-session CSRF token match. Consent-bearing routes must key off this value
|
|
247
255
|
* rather than off request headers, which the token holder can forge freely.
|
|
256
|
+
* `system-restart-capability` is a process-scoped HMAC accepted only for the exact
|
|
257
|
+
* restart route and bound to the current process PID and listening port.
|
|
248
258
|
*/
|
|
249
|
-
export type ManagementPrincipal = "admin-token" | "gui-session";
|
|
259
|
+
export type ManagementPrincipal = "admin-token" | "gui-session" | "system-restart-capability";
|
|
260
|
+
|
|
261
|
+
export interface LocalManagementAuthContext {
|
|
262
|
+
attestationSecret: string;
|
|
263
|
+
pid: number;
|
|
264
|
+
port: number;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function hasSystemRestartCapability(
|
|
268
|
+
req: Request,
|
|
269
|
+
local: LocalManagementAuthContext | undefined,
|
|
270
|
+
): boolean {
|
|
271
|
+
if (!local || req.method !== "POST") return false;
|
|
272
|
+
let path: string;
|
|
273
|
+
try {
|
|
274
|
+
path = new URL(req.url).pathname;
|
|
275
|
+
} catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
if (path !== SYSTEM_RESTART_PATH) return false;
|
|
279
|
+
const expectedPid = parseExpectedSystemRestartPid(
|
|
280
|
+
req.headers.get(SYSTEM_RESTART_EXPECTED_PID_HEADER),
|
|
281
|
+
);
|
|
282
|
+
if (expectedPid.kind !== "present" || expectedPid.pid !== local.pid) return false;
|
|
283
|
+
return verifySystemRestartCapability(
|
|
284
|
+
local.attestationSecret,
|
|
285
|
+
req.headers.get(SYSTEM_RESTART_NONCE_HEADER),
|
|
286
|
+
req.method,
|
|
287
|
+
path,
|
|
288
|
+
local.pid,
|
|
289
|
+
local.port,
|
|
290
|
+
req.headers.get(SYSTEM_RESTART_CAPABILITY_HEADER),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
250
293
|
|
|
251
294
|
/**
|
|
252
295
|
* The principal for a request that already passed `requireManagementAuth`. Kept as a
|
|
253
296
|
* separate resolution (rather than a changed return type) so every existing caller
|
|
254
|
-
* keeps its `Response | null` contract
|
|
255
|
-
*
|
|
297
|
+
* keeps its `Response | null` contract. Browser and admin principals are derived
|
|
298
|
+
* from the same session table and CSRF comparison the gate uses; the restart
|
|
299
|
+
* principal is derived from the same process-scoped capability check.
|
|
256
300
|
*/
|
|
257
301
|
export function managementPrincipal(
|
|
258
302
|
req: Request,
|
|
259
303
|
state: ManagementAuthState,
|
|
260
304
|
config?: OcxConfig,
|
|
305
|
+
local?: LocalManagementAuthContext,
|
|
261
306
|
): ManagementPrincipal | null {
|
|
307
|
+
if (hasSystemRestartCapability(req, local)) return "system-restart-capability";
|
|
262
308
|
if (!state.available) return null;
|
|
263
309
|
const actual = req.headers.get("x-opencodex-api-key")?.trim()
|
|
264
310
|
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
|
|
@@ -273,7 +319,9 @@ export function requireManagementAuth(
|
|
|
273
319
|
req: Request,
|
|
274
320
|
state: ManagementAuthState,
|
|
275
321
|
config?: OcxConfig,
|
|
322
|
+
local?: LocalManagementAuthContext,
|
|
276
323
|
): Response | null {
|
|
324
|
+
if (hasSystemRestartCapability(req, local)) return null;
|
|
277
325
|
if (!state.available) {
|
|
278
326
|
return Response.json({
|
|
279
327
|
error: "management API unavailable",
|
package/src/server/ports.ts
CHANGED
|
@@ -57,6 +57,16 @@ export type FindAvailablePortOptions = {
|
|
|
57
57
|
* update restart cannot hop to a random ephemeral listener (PR #152 gap).
|
|
58
58
|
*/
|
|
59
59
|
allowEphemeralFallback?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* A port this selection must never return, even when it is free (#1102).
|
|
62
|
+
*
|
|
63
|
+
* The unauthenticated loopback listener binds a fixed port from config. If the public
|
|
64
|
+
* listener took that port first — via an explicit `--port`, a `config.port` of 0, or the
|
|
65
|
+
* ephemeral fallback happening to land on it — the loopback bind would then fail with
|
|
66
|
+
* EADDRINUSE, and the startup transaction would roll back a public listener that had
|
|
67
|
+
* nothing wrong with it. Excluding the port here fails the right thing at the right time.
|
|
68
|
+
*/
|
|
69
|
+
reservedPort?: number;
|
|
60
70
|
};
|
|
61
71
|
|
|
62
72
|
export class PortUnavailableError extends Error {
|
|
@@ -75,6 +85,12 @@ export async function findAvailablePort(
|
|
|
75
85
|
): Promise<number> {
|
|
76
86
|
const preferRetryMs = opts.preferRetryMs ?? 0;
|
|
77
87
|
const allowEphemeral = opts.allowEphemeralFallback !== false;
|
|
88
|
+
const reserved = opts.reservedPort;
|
|
89
|
+
// An explicit preference for the reserved port is a configuration mistake, not a busy
|
|
90
|
+
// socket: retrying or hopping would hide it. Refuse before probing anything.
|
|
91
|
+
if (reserved !== undefined && preferredPort === reserved) {
|
|
92
|
+
throw new PortUnavailableError(preferredPort, hostname);
|
|
93
|
+
}
|
|
78
94
|
// Port 0 asks the OS to select an ephemeral port. Resolve it to that concrete
|
|
79
95
|
// port here so callers never persist or advertise an unusable `:0` endpoint.
|
|
80
96
|
if (preferredPort > 0 && preferRetryMs > 0) {
|
|
@@ -92,7 +108,31 @@ export async function findAvailablePort(
|
|
|
92
108
|
throw new PortUnavailableError(preferredPort, hostname);
|
|
93
109
|
}
|
|
94
110
|
|
|
95
|
-
|
|
111
|
+
// Bounded, not recursive. The OS can hand back the reserved port, and a redraw practically
|
|
112
|
+
// always differs — but "practically always" is not a termination argument, and an unbounded
|
|
113
|
+
// async recursion has no way to stop if the assumption is ever wrong.
|
|
114
|
+
for (let attempt = 0; attempt < EPHEMERAL_REDRAW_LIMIT; attempt += 1) {
|
|
115
|
+
const port = await allocateEphemeralPort(hostname);
|
|
116
|
+
if (port !== reserved) return port;
|
|
117
|
+
}
|
|
118
|
+
throw new Error("failed to allocate an available port");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** How many times an ephemeral draw may come back reserved before we give up. */
|
|
122
|
+
const EPHEMERAL_REDRAW_LIMIT = 8;
|
|
123
|
+
|
|
124
|
+
/** Test seam: replace the OS ephemeral allocator so the redraw path is reachable. */
|
|
125
|
+
let ephemeralAllocator: ((hostname: string) => Promise<number>) | null = null;
|
|
126
|
+
|
|
127
|
+
export function setEphemeralPortAllocatorForTests(
|
|
128
|
+
allocator: ((hostname: string) => Promise<number>) | null,
|
|
129
|
+
): void {
|
|
130
|
+
ephemeralAllocator = allocator;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function allocateEphemeralPort(hostname: string): Promise<number> {
|
|
134
|
+
if (ephemeralAllocator) return ephemeralAllocator(hostname);
|
|
135
|
+
return await new Promise<number>((resolve, reject) => {
|
|
96
136
|
const server = createServer();
|
|
97
137
|
server.once("error", reject);
|
|
98
138
|
server.once("listening", () => {
|