@bitkyc08/opencodex 2.69.0 → 2.70.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/gui/dist/assets/App-_MrbTrBe.js +51 -0
- package/gui/dist/assets/{Tray-uqKRqfvw.js → Tray-5PuUalET.js} +1 -1
- package/gui/dist/assets/index-DKrO2eF3.js +86 -0
- package/gui/dist/assets/quota-summary-BCLivc0Q.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic-model-contract.ts +119 -0
- package/src/adapters/anthropic.ts +14 -83
- package/src/adapters/base.ts +2 -0
- package/src/adapters/command-code-project-context.ts +542 -0
- package/src/adapters/command-code-tool-text.ts +99 -67
- package/src/adapters/command-code.ts +5 -1
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/devin/cloud-direct/catalog.ts +91 -0
- package/src/adapters/devin/cloud-direct/chat.ts +78 -48
- package/src/adapters/devin/cloud-direct/tool-schema.ts +121 -0
- package/src/adapters/devin/context-overflow.ts +75 -0
- package/src/adapters/devin/live-models.ts +174 -13
- package/src/adapters/devin/reasoning-signature.ts +100 -0
- package/src/adapters/devin.ts +310 -117
- package/src/adapters/kiro/payload.ts +10 -10
- package/src/adapters/kiro-images.ts +58 -7
- package/src/adapters/openai-chat/passthrough.ts +3 -1
- package/src/adapters/openai-chat.ts +2 -1
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/bridge/response-json.ts +15 -1
- package/src/bridge/sse.ts +3 -1
- package/src/claude/intercept/model-bindings.ts +1 -0
- package/src/cli/account-extended.ts +31 -1
- package/src/cli/account.ts +3 -0
- package/src/cli/capabilities.ts +13 -0
- package/src/cli/client-start-fence.ts +36 -0
- package/src/cli/dispatch.ts +3 -2
- package/src/cli/index.ts +98 -117
- package/src/cli/provider-runtime.ts +23 -2
- package/src/cli/provider.ts +3 -0
- package/src/cli/resolve.ts +18 -0
- package/src/cli/start-owner-fence.ts +30 -0
- package/src/cli/stop-approval.ts +13 -1
- package/src/cli/stop-restore.ts +73 -0
- package/src/cli/tray-proxy.ts +221 -18
- package/src/cli/version-skew.ts +25 -23
- package/src/client/runtime.ts +8 -4
- package/src/codex/auth-api/account-list.ts +11 -5
- package/src/codex/auth-api/login-flow.ts +5 -3
- package/src/codex/auth-api/main-account-probe.ts +186 -110
- package/src/codex/auth-api/pool-mode-gate.ts +61 -7
- package/src/codex/auth-api/pool-quota-probe.ts +142 -90
- package/src/codex/auth-api/reset-credit-service.ts +1 -1
- package/src/codex/catalog/gather-capture.ts +1 -0
- package/src/codex/catalog/model-hints.ts +3 -3
- package/src/codex/catalog/provider-models.ts +2 -0
- package/src/codex/low-quota-events.ts +28 -0
- package/src/codex/low-quota-observer.ts +23 -0
- package/src/codex/low-quota-protection.ts +272 -0
- package/src/codex/quota-query-backoff.ts +148 -0
- package/src/codex/quota.ts +7 -2
- package/src/config/diagnostics.ts +10 -1
- package/src/config/load-degrade.ts +17 -0
- package/src/config/mutation-lock.ts +3 -4
- package/src/config/process-state.ts +1 -1
- package/src/config/provider-validation.ts +22 -0
- package/src/config/schema/blocked-model-redirects.ts +17 -0
- package/src/config/schema/config-schema.ts +4 -1
- package/src/config/schema/leaf-validators.ts +33 -2
- package/src/config/serving-runtimes.ts +412 -0
- package/src/generated/compatibility-version.json +211 -115
- package/src/generated/model-metadata.ts +3 -3
- package/src/images/loop.ts +3 -0
- package/src/lib/errors.ts +2 -1
- package/src/lib/handled-signal-exit.ts +23 -0
- package/src/lib/request-failure-model.ts +1 -0
- package/src/lib/self-launch-argv.ts +9 -0
- package/src/lib/shadow-call.ts +5 -2
- package/src/lib/strict-semver.ts +26 -0
- package/src/lib/transient-service-path.ts +16 -0
- package/src/lib/windows-service-wrappers.ts +10 -6
- package/src/lib/winsw.ts +3 -1
- package/src/oauth/anthropic-model-routes.ts +105 -0
- package/src/oauth/anthropic-routing.ts +85 -48
- package/src/oauth/devin.ts +135 -5
- package/src/oauth/index.ts +20 -3
- package/src/oauth/kiro-terminal-failover.ts +23 -9
- package/src/oauth/pool-settings-capability.ts +10 -2
- package/src/oauth/store.ts +1 -1
- package/src/protocols/encoders/adapter-events.ts +3 -1
- package/src/providers/github-copilot-context.ts +27 -0
- package/src/providers/kiro-models.ts +4 -0
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/quota/account-cache.ts +8 -3
- package/src/providers/quota/devin.ts +286 -0
- package/src/providers/quota/vendor-probes-oauth.ts +6 -2
- package/src/providers/quota.ts +1 -1
- package/src/providers/registry/entries-core.ts +1 -1
- package/src/providers/registry/model-seeds.ts +5 -2
- package/src/responses/compaction-images.ts +27 -0
- package/src/router.ts +104 -16
- package/src/routing/analytics.ts +1 -0
- package/src/server/auth-cors.ts +9 -1
- package/src/server/background-lifecycle.ts +21 -10
- package/src/server/chat-completions.ts +2 -1
- package/src/server/chat-native.ts +1 -0
- package/src/server/index.ts +4 -8
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/management/context.ts +3 -0
- package/src/server/management/low-quota-routes.ts +14 -0
- package/src/server/management/oauth-account-routes.ts +21 -1
- package/src/server/management/provider-overwrite-carry.ts +79 -6
- package/src/server/management/provider-routes.ts +41 -28
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management-api.ts +7 -0
- package/src/server/responses/adapter-continuation.ts +3 -0
- package/src/server/responses/adapter-delivery.ts +3 -0
- package/src/server/responses/adapter-dispatch.ts +20 -4
- package/src/server/responses/antigravity-validation-refusal.ts +20 -0
- package/src/server/responses/core-auth.ts +1 -0
- package/src/server/responses/core-codex-account.ts +1 -0
- package/src/server/responses/core-normalize.ts +4 -0
- package/src/server/responses/passthrough-dispatch.ts +8 -1
- package/src/server/responses/request-sidecar-auth.ts +2 -0
- package/src/server/responses/request-transport.ts +27 -10
- package/src/server/responses/response-effects.ts +4 -1
- package/src/server/responses/run-turn-execution.ts +126 -42
- package/src/server/responses/sidecar-execution.ts +4 -1
- package/src/service/diagnostics.ts +3 -1
- package/src/service/guarded-manager-target.ts +39 -12
- package/src/service/launchd.ts +14 -6
- package/src/service/repair.ts +6 -1
- package/src/service/service-child-ownership.ts +168 -0
- package/src/service/state.ts +22 -4
- package/src/service/systemd.ts +2 -2
- package/src/service/windows-guarded-manager.ts +530 -0
- package/src/service/windows-taskxml.ts +2 -2
- package/src/service.ts +2 -0
- package/src/types/config.ts +26 -0
- package/src/types/provider.ts +12 -0
- package/src/types/request.ts +2 -0
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +13 -0
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/vision/anthropic-describe.ts +2 -1
- package/src/web-search/anthropic-executor.ts +2 -1
- package/src/web-search/loop.ts +1 -0
- package/gui/dist/assets/App-DDbgOTh6.js +0 -51
- package/gui/dist/assets/index-CuaBJkG4.js +0 -86
- package/gui/dist/assets/quota-summary-COppQSVq.js +0 -1
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
normalizeNonBlankStringArray,
|
|
11
11
|
normalizeAutoReviewModelOverrides,
|
|
12
12
|
modelCapabilitiesConfigError,
|
|
13
|
+
contextTierRecordConfigError,
|
|
13
14
|
mergeModelCapabilities,
|
|
14
15
|
} from "../provider-validation";
|
|
15
16
|
import { isValidCodexAccountNamespaceTarget } from "../../codex/account-namespace-match";
|
|
@@ -92,7 +93,7 @@ export const retryOn429PolicySchema = z.object({
|
|
|
92
93
|
* both retry layers, so the ceiling is deliberately lower than `retryOn429`'s: 10 total sends
|
|
93
94
|
* against an already-failing provider is already generous.
|
|
94
95
|
*/
|
|
95
|
-
const transientRetryOn5xxPolicySchema = z.object({
|
|
96
|
+
export const transientRetryOn5xxPolicySchema = z.object({
|
|
96
97
|
enabled: z.boolean().optional(),
|
|
97
98
|
attempts: z.number().int().min(1).max(10).optional(),
|
|
98
99
|
}).strict();
|
|
@@ -279,6 +280,10 @@ const providerNoProxySchema = z.unknown().superRefine((value, ctx) => {
|
|
|
279
280
|
*/
|
|
280
281
|
export const providerConfigSchema = z.object({
|
|
281
282
|
modelCapabilities: modelCapabilitiesSchema.optional(),
|
|
283
|
+
modelContextTiers: z.unknown().superRefine((value, ctx) => {
|
|
284
|
+
const error = contextTierRecordConfigError(value);
|
|
285
|
+
if (error) ctx.addIssue({ code: "custom", message: error });
|
|
286
|
+
}).optional().transform(value => value as OcxProviderConfig["modelContextTiers"]),
|
|
282
287
|
pinnedReasoningEffort: pinnedReasoningEffortSchema.optional(),
|
|
283
288
|
modelPinnedReasoningEfforts: modelPinnedEffortsSchema.optional(),
|
|
284
289
|
// Validated rather than left to passthrough: an unrecognized strategy would otherwise
|
|
@@ -377,6 +382,7 @@ export const providerConfigSchema = z.object({
|
|
|
377
382
|
// accepted, persisted, and then silently resolved to the `code_mode_only` default — the
|
|
378
383
|
// operator asked for shell mode, got code mode, and was told nothing (#2106).
|
|
379
384
|
codexToolMode: z.enum(["code_mode_only", "shell"]).optional(),
|
|
385
|
+
projectContext: z.enum(["off", "on"]).optional(),
|
|
380
386
|
responsesItemIdRepair: z.object({
|
|
381
387
|
message: z.array(z.string().min(1)).optional(),
|
|
382
388
|
reasoning: z.array(z.string().min(1)).optional(),
|
|
@@ -384,6 +390,7 @@ export const providerConfigSchema = z.object({
|
|
|
384
390
|
repairInvalidIds: z.boolean().optional(),
|
|
385
391
|
}).strict().optional(),
|
|
386
392
|
responsesSnapshotRepair: z.boolean().optional(),
|
|
393
|
+
hideRawReasoning: z.boolean().optional(),
|
|
387
394
|
// Invalid blocks degrade to "absent" rather than failing the whole config load: an unusable
|
|
388
395
|
// bridge block must never send an operator through invalid-config recovery for an opt-in
|
|
389
396
|
// feature that is off by default. The management write boundary still rejects it loudly.
|
|
@@ -391,7 +398,11 @@ export const providerConfigSchema = z.object({
|
|
|
391
398
|
xaiResponsesXSearch: z.boolean().optional(),
|
|
392
399
|
xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined),
|
|
393
400
|
zaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined),
|
|
394
|
-
}).passthrough()
|
|
401
|
+
}).passthrough().superRefine((provider, ctx) => {
|
|
402
|
+
if (provider.projectContext !== undefined && provider.adapter !== "command-code") {
|
|
403
|
+
ctx.addIssue({ code: "custom", path: ["projectContext"], message: "projectContext is supported only by the command-code adapter" });
|
|
404
|
+
}
|
|
405
|
+
});
|
|
395
406
|
|
|
396
407
|
|
|
397
408
|
export { providerRelativeSendPathConfigError } from "../provider-relative-send-path";
|
|
@@ -960,6 +971,26 @@ export const clientConnectionSchema = z.object({
|
|
|
960
971
|
export const codexPoolSchema = z.object({
|
|
961
972
|
excludedPlans: z.array(z.string().trim().min(1)).optional(),
|
|
962
973
|
startIdleWindows: z.boolean().optional(),
|
|
974
|
+
lowQuotaProtection: z.object({
|
|
975
|
+
enabled: z.boolean(),
|
|
976
|
+
threshold: z.number().finite().min(1).max(100),
|
|
977
|
+
actions: z.object({
|
|
978
|
+
pause: z.boolean(),
|
|
979
|
+
notify: z.boolean(),
|
|
980
|
+
}).strict(),
|
|
981
|
+
windows: z.object({
|
|
982
|
+
short: z.boolean(),
|
|
983
|
+
weekly: z.boolean(),
|
|
984
|
+
}).strict(),
|
|
985
|
+
}).strict().superRefine((policy, ctx) => {
|
|
986
|
+
if (!policy.enabled) return;
|
|
987
|
+
if (!policy.actions.pause && !policy.actions.notify) {
|
|
988
|
+
ctx.addIssue({ code: "custom", path: ["actions"], message: "enabled low-quota protection needs an action" });
|
|
989
|
+
}
|
|
990
|
+
if (!policy.windows.short && !policy.windows.weekly) {
|
|
991
|
+
ctx.addIssue({ code: "custom", path: ["windows"], message: "enabled low-quota protection needs a window" });
|
|
992
|
+
}
|
|
993
|
+
}).optional(),
|
|
963
994
|
}).strict();
|
|
964
995
|
|
|
965
996
|
/**
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serving-runtime census: which installations have served this home, newest known.
|
|
3
|
+
*
|
|
4
|
+
* The reported failure shape: the desktop app self-updates its bundled runtime while the
|
|
5
|
+
* npm/mise install the Task Scheduler wrapper was baked against stays behind. When the
|
|
6
|
+
* desktop session ends, the wrapper relaunches its pinned Bun + CLI pair and the machine
|
|
7
|
+
* silently downgrades to the older package — features the operator just gained stop
|
|
8
|
+
* existing, with nothing in the log saying why. The same hazard runs in reverse whenever
|
|
9
|
+
* the packaged install is the stale half.
|
|
10
|
+
*
|
|
11
|
+
* The fix is a small census, not a resolution oracle. Every `ocx start` that reaches the
|
|
12
|
+
* bind boundary records the command that would relaunch it (`argv`-shaped absolute
|
|
13
|
+
* paths) plus its package version. A service child — the path where a fixed definition
|
|
14
|
+
* picks the runtime, instead of the operator's shell — then asks whether a strictly newer
|
|
15
|
+
* recorded install still exists on disk, re-verifies it with a bounded `--version` probe,
|
|
16
|
+
* and hands the serve to it. Because only strictly newer candidates defer, the newest
|
|
17
|
+
* available install converges in one hop and can never ping-pong.
|
|
18
|
+
*
|
|
19
|
+
* Everything here is fail-open toward the recorded install's own runtime: a missing file,
|
|
20
|
+
* a malformed record, or a dead probe means "serve this install", never "stay down". The
|
|
21
|
+
* registry is information a serving proxy wrote about itself; it authorizes relaunching a
|
|
22
|
+
* binary the same installation already ran, nothing more.
|
|
23
|
+
*/
|
|
24
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
25
|
+
import { constants as osConstants } from "node:os";
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { isAbsolute, join } from "node:path";
|
|
29
|
+
import { durableBunRuntime } from "../lib/bun-runtime";
|
|
30
|
+
import { selfLaunchArgv } from "../lib/self-launch-argv";
|
|
31
|
+
import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver";
|
|
32
|
+
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
33
|
+
import { atomicWriteFile } from "./atomic-write";
|
|
34
|
+
import { getConfigDir } from "./paths";
|
|
35
|
+
import { ConfigMutationLockError, withConfigMutationLockSync } from "./mutation-lock";
|
|
36
|
+
import { SERVICE_MANAGED_ENV } from "../service/state";
|
|
37
|
+
import { WINDOWS_WRAPPER_PROTOCOL_ENV } from "../service/windows-wrapper-exit";
|
|
38
|
+
|
|
39
|
+
export function servingRuntimesPath(dir: string = getConfigDir()): string {
|
|
40
|
+
return join(dir, "serving-runtimes.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The argv prefix that relaunches THIS install's proxy: `[exe]` for a compiled
|
|
45
|
+
* standalone binary, `[bun, cli/index.ts]` for a package install. Lives here because
|
|
46
|
+
* the census is its only consumer.
|
|
47
|
+
*/
|
|
48
|
+
export function currentServingCommand(): string[] {
|
|
49
|
+
return [durableBunRuntime().path, ...selfLaunchArgv([])]
|
|
50
|
+
.map(part => (isAbsolute(part) ? part : join(process.cwd(), part)));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One installation's relaunch command and the version it last served. */
|
|
54
|
+
export interface ServedRuntimeRecord {
|
|
55
|
+
/**
|
|
56
|
+
* The argv prefix that relaunches this install's proxy: `[exe]` for a compiled
|
|
57
|
+
* standalone binary, `[bun, cli/index.ts]` for a package install. Absolute paths only.
|
|
58
|
+
*/
|
|
59
|
+
readonly command: readonly string[];
|
|
60
|
+
readonly version: string;
|
|
61
|
+
readonly servedAt: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Bound on retained installs; the file is operator-facing state, not a log. */
|
|
65
|
+
const MAX_SERVING_RUNTIME_RECORDS = 16;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Probe budget for re-verifying a recorded binary. Runs once per service-child start,
|
|
69
|
+
* so a short ceiling keeps a wedged sibling binary from delaying every relaunch.
|
|
70
|
+
*/
|
|
71
|
+
const RUNTIME_VERSION_PROBE_TIMEOUT_MS = 3_000;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Bound on candidate probes per service-child start. The census can retain up to
|
|
75
|
+
* MAX_SERVING_RUNTIME_RECORDS installs and each probe carries its own timeout, so
|
|
76
|
+
* without a cap a registry full of dead records would stall every relaunch by
|
|
77
|
+
* RECORDS × the probe timeout.
|
|
78
|
+
*/
|
|
79
|
+
const MAX_VERSION_PROBE_ATTEMPTS = 4;
|
|
80
|
+
|
|
81
|
+
function canonicalPath(path: string): string {
|
|
82
|
+
let resolved = path;
|
|
83
|
+
try { resolved = realpathSync(path); } catch { /* keep the literal path */ }
|
|
84
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Identity of a relaunch command. Two spellings of one binary (junction, mapped drive,
|
|
89
|
+
* case fold) must collide; different installs must not.
|
|
90
|
+
*/
|
|
91
|
+
export function servingRuntimeCommandKey(command: readonly string[]): string {
|
|
92
|
+
return command.map(canonicalPath).join("\u0000");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isValidRecord(value: unknown): value is ServedRuntimeRecord {
|
|
96
|
+
if (!value || typeof value !== "object") return false;
|
|
97
|
+
const record = value as Record<string, unknown>;
|
|
98
|
+
if (!Array.isArray(record.command) || record.command.length === 0) return false;
|
|
99
|
+
if (!record.command.every(part => typeof part === "string" && isAbsolute(part))) return false;
|
|
100
|
+
if (typeof record.version !== "string" || parseStrictSemver(record.version) === null) return false;
|
|
101
|
+
return typeof record.servedAt === "string";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Read the census. Malformed entries are dropped; a malformed file reads as empty. */
|
|
105
|
+
export function readServingRuntimes(dir: string = getConfigDir()): ServedRuntimeRecord[] {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(readFileSync(servingRuntimesPath(dir), "utf-8"));
|
|
108
|
+
const runtimes = (parsed as Record<string, unknown>)?.runtimes;
|
|
109
|
+
if (!Array.isArray(runtimes)) return [];
|
|
110
|
+
return runtimes.filter(isValidRecord);
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Record that this install served this home. Runs only after the bind boundary
|
|
118
|
+
* succeeded: a start that never reached serving must not register a runtime that
|
|
119
|
+
* never actually ran. Best-effort after the entry shape is validated — a census
|
|
120
|
+
* write failure must never take down a healthy start.
|
|
121
|
+
*/
|
|
122
|
+
export function recordServingRuntime(
|
|
123
|
+
record: ServedRuntimeRecord,
|
|
124
|
+
dir: string = getConfigDir(),
|
|
125
|
+
): void {
|
|
126
|
+
if (!isValidRecord(record)) return;
|
|
127
|
+
try {
|
|
128
|
+
assertNotRealHomeUnderTest(dir);
|
|
129
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
130
|
+
// The complete read/merge/replace is one cross-process transaction. Briefly retry
|
|
131
|
+
// SQLITE_BUSY because this best-effort startup write must not drop a concurrent serve.
|
|
132
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
133
|
+
try {
|
|
134
|
+
withConfigMutationLockSync(() => {
|
|
135
|
+
const key = servingRuntimeCommandKey(record.command);
|
|
136
|
+
const merged = [record, ...readServingRuntimes(dir).filter(entry => servingRuntimeCommandKey(entry.command) !== key)];
|
|
137
|
+
atomicWriteFile(servingRuntimesPath(dir), JSON.stringify({ runtimes: merged.slice(0, MAX_SERVING_RUNTIME_RECORDS) }, null, 2) + "\n");
|
|
138
|
+
}, dir);
|
|
139
|
+
break;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (!(error instanceof ConfigMutationLockError) || attempt === 19) throw error;
|
|
142
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch { /* census loss must never fail a start */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface SyncRunResult {
|
|
149
|
+
readonly status: number | null;
|
|
150
|
+
readonly stdout: string;
|
|
151
|
+
readonly stderr: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type SyncRunner = (file: string, args: readonly string[]) => SyncRunResult;
|
|
155
|
+
|
|
156
|
+
function bufferedSyncRunner(file: string, args: readonly string[]): SyncRunResult {
|
|
157
|
+
const result = spawnSync(file, [...args], {
|
|
158
|
+
encoding: "utf8",
|
|
159
|
+
windowsHide: true,
|
|
160
|
+
timeout: RUNTIME_VERSION_PROBE_TIMEOUT_MS,
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
status: result.status,
|
|
164
|
+
stdout: String(result.stdout ?? ""),
|
|
165
|
+
stderr: String(result.stderr ?? ""),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Extract the strict semver a binary reports for `--version`, or null when it cannot be
|
|
171
|
+
* asked. The probe is the re-verification step: a census record survives the binary it
|
|
172
|
+
* described (a rollback, a partial uninstall), so the version on record alone never
|
|
173
|
+
* authorizes a handoff.
|
|
174
|
+
*/
|
|
175
|
+
export function probeServedRuntimeVersion(
|
|
176
|
+
command: readonly string[],
|
|
177
|
+
run: SyncRunner = bufferedSyncRunner,
|
|
178
|
+
): string | null {
|
|
179
|
+
if (command.length === 0) return null;
|
|
180
|
+
let result: SyncRunResult;
|
|
181
|
+
try {
|
|
182
|
+
result = run(command[0]!, [...command.slice(1), "--version"]);
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
if (result.status !== 0) return null;
|
|
187
|
+
// printVersion() emits "opencodex X.Y.Z"; accept the strict-semver token wherever it lands.
|
|
188
|
+
const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/.exec(result.stdout);
|
|
189
|
+
if (!match) return null;
|
|
190
|
+
return parseStrictSemver(match[1])?.raw ?? null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Reject a substituted launch target from home-local census state. */
|
|
194
|
+
function trustedRecordedPath(path: string): boolean {
|
|
195
|
+
try {
|
|
196
|
+
if (!isAbsolute(path)) return false;
|
|
197
|
+
const info = statSync(realpathSync(path));
|
|
198
|
+
if (!info.isFile()) return false;
|
|
199
|
+
if (process.platform !== "win32") {
|
|
200
|
+
if (typeof process.getuid === "function" && info.uid !== process.getuid()) return false;
|
|
201
|
+
if ((info.mode & 0o022) !== 0) return false;
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface NewerServingRuntimeDeps {
|
|
210
|
+
readonly dir?: string;
|
|
211
|
+
readonly exists?: (path: string) => boolean;
|
|
212
|
+
readonly run?: SyncRunner;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The newest recorded install that can actually replace this one, or null.
|
|
217
|
+
*
|
|
218
|
+
* "Recorded" alone never suffices: the probe re-asks the binary for its version, so a
|
|
219
|
+
* record left behind by a replaced or partially removed install demotes itself instead of
|
|
220
|
+
* handing the port to a stale executable. Candidates probe best-recorded-version first
|
|
221
|
+
* and a dead or demoting candidate falls through to the next one — a rolled-back top
|
|
222
|
+
* record must not keep a good install beneath it from serving. Self is excluded by
|
|
223
|
+
* command identity, not by version, so an equal-version sibling is never a candidate.
|
|
224
|
+
*/
|
|
225
|
+
export function selectNewerServingRuntime(
|
|
226
|
+
selfVersion: string,
|
|
227
|
+
selfCommand: readonly string[],
|
|
228
|
+
deps: NewerServingRuntimeDeps = {},
|
|
229
|
+
): ServedRuntimeRecord | null {
|
|
230
|
+
const self = parseStrictSemver(selfVersion);
|
|
231
|
+
if (self === null) return null;
|
|
232
|
+
const exists = deps.exists ?? existsSync;
|
|
233
|
+
const selfKey = servingRuntimeCommandKey(selfCommand);
|
|
234
|
+
const candidates = readServingRuntimes(deps.dir ?? getConfigDir())
|
|
235
|
+
.filter(record => servingRuntimeCommandKey(record.command) !== selfKey)
|
|
236
|
+
.filter(record => {
|
|
237
|
+
const recorded = parseStrictSemver(record.version);
|
|
238
|
+
return recorded !== null && compareStrictSemver(recorded, self) > 0;
|
|
239
|
+
})
|
|
240
|
+
.filter(record => record.command.every(part => exists(part) && trustedRecordedPath(part)))
|
|
241
|
+
.sort((left, right) => compareStrictSemver(parseStrictSemver(right.version)!, parseStrictSemver(left.version)!));
|
|
242
|
+
// Probe the bounded candidate set and pick the greatest VERIFIED version. The recorded
|
|
243
|
+
// version only orders the probes: a record claiming 2.70 that now answers 2.68 must not win
|
|
244
|
+
// over a sibling that records and answers 2.69, and the one-hop marker means the delegate
|
|
245
|
+
// could never correct that choice itself.
|
|
246
|
+
let best: { record: ServedRuntimeRecord; version: string; semver: NonNullable<ReturnType<typeof parseStrictSemver>> } | null = null;
|
|
247
|
+
for (const record of candidates.slice(0, MAX_VERSION_PROBE_ATTEMPTS)) {
|
|
248
|
+
const probed = probeServedRuntimeVersion(record.command, deps.run);
|
|
249
|
+
const probedSemver = probed === null ? null : parseStrictSemver(probed);
|
|
250
|
+
if (probedSemver === null || compareStrictSemver(probedSemver, self) <= 0) continue;
|
|
251
|
+
if (best === null || compareStrictSemver(probedSemver, best.semver) > 0) best = { record, version: probed!, semver: probedSemver };
|
|
252
|
+
}
|
|
253
|
+
return best === null ? null : { ...best.record, version: best.version };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface DeferToNewerRuntimeDeps extends NewerServingRuntimeDeps {
|
|
257
|
+
readonly runInherited?: (command: readonly string[], args: readonly string[]) => Promise<DelegatedExit>;
|
|
258
|
+
readonly log?: (line: string) => void;
|
|
259
|
+
/** Environment the service manager gave this child; decides its stay-out exit code. */
|
|
260
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
interface DelegatedExit { readonly exitCode: number; readonly ready: boolean }
|
|
264
|
+
const DELEGATED_ONCE_ENV = "OCX_DELEGATED_ONCE";
|
|
265
|
+
const DELEGATED_READY_ENV = "OCX_DELEGATED_READY_NONCE";
|
|
266
|
+
const READY_NONCE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
267
|
+
function readyMarkerPath(nonce: string): string {
|
|
268
|
+
return join(getConfigDir(), `.delegated-ready-${nonce}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Child publishes bind completion for its waiting service parent. */
|
|
272
|
+
export function markDelegatedServiceReady(): void {
|
|
273
|
+
const nonce = process.env[DELEGATED_READY_ENV];
|
|
274
|
+
if (process.env[DELEGATED_ONCE_ENV] !== "1" || !nonce || !READY_NONCE_RE.test(nonce)) return;
|
|
275
|
+
try { writeFileSync(readyMarkerPath(nonce), String(process.pid), { flag: "wx", mode: 0o600 }); }
|
|
276
|
+
catch { /* parent will conservatively treat an early failed child as unready */ }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Time a delegated child gets to finish shutting down after this parent takes a
|
|
281
|
+
* termination signal, before escalation to SIGKILL. The parent waits for the child
|
|
282
|
+
* to exit; repeated signals share one timer, which is cleared when that wait ends.
|
|
283
|
+
*/
|
|
284
|
+
const DELEGATED_SIGNAL_GRACE_MS = 5_000;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Run the delegated install in the foreground and wait for its exit.
|
|
288
|
+
*
|
|
289
|
+
* The service manager tracks THIS process, so termination must reach the child:
|
|
290
|
+
* signals are forwarded, and a parent exit (including a manager kill that never
|
|
291
|
+
* signals the child) still terminates it through the exit hook. A manager that
|
|
292
|
+
* force-kills the parent without a signal remains the one uncovered case — the
|
|
293
|
+
* port-holding child then outlives the registration, which the next service start
|
|
294
|
+
* sees through the usual live-owner path.
|
|
295
|
+
*/
|
|
296
|
+
async function inheritedRunner(command: readonly string[], args: readonly string[]): Promise<DelegatedExit> {
|
|
297
|
+
const nonce = randomUUID();
|
|
298
|
+
const marker = readyMarkerPath(nonce);
|
|
299
|
+
const child = spawn(command[0]!, [...command.slice(1), ...args], {
|
|
300
|
+
stdio: "inherit",
|
|
301
|
+
windowsHide: true,
|
|
302
|
+
env: { ...process.env, [DELEGATED_ONCE_ENV]: "1", [DELEGATED_READY_ENV]: nonce },
|
|
303
|
+
});
|
|
304
|
+
const terminateChild = () => { try { child.kill(); } catch { /* already gone */ } };
|
|
305
|
+
const forceKillChild = () => { try { child.kill("SIGKILL"); } catch { /* already gone */ } };
|
|
306
|
+
let escalation: ReturnType<typeof setTimeout> | undefined;
|
|
307
|
+
let terminationRequested = false;
|
|
308
|
+
const forward = (signal: NodeJS.Signals) => () => {
|
|
309
|
+
terminationRequested = true;
|
|
310
|
+
try { child.kill(signal); } catch { /* already gone */ }
|
|
311
|
+
escalation ??= setTimeout(forceKillChild, DELEGATED_SIGNAL_GRACE_MS);
|
|
312
|
+
escalation.unref();
|
|
313
|
+
};
|
|
314
|
+
const onSigint = forward("SIGINT");
|
|
315
|
+
const onSigterm = forward("SIGTERM");
|
|
316
|
+
const onSighup = forward("SIGHUP");
|
|
317
|
+
process.on("SIGINT", onSigint);
|
|
318
|
+
process.on("SIGTERM", onSigterm);
|
|
319
|
+
process.on("SIGHUP", onSighup);
|
|
320
|
+
process.on("exit", terminateChild);
|
|
321
|
+
try {
|
|
322
|
+
const exitCode = await new Promise<number>((resolve, reject) => {
|
|
323
|
+
child.once("error", reject);
|
|
324
|
+
child.once("exit", (code, signal) => resolve(
|
|
325
|
+
signal === null ? (code ?? 1) : 128 + (osConstants.signals[signal] ?? 1),
|
|
326
|
+
));
|
|
327
|
+
});
|
|
328
|
+
let ready = terminationRequested;
|
|
329
|
+
try { ready ||= readFileSync(marker, "utf8") === String(child.pid); } catch { /* pre-bind exit */ }
|
|
330
|
+
return { exitCode, ready };
|
|
331
|
+
} finally {
|
|
332
|
+
try { unlinkSync(marker); } catch { /* absent marker */ }
|
|
333
|
+
if (escalation) clearTimeout(escalation);
|
|
334
|
+
process.off("SIGINT", onSigint);
|
|
335
|
+
process.off("SIGTERM", onSigterm);
|
|
336
|
+
process.off("SIGHUP", onSighup);
|
|
337
|
+
process.off("exit", terminateChild);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Hand this service child's serve to a strictly newer recorded install.
|
|
343
|
+
*
|
|
344
|
+
* Resolves to the delegated child's exit code when a handoff happened, null when this
|
|
345
|
+
* process should serve itself. The foreground wait keeps the service contract intact:
|
|
346
|
+
* the wrapper sees the real child's exit (including the stay-out code) and restart
|
|
347
|
+
* ownership stays exactly where the manager put it. Only called on the `OCX_SERVICE`
|
|
348
|
+
* path — an interactive `ocx start` already picked its binary on PATH.
|
|
349
|
+
*/
|
|
350
|
+
export async function deferToNewerServiceRuntime(
|
|
351
|
+
selfVersion: string,
|
|
352
|
+
selfCommand: readonly string[],
|
|
353
|
+
port: number | undefined,
|
|
354
|
+
deps: DeferToNewerRuntimeDeps = {},
|
|
355
|
+
): Promise<number | null> {
|
|
356
|
+
const log = deps.log ?? (line => console.log(line));
|
|
357
|
+
const candidate = selectNewerServingRuntime(selfVersion, selfCommand, deps);
|
|
358
|
+
if (candidate === null) return null;
|
|
359
|
+
const startArgs = ["start"];
|
|
360
|
+
if (port !== undefined && Number.isFinite(port) && port > 0 && port <= 65535) {
|
|
361
|
+
startArgs.push("--port", String(Math.trunc(port)));
|
|
362
|
+
}
|
|
363
|
+
const run = deps.runInherited ?? inheritedRunner;
|
|
364
|
+
log(
|
|
365
|
+
`⚠️ This install (${selfVersion}) is older than the runtime that last served this home (${candidate.version}). `
|
|
366
|
+
+ `Deferring to ${candidate.command.join(" ")} so the service does not silently downgrade.`,
|
|
367
|
+
);
|
|
368
|
+
try {
|
|
369
|
+
const result = await run(candidate.command, startArgs);
|
|
370
|
+
// Any exit before the delegate published its bind, whatever the code, falls back to this
|
|
371
|
+
// installation. Propagating a pre-bind exit 0 (or the stay-out code) would end the service
|
|
372
|
+
// with nothing serving. Falling back cannot override a deliberate stand-down: this process
|
|
373
|
+
// continues through the same start path, whose lease-held bind fence re-applies every
|
|
374
|
+
// stay-out condition (a live proxy, a foreign recorded owner) before it binds anything.
|
|
375
|
+
if (!result.ready) {
|
|
376
|
+
log(`⚠️ Newer runtime exited before bind (status ${result.exitCode}); serving this install instead.`);
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
return result.exitCode;
|
|
380
|
+
} catch (error) {
|
|
381
|
+
log(`⚠️ Newer runtime failed to launch (${error instanceof Error ? error.message : String(error)}); serving this install instead.`);
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The `handleStart` gate: only a service child defers, and only before it binds.
|
|
388
|
+
* A sibling owns nothing shared, and an interactive start picked its binary on PATH —
|
|
389
|
+
* the manager's baked definition is the one place "which install serves" can drift.
|
|
390
|
+
*/
|
|
391
|
+
export async function deferServiceChildToNewerRuntime(options: {
|
|
392
|
+
readonly sibling: boolean;
|
|
393
|
+
readonly env: NodeJS.ProcessEnv;
|
|
394
|
+
readonly selfVersion: string;
|
|
395
|
+
readonly selfCommand: readonly string[];
|
|
396
|
+
readonly port?: number;
|
|
397
|
+
readonly deps?: DeferToNewerRuntimeDeps;
|
|
398
|
+
}): Promise<number | null> {
|
|
399
|
+
if (options.sibling || !isManagedServiceEnvironment(options.env) || options.env[DELEGATED_ONCE_ENV] === "1") return null;
|
|
400
|
+
return deferToNewerServiceRuntime(options.selfVersion, options.selfCommand, options.port, { env: options.env, ...options.deps });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* A child a service manager started, as its environment proves it. launchd, systemd, and
|
|
405
|
+
* current or repaired WinSW definitions write `OCX_SERVICE_MANAGED=1`; the Windows Task
|
|
406
|
+
* Scheduler wrapper writes `OCX_SERVICE=1` with its stay-out protocol marker instead.
|
|
407
|
+
* Bare `OCX_SERVICE=1` never qualifies: `ocx claude` and `ocx opencode` companions carry
|
|
408
|
+
* it, and legacy WinSW definitions without the managed marker do not delegate until repaired.
|
|
409
|
+
*/
|
|
410
|
+
export function isManagedServiceEnvironment(env: NodeJS.ProcessEnv): boolean {
|
|
411
|
+
return env[SERVICE_MANAGED_ENV] === "1" || (env.OCX_SERVICE === "1" && env[WINDOWS_WRAPPER_PROTOCOL_ENV] === "1");
|
|
412
|
+
}
|