@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 +8 -1
- package/bin/ocx.mjs +47 -22
- package/gui/dist/assets/index-BDjpkcRN.js +67 -0
- package/gui/dist/assets/index-BHsKRFh9.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic.ts +15 -6
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/cursor/native-exec-shell.ts +18 -6
- package/src/adapters/cursor/protobuf-events.ts +24 -2
- package/src/adapters/cursor/protobuf-request.ts +1 -2
- package/src/adapters/cursor/tool-definitions.ts +68 -29
- package/src/adapters/google-wire-compiler.ts +4 -0
- package/src/adapters/google.ts +128 -2
- package/src/adapters/identity.ts +12 -2
- package/src/adapters/kiro.ts +64 -7
- package/src/adapters/mimo-free.ts +2 -0
- package/src/adapters/openai-responses.ts +246 -59
- package/src/claude/agents-inject.ts +5 -0
- package/src/claude/alias.ts +94 -14
- package/src/claude/inbound.ts +26 -9
- package/src/claude/outbound.ts +6 -3
- package/src/cli/account-auth.ts +1 -1
- package/src/cli/agent-driven.ts +37 -0
- package/src/cli/catalog-prewarm.ts +24 -0
- package/src/cli/claude.ts +35 -10
- package/src/cli/doctor.ts +71 -19
- package/src/cli/help.ts +42 -6
- package/src/cli/index.ts +93 -19
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/opencode.ts +701 -0
- package/src/cli/provider-runtime.ts +3 -0
- package/src/cli/provider.ts +31 -10
- package/src/cli/star-prompt.ts +79 -18
- package/src/cli/status.ts +47 -13
- package/src/cli/v2.ts +10 -1
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-lifecycle.ts +4 -1
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +149 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-store.ts +2 -0
- package/src/codex/account-usability.ts +6 -1
- package/src/codex/app-server-processes.ts +511 -0
- package/src/codex/auth-api.ts +293 -34
- package/src/codex/auth-collision.ts +2 -1
- package/src/codex/auth-context.ts +60 -17
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/parsing.ts +42 -2
- package/src/codex/catalog/provider-fetch.ts +264 -70
- package/src/codex/catalog/sync.ts +45 -8
- package/src/codex/catalog.ts +2 -2
- package/src/codex/features.ts +524 -5
- package/src/codex/history-provider.ts +145 -1
- package/src/codex/inject.ts +114 -14
- package/src/codex/main-account.ts +2 -8
- package/src/codex/pool-rotation.ts +186 -0
- package/src/codex/quota.ts +92 -2
- package/src/codex/routing.ts +695 -106
- package/src/codex/runtime.ts +10 -1
- package/src/codex/shim.ts +4 -1
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +2 -0
- package/src/codex/sync.ts +3 -0
- package/src/config.ts +574 -25
- package/src/generated/jawcode-model-metadata.ts +12 -12
- package/src/github/star-state.ts +191 -0
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +111 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +789 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +6 -20
- package/src/lib/config-ownership.ts +327 -0
- package/src/lib/crash-guard.ts +2 -0
- package/src/lib/destination-policy.ts +132 -7
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/process-control.ts +2 -2
- package/src/lib/provider-outbound.ts +167 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/win-exec.ts +12 -2
- package/src/lib/windows-elevation.ts +81 -3
- package/src/lib/windows-secret-acl.ts +189 -12
- package/src/lib/winsw.ts +2 -0
- package/src/oauth/anthropic-routing.ts +570 -0
- package/src/oauth/health.ts +6 -0
- package/src/oauth/index.ts +310 -75
- package/src/oauth/key-providers.ts +38 -8
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro-credentials.ts +373 -12
- package/src/oauth/kiro.ts +424 -43
- package/src/oauth/login-cli.ts +33 -6
- package/src/oauth/store.ts +56 -4
- package/src/oauth/types.ts +11 -0
- package/src/providers/alibaba-region-migration.ts +16 -3
- package/src/providers/antigravity-models.ts +3 -0
- package/src/providers/api-keys.ts +13 -6
- package/src/providers/derive.ts +8 -2
- package/src/providers/key-failover.ts +24 -4
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/quota.ts +233 -29
- package/src/providers/registry.ts +125 -3
- package/src/responses/parser.ts +11 -0
- package/src/responses/state.ts +22 -8
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +19 -7
- package/src/server/auth-cors.ts +114 -24
- package/src/server/claude-messages.ts +8 -1
- package/src/server/gui-static.ts +30 -6
- package/src/server/images.ts +303 -9
- package/src/server/index.ts +77 -9
- package/src/server/lifecycle.ts +25 -1
- package/src/server/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +106 -8
- package/src/server/management/combo-routes.ts +7 -0
- package/src/server/management/config-routes.ts +22 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/logs-usage-routes.ts +167 -3
- package/src/server/management/model-routes.ts +46 -13
- package/src/server/management/oauth-account-routes.ts +163 -17
- package/src/server/management/provider-routes.ts +73 -10
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management/system-restart.ts +172 -0
- package/src/server/management/system-routes.ts +33 -10
- package/src/server/management-api.ts +5 -3
- package/src/server/management-auth.ts +216 -0
- package/src/server/proxy-liveness.ts +14 -3
- package/src/server/responses/compact.ts +21 -13
- package/src/server/responses/core.ts +614 -172
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/responses-image-gen-repair.ts +118 -0
- package/src/server/responses-item-id-repair.ts +10 -85
- package/src/server/sse-payload-rewrite.ts +116 -0
- package/src/server/startup-action-control.ts +30 -14
- package/src/server/system-env.ts +28 -10
- package/src/service.ts +284 -19
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +1504 -28
- package/src/storage/policy-job.ts +387 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +53 -0
- package/src/storage/policy.ts +522 -0
- package/src/storage/restore-job.ts +253 -0
- package/src/storage/restore-worker.ts +52 -0
- package/src/storage/storage-mutation-coordinator.ts +109 -0
- package/src/storage/worker-lifecycle.ts +81 -0
- package/src/tray/windows.ts +34 -4
- package/src/types.ts +107 -1
- package/src/update/badge.ts +72 -0
- package/src/update/index.ts +36 -18
- package/src/update/job.ts +111 -16
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/usage/debug.ts +2 -0
- package/src/usage/expected-prices.ts +6 -5
- package/src/usage/log.ts +12 -0
- package/src/web-search/loop.ts +57 -16
- package/gui/dist/assets/index-CjKFJHSC.js +0 -65
- package/gui/dist/assets/index-DfVGuN88.css +0 -1
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
multiAgentGuidanceEnabled,
|
|
11
11
|
providerBaseUrlConfigError,
|
|
12
12
|
providerHeadersConfigError,
|
|
13
|
+
readConfigDiagnostics,
|
|
14
|
+
reconcileLiveConfigFromDisk,
|
|
13
15
|
saveConfigPreservingClaudeCode,
|
|
14
16
|
} from "../../config";
|
|
15
17
|
import {
|
|
@@ -19,7 +21,6 @@ import {
|
|
|
19
21
|
listOAuthProviders,
|
|
20
22
|
startLoginFlow,
|
|
21
23
|
submitManualLoginCode,
|
|
22
|
-
upsertOAuthProvider,
|
|
23
24
|
} from "../../oauth";
|
|
24
25
|
import { removeCredential } from "../../oauth/store";
|
|
25
26
|
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
@@ -27,9 +28,15 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/ke
|
|
|
27
28
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
28
29
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
29
30
|
import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
30
|
-
import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
|
|
31
|
+
import { clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports, supportsPerAccountQuota } from "../../providers/quota";
|
|
31
32
|
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
32
33
|
import { clearThreadAccountMap } from "../../codex/routing";
|
|
34
|
+
import {
|
|
35
|
+
normalizeAccountPoolStickyLimit,
|
|
36
|
+
normalizeAccountPoolStrategy,
|
|
37
|
+
parseAccountPoolStickyLimit,
|
|
38
|
+
parseAccountPoolStrategy,
|
|
39
|
+
} from "../../codex/pool-rotation";
|
|
33
40
|
import { primeCodexPoolQuotas } from "../../codex/auth-api";
|
|
34
41
|
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
|
|
35
42
|
import { resolveCodexHomeDir } from "../../codex/home";
|
|
@@ -59,6 +66,7 @@ import { buildApiAccessEndpoints } from "./api-access";
|
|
|
59
66
|
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
|
|
60
67
|
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
|
|
61
68
|
import type { ManagementContext } from "./context";
|
|
69
|
+
import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
|
|
62
70
|
|
|
63
71
|
export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
64
72
|
const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
|
|
@@ -80,6 +88,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
80
88
|
const body = await req.json().catch(() => ({})) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean };
|
|
81
89
|
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
82
90
|
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
91
|
+
const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
|
|
92
|
+
if (namespaceCollision) return jsonResponse({ error: namespaceCollision }, 409);
|
|
83
93
|
const accountId = body.accountId?.trim();
|
|
84
94
|
const reauth = body.reauth === true || Boolean(accountId);
|
|
85
95
|
try {
|
|
@@ -90,12 +100,19 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
90
100
|
return jsonResponse({ error: "Unknown account for reauth" }, 404);
|
|
91
101
|
}
|
|
92
102
|
}
|
|
103
|
+
// Use persisted state, not the live object, as the merge base: another management
|
|
104
|
+
// request may already have mutated live config and yielded before its save.
|
|
105
|
+
const persistedBaseline = readConfigDiagnostics().config;
|
|
93
106
|
// addAccount / reauth forces a fresh browser identity (skips local-CLI token import).
|
|
94
107
|
const { url: authUrl, instructions, deviceCode } = await startLoginFlow(provider, {
|
|
95
108
|
forceLogin: body.addAccount === true || reauth,
|
|
96
109
|
...(accountId ? { reauthAccountId: accountId } : {}),
|
|
110
|
+
}, {
|
|
111
|
+
// startLoginFlow returns the authorization URL before background persistence completes.
|
|
112
|
+
// Three-way reconcile settled disk changes so a failed login cannot leave a provider
|
|
113
|
+
// live-only and an in-flight management mutation cannot be erased before it saves.
|
|
114
|
+
onSettled: () => reconcileLiveConfigFromDisk(config, persistedBaseline),
|
|
97
115
|
});
|
|
98
|
-
upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart
|
|
99
116
|
if (authUrl && !deviceCode) {
|
|
100
117
|
// Open the browser server-side (the proxy runs on the user's machine) — the GUI's
|
|
101
118
|
// window.open is popup-blocked because it runs after an await, not a direct click.
|
|
@@ -146,8 +163,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
146
163
|
await removeCredential(provider);
|
|
147
164
|
clearLoginState(provider);
|
|
148
165
|
// Drop cached/last-good quota rows tied to the removed credential.
|
|
149
|
-
const { clearProviderQuotaCache } = await import("../../providers/quota");
|
|
166
|
+
const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota");
|
|
150
167
|
clearProviderQuotaCache();
|
|
168
|
+
clearAccountQuotaCache(provider);
|
|
151
169
|
return jsonResponse({ success: true });
|
|
152
170
|
}
|
|
153
171
|
|
|
@@ -163,18 +181,46 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
163
181
|
projectOAuthAccountHealth,
|
|
164
182
|
projectStoredOAuthAccountHealth,
|
|
165
183
|
} = await import("../../oauth/health");
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
184
|
+
const projectAccounts = () => {
|
|
185
|
+
const set = getAccountSet(provider);
|
|
186
|
+
const current = getLoginStatus(provider);
|
|
187
|
+
return {
|
|
188
|
+
activeAccountId: current.activeAccountId ?? null,
|
|
189
|
+
accounts: (current.accounts ?? []).map(summary => {
|
|
190
|
+
const full = set?.accounts.find(account => account.id === summary.id);
|
|
191
|
+
const health = full
|
|
192
|
+
? projectStoredOAuthAccountHealth(provider, full)
|
|
193
|
+
: projectOAuthAccountHealth({
|
|
194
|
+
needsReauth: summary.needsReauth === true,
|
|
195
|
+
reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
|
|
196
|
+
});
|
|
197
|
+
return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health) };
|
|
198
|
+
}),
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
// Per-account rate limits: Anthropic reports usage per credential, so every logged-in
|
|
202
|
+
// account can show its own 5h/weekly bars (not just the active one). Opt-in via ?quota=1
|
|
203
|
+
// so the plain account list stays a cheap local read; ?refresh=1 bypasses the TTL.
|
|
204
|
+
const wantQuota = url.searchParams.get("quota") === "1" && supportsPerAccountQuota(provider);
|
|
205
|
+
if (!wantQuota) return jsonResponse(projectAccounts());
|
|
206
|
+
const forceRefresh = url.searchParams.get("refresh") === "1";
|
|
207
|
+
// Probing may refresh the active credential and mark needsReauth — project health
|
|
208
|
+
// from the post-probe store so the response is not stale.
|
|
209
|
+
const rows = await fetchProviderAccountQuotas(provider, forceRefresh);
|
|
210
|
+
const byId = new Map(rows.map(row => [row.accountId, row]));
|
|
211
|
+
const projected = projectAccounts();
|
|
212
|
+
return jsonResponse({
|
|
213
|
+
activeAccountId: projected.activeAccountId,
|
|
214
|
+
accounts: projected.accounts.map(account => {
|
|
215
|
+
const row = byId.get(account.id);
|
|
216
|
+
if (!row) return account;
|
|
217
|
+
return {
|
|
218
|
+
...account,
|
|
219
|
+
quota: row.quota,
|
|
220
|
+
...(row.unavailable ? { quotaUnavailable: true } : {}),
|
|
221
|
+
};
|
|
222
|
+
}),
|
|
176
223
|
});
|
|
177
|
-
return jsonResponse({ activeAccountId: status.activeAccountId ?? null, accounts });
|
|
178
224
|
}
|
|
179
225
|
if (url.pathname === "/api/oauth/accounts/active" && req.method === "PUT") {
|
|
180
226
|
const body = await req.json().catch(() => ({})) as { provider?: string; accountId?: string };
|
|
@@ -183,10 +229,104 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
183
229
|
if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400);
|
|
184
230
|
const { setActiveAccount } = await import("../../oauth/store");
|
|
185
231
|
if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404);
|
|
232
|
+
if (provider === "anthropic") {
|
|
233
|
+
const { resetAnthropicRoutingForManualSelection } = await import("../../oauth/anthropic-routing");
|
|
234
|
+
resetAnthropicRoutingForManualSelection(body.accountId);
|
|
235
|
+
}
|
|
186
236
|
const { clearProviderQuotaCache } = await import("../../providers/quota");
|
|
187
237
|
clearProviderQuotaCache();
|
|
188
238
|
return jsonResponse({ ok: true, provider, activeAccountId: body.accountId });
|
|
189
239
|
}
|
|
240
|
+
|
|
241
|
+
// Opt-in Anthropic OAuth account pool (#294): enable/threshold/strategy + clear cooldown.
|
|
242
|
+
if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") {
|
|
243
|
+
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
244
|
+
if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400);
|
|
245
|
+
const pool = config.anthropicAccountPool ?? {};
|
|
246
|
+
return jsonResponse({
|
|
247
|
+
provider,
|
|
248
|
+
enabled: pool.enabled === true,
|
|
249
|
+
autoSwitchThreshold: typeof pool.autoSwitchThreshold === "number" ? pool.autoSwitchThreshold : 80,
|
|
250
|
+
strategy: normalizeAccountPoolStrategy(pool.strategy),
|
|
251
|
+
stickyLimit: normalizeAccountPoolStickyLimit(pool.stickyLimit),
|
|
252
|
+
experimental: true,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (url.pathname === "/api/oauth/accounts/pool" && (req.method === "PUT" || req.method === "PATCH")) {
|
|
256
|
+
const parsedBody = await req.json().catch(() => ({}));
|
|
257
|
+
if (!isPlainRecord(parsedBody)) {
|
|
258
|
+
return jsonResponse({ error: "body must be an object" }, 400);
|
|
259
|
+
}
|
|
260
|
+
const body = parsedBody as {
|
|
261
|
+
provider?: unknown;
|
|
262
|
+
enabled?: unknown;
|
|
263
|
+
autoSwitchThreshold?: unknown;
|
|
264
|
+
strategy?: unknown;
|
|
265
|
+
stickyLimit?: unknown;
|
|
266
|
+
};
|
|
267
|
+
const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
|
|
268
|
+
if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400);
|
|
269
|
+
let enabled = config.anthropicAccountPool?.enabled === true;
|
|
270
|
+
if (body.enabled !== undefined) {
|
|
271
|
+
if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
|
|
272
|
+
enabled = body.enabled;
|
|
273
|
+
}
|
|
274
|
+
let threshold = config.anthropicAccountPool?.autoSwitchThreshold ?? 80;
|
|
275
|
+
if (body.autoSwitchThreshold !== undefined) {
|
|
276
|
+
if (
|
|
277
|
+
typeof body.autoSwitchThreshold !== "number"
|
|
278
|
+
|| !Number.isInteger(body.autoSwitchThreshold)
|
|
279
|
+
|| body.autoSwitchThreshold < 0
|
|
280
|
+
|| body.autoSwitchThreshold > 100
|
|
281
|
+
) {
|
|
282
|
+
return jsonResponse({ error: "autoSwitchThreshold must be an integer 0-100" }, 400);
|
|
283
|
+
}
|
|
284
|
+
threshold = body.autoSwitchThreshold;
|
|
285
|
+
}
|
|
286
|
+
let strategy = config.anthropicAccountPool?.strategy;
|
|
287
|
+
if (body.strategy !== undefined) {
|
|
288
|
+
const parsed = parseAccountPoolStrategy(body.strategy);
|
|
289
|
+
if (parsed === null) {
|
|
290
|
+
return jsonResponse({ error: "strategy must be one of: quota, round-robin, fill-first" }, 400);
|
|
291
|
+
}
|
|
292
|
+
strategy = parsed;
|
|
293
|
+
}
|
|
294
|
+
let stickyLimit = config.anthropicAccountPool?.stickyLimit;
|
|
295
|
+
if (body.stickyLimit !== undefined) {
|
|
296
|
+
const parsed = parseAccountPoolStickyLimit(body.stickyLimit);
|
|
297
|
+
if (parsed === null) {
|
|
298
|
+
return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400);
|
|
299
|
+
}
|
|
300
|
+
stickyLimit = parsed;
|
|
301
|
+
}
|
|
302
|
+
config.anthropicAccountPool = {
|
|
303
|
+
enabled,
|
|
304
|
+
autoSwitchThreshold: threshold,
|
|
305
|
+
...(strategy !== undefined ? { strategy } : {}),
|
|
306
|
+
...(stickyLimit !== undefined ? { stickyLimit } : {}),
|
|
307
|
+
};
|
|
308
|
+
saveConfigPreservingClaudeCode(config);
|
|
309
|
+
return jsonResponse({
|
|
310
|
+
ok: true,
|
|
311
|
+
provider,
|
|
312
|
+
enabled,
|
|
313
|
+
autoSwitchThreshold: threshold,
|
|
314
|
+
strategy: normalizeAccountPoolStrategy(strategy),
|
|
315
|
+
stickyLimit: normalizeAccountPoolStickyLimit(stickyLimit),
|
|
316
|
+
experimental: true,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (url.pathname === "/api/oauth/accounts/clear-cooldown" && req.method === "POST") {
|
|
320
|
+
const body = await req.json().catch(() => ({})) as { provider?: unknown; accountId?: unknown };
|
|
321
|
+
const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
|
|
322
|
+
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
323
|
+
if (provider !== "anthropic") return jsonResponse({ error: "clear-cooldown is only supported for anthropic" }, 400);
|
|
324
|
+
if (!accountId) return jsonResponse({ error: "missing accountId" }, 400);
|
|
325
|
+
const { clearAnthropicAccountCooldown } = await import("../../oauth/anthropic-routing");
|
|
326
|
+
const cleared = clearAnthropicAccountCooldown(accountId);
|
|
327
|
+
return jsonResponse({ ok: true, cleared });
|
|
328
|
+
}
|
|
329
|
+
|
|
190
330
|
if (url.pathname === "/api/oauth/accounts/alias" && req.method === "PUT") {
|
|
191
331
|
const body = await req.json().catch(() => ({})) as { provider?: unknown; accountId?: unknown; alias?: unknown };
|
|
192
332
|
const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
|
|
@@ -208,9 +348,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
208
348
|
if (!id) return jsonResponse({ error: "missing id" }, 400);
|
|
209
349
|
const { removeAccount, getAccountSet } = await import("../../oauth/store");
|
|
210
350
|
if (!(await removeAccount(provider, id))) return jsonResponse({ error: "account not found" }, 404);
|
|
351
|
+
if (provider === "anthropic") {
|
|
352
|
+
const { clearAnthropicAccountCooldown, clearAnthropicSessionAffinityForAccount } = await import("../../oauth/anthropic-routing");
|
|
353
|
+
clearAnthropicAccountCooldown(id);
|
|
354
|
+
clearAnthropicSessionAffinityForAccount(id);
|
|
355
|
+
}
|
|
211
356
|
if (!getAccountSet(provider)) clearLoginState(provider);
|
|
212
|
-
const { clearProviderQuotaCache } = await import("../../providers/quota");
|
|
357
|
+
const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota");
|
|
213
358
|
clearProviderQuotaCache();
|
|
359
|
+
clearAccountQuotaCache(provider);
|
|
214
360
|
return jsonResponse({ ok: true });
|
|
215
361
|
}
|
|
216
362
|
|
|
@@ -308,7 +454,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
308
454
|
const salt = crypto.randomUUID();
|
|
309
455
|
const hashInput = `${providerKeys}|${salt}|${Date.now()}`;
|
|
310
456
|
const hashBuf = new Bun.CryptoHasher("sha256").update(hashInput).digest();
|
|
311
|
-
const key = "
|
|
457
|
+
const key = "ocx_data_" + Buffer.from(hashBuf).toString("hex").slice(0, 40);
|
|
312
458
|
const entry = { id: crypto.randomUUID(), name, key, createdAt: new Date().toISOString() };
|
|
313
459
|
config.apiKeys = [...(config.apiKeys ?? []), entry];
|
|
314
460
|
saveConfigPreservingClaudeCode(config);
|
|
@@ -23,12 +23,20 @@ import {
|
|
|
23
23
|
} from "../../oauth";
|
|
24
24
|
import { removeCredential } from "../../oauth/store";
|
|
25
25
|
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
26
|
+
import { ProviderOutboundPolicyError, providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound";
|
|
26
27
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
27
28
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
28
29
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
30
|
+
import {
|
|
31
|
+
extractModelEnvelopeRows,
|
|
32
|
+
extractProviderModelItems,
|
|
33
|
+
readBoundedDiscoveryJson,
|
|
34
|
+
resolveProviderModelDiscovery,
|
|
35
|
+
} from "../../providers/model-discovery";
|
|
29
36
|
import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
30
37
|
import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
|
|
31
38
|
import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
39
|
+
import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
|
|
32
40
|
import { clearThreadAccountMap } from "../../codex/routing";
|
|
33
41
|
import { primeCodexPoolQuotas } from "../../codex/auth-api";
|
|
34
42
|
import { getProviderDiscoveryStatus } from "../../codex/model-cache";
|
|
@@ -76,6 +84,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
76
84
|
liveModels: p.liveModels !== false,
|
|
77
85
|
models: p.models ?? [],
|
|
78
86
|
authMode: p.authMode,
|
|
87
|
+
apiKeyTransport: p.apiKeyTransport,
|
|
79
88
|
disabled: p.disabled === true,
|
|
80
89
|
codexAccountMode: providerCodexAccountMode(name, p),
|
|
81
90
|
discovery: p.liveModels === false ? undefined : getProviderDiscoveryStatus(name),
|
|
@@ -98,6 +107,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
98
107
|
if (!isValidProviderName(name)) {
|
|
99
108
|
return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
|
|
100
109
|
}
|
|
110
|
+
const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name);
|
|
111
|
+
if (namespaceCollision) {
|
|
112
|
+
return jsonResponse({ error: namespaceCollision }, 409);
|
|
113
|
+
}
|
|
101
114
|
// Hostname destinations additionally get a DNS-resolved SSRF check at write time —
|
|
102
115
|
// the sync check above only classifies literal IPs (review finding, PR #96).
|
|
103
116
|
// Canonical openai still runs the resolver: only Clash fake-IP (198.18.0.0/15)
|
|
@@ -213,6 +226,18 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
213
226
|
return jsonResponse({ error: "authMode must be key, forward, oauth, or local" }, 400);
|
|
214
227
|
}
|
|
215
228
|
}
|
|
229
|
+
if (Object.hasOwn(rawBody, "apiKeyTransport")) {
|
|
230
|
+
const transport = rawBody.apiKeyTransport;
|
|
231
|
+
if (transport === "x-api-key" || transport === "bearer") {
|
|
232
|
+
next.apiKeyTransport = transport;
|
|
233
|
+
touched = true;
|
|
234
|
+
} else if (transport === "") {
|
|
235
|
+
delete next.apiKeyTransport;
|
|
236
|
+
touched = true;
|
|
237
|
+
} else {
|
|
238
|
+
return jsonResponse({ error: "apiKeyTransport must be x-api-key, bearer, or empty to clear" }, 400);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
216
241
|
if (Object.hasOwn(rawBody, "note")) {
|
|
217
242
|
if (typeof rawBody.note !== "string") return jsonResponse({ error: "note must be a string" }, 400);
|
|
218
243
|
const note = rawBody.note.trim();
|
|
@@ -319,23 +344,59 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
319
344
|
return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" });
|
|
320
345
|
}
|
|
321
346
|
const { url: modelsUrl, headers } = buildModelsRequest(prov, apiKey, name);
|
|
347
|
+
const discovery = resolveProviderModelDiscovery(name, prov);
|
|
322
348
|
const started = Date.now();
|
|
323
349
|
try {
|
|
324
|
-
const res = await
|
|
350
|
+
const res = await providerOutboundGet(name, prov, modelsUrl, {
|
|
351
|
+
headers,
|
|
352
|
+
signal: AbortSignal.timeout(8000),
|
|
353
|
+
});
|
|
325
354
|
const latencyMs = Date.now() - started;
|
|
355
|
+
const redirectError = await providerRedirectError(res, modelsUrl);
|
|
356
|
+
if (redirectError) {
|
|
357
|
+
return jsonResponse({
|
|
358
|
+
ok: false,
|
|
359
|
+
latencyMs,
|
|
360
|
+
error: redirectError,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
326
363
|
if (!res.ok) {
|
|
364
|
+
try {
|
|
365
|
+
void res.body?.cancel().catch(() => undefined);
|
|
366
|
+
} catch {
|
|
367
|
+
// Best-effort release for non-conforming response streams.
|
|
368
|
+
}
|
|
327
369
|
return jsonResponse({ ok: false, latencyMs, error: `upstream /models returned ${res.status}` });
|
|
328
370
|
}
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
371
|
+
const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes);
|
|
372
|
+
if (!bounded.ok) {
|
|
373
|
+
return jsonResponse({
|
|
374
|
+
ok: false,
|
|
375
|
+
latencyMs,
|
|
376
|
+
error: bounded.reason === "response_too_large"
|
|
377
|
+
? `upstream /models exceeded the ${discovery.maxResponseBytes}-byte response limit`
|
|
378
|
+
: "upstream /models returned invalid JSON",
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
// OpenAI-style lists (and Together top-level arrays) use the same validation/dedupe/filter
|
|
382
|
+
// as catalog discovery. Google's /v1beta/models uses `models[].name` and remains a
|
|
383
|
+
// connectivity-only count because it is not an authoritative catalog source.
|
|
384
|
+
const record = bounded.value !== null && typeof bounded.value === "object" && !Array.isArray(bounded.value)
|
|
385
|
+
? bounded.value as Record<string, unknown>
|
|
334
386
|
: undefined;
|
|
335
|
-
|
|
336
|
-
|
|
387
|
+
const extracted = Array.isArray(bounded.value) || Array.isArray(record?.data)
|
|
388
|
+
? extractProviderModelItems(bounded.value, discovery)
|
|
389
|
+
: extractModelEnvelopeRows(bounded.value, discovery.maxModels, ["models"]);
|
|
390
|
+
if (!extracted.ok) {
|
|
391
|
+
return jsonResponse({
|
|
392
|
+
ok: false,
|
|
393
|
+
latencyMs,
|
|
394
|
+
error: extracted.reason === "too_many_models"
|
|
395
|
+
? `upstream /models exceeded the ${discovery.maxModels}-row model limit`
|
|
396
|
+
: "upstream /models returned an unexpected shape",
|
|
397
|
+
});
|
|
337
398
|
}
|
|
338
|
-
const models =
|
|
399
|
+
const models = "items" in extracted ? extracted.items.length : extracted.rows.length;
|
|
339
400
|
return jsonResponse({
|
|
340
401
|
ok: true,
|
|
341
402
|
latencyMs,
|
|
@@ -346,7 +407,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
346
407
|
return jsonResponse({
|
|
347
408
|
ok: false,
|
|
348
409
|
latencyMs: Date.now() - started,
|
|
349
|
-
error: err instanceof
|
|
410
|
+
error: err instanceof ProviderOutboundPolicyError
|
|
411
|
+
? `upstream /models blocked by destination policy: ${err.message}`
|
|
412
|
+
: err instanceof Error ? err.message : "Connection test failed",
|
|
350
413
|
});
|
|
351
414
|
}
|
|
352
415
|
}
|
|
@@ -37,7 +37,7 @@ import { readUsageEntries } from "../../usage/log";
|
|
|
37
37
|
import { getUsageDebugLogEntries } from "../../usage/debug";
|
|
38
38
|
import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
|
|
39
39
|
import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
|
|
40
|
-
import { getProviderRegistryEntry } from "../../providers/registry";
|
|
40
|
+
import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry";
|
|
41
41
|
import { getDebugLogEntries } from "../../lib/debug-log-buffer";
|
|
42
42
|
import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log";
|
|
43
43
|
import {
|
|
@@ -201,7 +201,7 @@ export async function fetchGrokCandidateModels(config: OcxConfig): Promise<GrokC
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProviderConfig): OcxProviderConfig {
|
|
204
|
-
const entry = getProviderRegistryEntry(name);
|
|
204
|
+
const entry = providerMatchesRegistryTransport(name, provider) ? getProviderRegistryEntry(name) : undefined;
|
|
205
205
|
if (!entry?.staticHeaders || !provider.headers) return provider;
|
|
206
206
|
const headerEntries = Object.entries(provider.headers);
|
|
207
207
|
const staticEntries = Object.entries(entry.staticHeaders);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /api/github/star and /api/update/badge — the two cheap polls behind the
|
|
3
|
+
* sidebar's GitHub star and update controls.
|
|
4
|
+
*
|
|
5
|
+
* Both ride the standard management gate (auth + origin check happen before
|
|
6
|
+
* dispatch), and both are scalar-only: a star state enum, a repo slug, version
|
|
7
|
+
* strings, and a fixed error code. No GitHub token, account login, or raw `gh`/npm
|
|
8
|
+
* output is ever serialized here — starring runs through the user's own `gh` CLI and
|
|
9
|
+
* this surface only learns the yes/no answer. `gh` writes the authenticated account
|
|
10
|
+
* name to stderr, so that output is discarded at the source rather than forwarded.
|
|
11
|
+
*/
|
|
12
|
+
import { jsonResponse } from "../auth-cors";
|
|
13
|
+
import type { ManagementContext } from "./context";
|
|
14
|
+
|
|
15
|
+
export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
16
|
+
const { req, url } = ctx;
|
|
17
|
+
|
|
18
|
+
if (url.pathname === "/api/github/star" && req.method === "GET") {
|
|
19
|
+
const { getStarStatus } = await import("../../github/star-state");
|
|
20
|
+
return jsonResponse(await getStarStatus());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (url.pathname === "/api/github/star" && req.method === "POST") {
|
|
24
|
+
const { starRepository } = await import("../../github/star-state");
|
|
25
|
+
const result = await starRepository();
|
|
26
|
+
return jsonResponse({
|
|
27
|
+
...result.status,
|
|
28
|
+
ok: result.ok,
|
|
29
|
+
...(result.code ? { code: result.code } : {}),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (url.pathname === "/api/update/badge" && req.method === "GET") {
|
|
34
|
+
const { readUpdateBadge } = await import("../../update/badge");
|
|
35
|
+
return jsonResponse(readUpdateBadge());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard memory-card drain-and-restart (#563).
|
|
3
|
+
*
|
|
4
|
+
* Longer than POST /api/stop's short drain: waits up to 60s for active turns,
|
|
5
|
+
* then respawns. Never runs restoreNativeCodex / stripGrokConfig — this is a
|
|
6
|
+
* recycle to reclaim RSS, not a teardown.
|
|
7
|
+
*
|
|
8
|
+
* Respawn policy (matches real supervisor configs in src/service.ts):
|
|
9
|
+
* - Supervised child (`OCX_SERVICE=1` + service installed): exit(1) so
|
|
10
|
+
* failure-only supervisors (systemd Restart=on-failure, WinSW onfailure,
|
|
11
|
+
* Task Scheduler ERRORLEVEL loop) bring the proxy back.
|
|
12
|
+
* - Otherwise: detached `ocx start --port <live>` (bypasses ensure's
|
|
13
|
+
* codexAutoStart gate), mark recycle so exit cleanup keeps injection, exit(0).
|
|
14
|
+
* - If detached spawn fails (sync throw or pre-start `error`): exit(1) without
|
|
15
|
+
* markRecycling — after drain the listen socket is already closed, so a latch
|
|
16
|
+
* reset cannot recover serving. Clear inherited `OCX_SERVICE` so exit cleanup
|
|
17
|
+
* can restore Codex/Grok fences (ensure/tray daemons set the marker without a
|
|
18
|
+
* real supervisor). Log only a stable errno code — never the raw message
|
|
19
|
+
* (paths in ENOENT often include the OS username).
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import {
|
|
23
|
+
drainAndShutdown,
|
|
24
|
+
getActiveTurnCount,
|
|
25
|
+
getServerListenPort,
|
|
26
|
+
isDraining,
|
|
27
|
+
markRecyclingForExit,
|
|
28
|
+
setDraining,
|
|
29
|
+
} from "../lifecycle";
|
|
30
|
+
import { isServiceInstalled } from "../../service";
|
|
31
|
+
import { readRuntimePort } from "../../config";
|
|
32
|
+
|
|
33
|
+
/** Fixed v1 drain window for the memory-card action (not config-driven). */
|
|
34
|
+
export const MEMORY_DRAIN_RESTART_MS = 60_000;
|
|
35
|
+
|
|
36
|
+
export interface SystemRestartIo {
|
|
37
|
+
drainAndShutdown?: typeof drainAndShutdown;
|
|
38
|
+
isServiceInstalled?: () => boolean;
|
|
39
|
+
isSupervisedServiceChild?: () => boolean;
|
|
40
|
+
/** Must resolve only after the replacement process has actually started. */
|
|
41
|
+
spawnStart?: (port?: number) => void | Promise<void>;
|
|
42
|
+
markRecycling?: () => void;
|
|
43
|
+
exitProcess?: (code: number) => void;
|
|
44
|
+
schedule?: (fn: () => void | Promise<void>, ms: number) => void;
|
|
45
|
+
isDraining?: () => boolean;
|
|
46
|
+
setDraining?: (value: boolean) => void;
|
|
47
|
+
getActiveTurnCount?: () => number;
|
|
48
|
+
listenPort?: () => number | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let restartIo: SystemRestartIo = {};
|
|
52
|
+
/** Prevents double-scheduling in the 200ms window before drainAndShutdown sets draining. */
|
|
53
|
+
let restartAccepted = false;
|
|
54
|
+
|
|
55
|
+
/** Test seam — reset between tests. */
|
|
56
|
+
export function setSystemRestartIoForTests(io: SystemRestartIo = {}): void {
|
|
57
|
+
restartIo = io;
|
|
58
|
+
restartAccepted = false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveListenPort(): number | undefined {
|
|
62
|
+
const live = getServerListenPort();
|
|
63
|
+
if (live) return live;
|
|
64
|
+
const runtime = readRuntimePort(process.pid);
|
|
65
|
+
if (runtime && runtime.port > 0) return runtime.port;
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isSupervisedServiceChild(): boolean {
|
|
70
|
+
return process.env.OCX_SERVICE === "1" && isServiceInstalled();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Stable, path-free spawn failure label for logs (never interpolate err.message). */
|
|
74
|
+
function spawnFailureCode(err: unknown): string {
|
|
75
|
+
if (err && typeof err === "object" && "code" in err) {
|
|
76
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
77
|
+
if (typeof code === "string" && code.length > 0 && code.length <= 64) return code;
|
|
78
|
+
}
|
|
79
|
+
return "spawn_failed";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function spawnDetachedStart(port?: number): Promise<void> {
|
|
83
|
+
const args = [process.argv[1], "start"];
|
|
84
|
+
if (typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535) {
|
|
85
|
+
args.push("--port", String(Math.trunc(port)));
|
|
86
|
+
}
|
|
87
|
+
return new Promise<void>((resolve, reject) => {
|
|
88
|
+
let child: ReturnType<typeof spawn>;
|
|
89
|
+
try {
|
|
90
|
+
child = spawn(process.execPath, args, {
|
|
91
|
+
detached: true,
|
|
92
|
+
stdio: "ignore",
|
|
93
|
+
windowsHide: true,
|
|
94
|
+
env: { ...process.env, OCX_SERVICE: "1" },
|
|
95
|
+
});
|
|
96
|
+
} catch (err) {
|
|
97
|
+
reject(err);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
let settled = false;
|
|
101
|
+
const finish = (fn: () => void) => {
|
|
102
|
+
if (settled) return;
|
|
103
|
+
settled = true;
|
|
104
|
+
fn();
|
|
105
|
+
};
|
|
106
|
+
child.once("error", (err) => {
|
|
107
|
+
finish(() => reject(err));
|
|
108
|
+
});
|
|
109
|
+
child.once("spawn", () => {
|
|
110
|
+
finish(() => {
|
|
111
|
+
child.unref();
|
|
112
|
+
resolve();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Accept a drain-and-restart request. Returns immediately; the drain +
|
|
120
|
+
* respawn runs on a short timer so the HTTP response can flush first.
|
|
121
|
+
* Idempotent while already draining: returns the accepted shape again.
|
|
122
|
+
*/
|
|
123
|
+
export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
|
|
124
|
+
accepted: true;
|
|
125
|
+
alreadyDraining: boolean;
|
|
126
|
+
activeTurnCount: number;
|
|
127
|
+
drainTimeoutMs: number;
|
|
128
|
+
} {
|
|
129
|
+
const alreadyDraining = restartAccepted || (io.isDraining ?? isDraining)();
|
|
130
|
+
const activeTurnCount = (io.getActiveTurnCount ?? getActiveTurnCount)();
|
|
131
|
+
const schedule = io.schedule ?? ((fn, ms) => { setTimeout(() => { void fn(); }, ms); });
|
|
132
|
+
|
|
133
|
+
if (!alreadyDraining) {
|
|
134
|
+
restartAccepted = true;
|
|
135
|
+
// Reject new data-plane traffic immediately (503), before the 200ms response-flush delay.
|
|
136
|
+
(io.setDraining ?? setDraining)(true);
|
|
137
|
+
schedule(async () => {
|
|
138
|
+
const drain = io.drainAndShutdown ?? drainAndShutdown;
|
|
139
|
+
await drain(undefined, MEMORY_DRAIN_RESTART_MS);
|
|
140
|
+
const supervised = (io.isSupervisedServiceChild ?? isSupervisedServiceChild)();
|
|
141
|
+
if (supervised) {
|
|
142
|
+
// Failure-only supervisors ignore exit(0); intentional non-zero triggers respawn.
|
|
143
|
+
(io.exitProcess ?? ((code: number) => { process.exit(code); }))(1);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const port = (io.listenPort ?? resolveListenPort)();
|
|
147
|
+
const exitProcess = io.exitProcess ?? ((code: number) => { process.exit(code); });
|
|
148
|
+
try {
|
|
149
|
+
await (io.spawnStart ?? spawnDetachedStart)(port);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.warn(
|
|
152
|
+
`⚠️ Drain-and-restart spawn failed (${spawnFailureCode(err)}); exiting without replacement`,
|
|
153
|
+
);
|
|
154
|
+
// Listen socket is already stopped; do not markRecycling — no child to inherit fences.
|
|
155
|
+
// ensure/tray children inherit OCX_SERVICE=1 without an installed service; clear it so
|
|
156
|
+
// syncCleanup can restore Codex/Grok fences instead of leaving clients pointed at a dead port.
|
|
157
|
+
delete process.env.OCX_SERVICE;
|
|
158
|
+
exitProcess(1);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
(io.markRecycling ?? markRecyclingForExit)();
|
|
162
|
+
exitProcess(0);
|
|
163
|
+
}, 200);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
accepted: true,
|
|
168
|
+
alreadyDraining,
|
|
169
|
+
activeTurnCount,
|
|
170
|
+
drainTimeoutMs: MEMORY_DRAIN_RESTART_MS,
|
|
171
|
+
};
|
|
172
|
+
}
|