@bitkyc08/opencodex 2.77.0 → 2.78.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/bin/ocx.mjs +11 -10
- package/gui/dist/assets/App-BtfG5id-.js +54 -0
- package/gui/dist/assets/App-lCIslZ1L.css +1 -0
- package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
- package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
- package/gui/dist/assets/index-DmQB8D_R.css +1 -0
- package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic/beta-allowlist.ts +19 -2
- package/src/adapters/anthropic/client-identity.ts +13 -4
- package/src/adapters/anthropic/native-client-preamble.ts +31 -0
- package/src/adapters/anthropic/passthrough.ts +103 -25
- package/src/adapters/devin/cloud-direct/chat.ts +7 -2
- package/src/adapters/devin/trajectory.ts +49 -0
- package/src/adapters/devin.ts +13 -0
- package/src/adapters/ollama-native.ts +35 -19
- package/src/claude/intercept/client-hello.ts +103 -0
- package/src/claude/intercept/model-bindings.ts +3 -2
- package/src/claude/intercept/picker-listener.ts +279 -66
- package/src/cli/access-audio-input.ts +84 -0
- package/src/cli/access-audio-live.ts +99 -0
- package/src/cli/access-audio.ts +123 -0
- package/src/cli/access-data-client.ts +127 -0
- package/src/cli/access-data-plane.ts +104 -0
- package/src/cli/access-data-response.ts +76 -0
- package/src/cli/access.ts +65 -2
- package/src/cli/account-api.ts +30 -6
- package/src/cli/account-auth.ts +111 -42
- package/src/cli/account-key-quota.ts +118 -0
- package/src/cli/account-main.ts +11 -4
- package/src/cli/account-next-actions.ts +63 -0
- package/src/cli/account-policy-dto.ts +68 -0
- package/src/cli/account-policy.ts +167 -0
- package/src/cli/account-target.ts +8 -0
- package/src/cli/account.ts +34 -8
- package/src/cli/agent-runtime-settings.ts +130 -0
- package/src/cli/agent-settings.ts +101 -0
- package/src/cli/agent.ts +19 -2
- package/src/cli/alias.ts +7 -2
- package/src/cli/aside-profiles.ts +2 -2
- package/src/cli/capabilities-access-remote.ts +147 -0
- package/src/cli/capabilities-accounts.ts +195 -0
- package/src/cli/capabilities-agents-routing.ts +414 -0
- package/src/cli/capabilities-base.ts +783 -0
- package/src/cli/capabilities-command.ts +1 -0
- package/src/cli/capabilities-integrations.ts +264 -0
- package/src/cli/capabilities-lab.ts +214 -0
- package/src/cli/capabilities-observe-system.ts +662 -0
- package/src/cli/capabilities-provider-models.ts +302 -0
- package/src/cli/capabilities.ts +30 -1199
- package/src/cli/capability-types.ts +54 -0
- package/src/cli/catalog-command-result.ts +57 -0
- package/src/cli/chatgpt-command.ts +25 -2
- package/src/cli/claude-agent-startup-sync.ts +17 -0
- package/src/cli/claude-desktop-profile.ts +100 -0
- package/src/cli/claude-desktop.ts +39 -6
- package/src/cli/codex-shim-autorestore.ts +2 -1
- package/src/cli/codex-shim-readiness.ts +23 -3
- package/src/cli/combo-input.ts +88 -0
- package/src/cli/combo-result.ts +52 -0
- package/src/cli/combo-stats.ts +123 -0
- package/src/cli/combo.ts +37 -14
- package/src/cli/companion-timeline.ts +142 -0
- package/src/cli/companion-usage.ts +146 -0
- package/src/cli/companion.ts +12 -3
- package/src/cli/config-command.ts +24 -8
- package/src/cli/dispatch.ts +52 -79
- package/src/cli/doctor.ts +22 -4
- package/src/cli/help-catalog.ts +31 -14
- package/src/cli/help-recovery.ts +2 -2
- package/src/cli/help.ts +25 -11
- package/src/cli/index.ts +38 -27
- package/src/cli/injection-follow.ts +35 -0
- package/src/cli/inspect.ts +5 -0
- package/src/cli/integration-aside-sync.ts +69 -0
- package/src/cli/integration-cursor.ts +106 -0
- package/src/cli/integration-input.ts +60 -0
- package/src/cli/integration-journal.ts +49 -0
- package/src/cli/integration-plan-dto.ts +156 -0
- package/src/cli/integration-preview.ts +215 -0
- package/src/cli/integrations.ts +24 -18
- package/src/cli/json-input.ts +134 -0
- package/src/cli/link.ts +32 -4
- package/src/cli/local-sync-result.ts +41 -0
- package/src/cli/log-follow.ts +84 -0
- package/src/cli/log-view-filter.ts +146 -0
- package/src/cli/logout-command.ts +44 -0
- package/src/cli/model-picker-ordering.ts +137 -0
- package/src/cli/models-custom-input.ts +107 -0
- package/src/cli/models-custom-runtime.ts +112 -0
- package/src/cli/models-order.ts +194 -0
- package/src/cli/models-runtime-subcommands.ts +2 -0
- package/src/cli/models-runtime.ts +7 -0
- package/src/cli/models.ts +66 -81
- package/src/cli/observe-stream.ts +142 -0
- package/src/cli/observe.ts +75 -34
- package/src/cli/provider-batch.ts +77 -0
- package/src/cli/provider-lifecycle-runtime.ts +170 -0
- package/src/cli/provider-result.ts +124 -0
- package/src/cli/provider-runtime.ts +15 -12
- package/src/cli/provider-settings.ts +143 -0
- package/src/cli/provider.ts +112 -34
- package/src/cli/registry.ts +62 -20
- package/src/cli/remote-workspace-hub.ts +83 -0
- package/src/cli/remote-workspace.ts +6 -1
- package/src/cli/restart-failure.ts +27 -0
- package/src/cli/root.ts +12 -0
- package/src/cli/route-policy-write.ts +137 -0
- package/src/cli/route-policy.ts +8 -0
- package/src/cli/runtime-api.ts +56 -79
- package/src/cli/secret-args.ts +149 -0
- package/src/cli/settings-result.ts +30 -0
- package/src/cli/storage.ts +77 -7
- package/src/cli/system-command.ts +60 -2
- package/src/cli/system-restart-client.ts +20 -3
- package/src/cli/system-settings-parity.ts +83 -0
- package/src/cli/update-restart-candidate.ts +21 -0
- package/src/cli/update-restart-child.ts +81 -0
- package/src/cli/update-restart-home.ts +38 -0
- package/src/cli/update-restart-transport.ts +193 -0
- package/src/cli/update-restart.ts +260 -0
- package/src/cli/usage-model-search.ts +58 -0
- package/src/cli/usage-report.ts +13 -5
- package/src/cli/v2-input.ts +66 -0
- package/src/cli/v2-local-output.ts +37 -0
- package/src/cli/v2-runtime.ts +90 -0
- package/src/cli/v2.ts +102 -144
- package/src/clients/config-export/dsh.ts +21 -0
- package/src/clients/config-export.ts +7 -1
- package/src/codex/catalog/build-entries.ts +1 -1
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/heal-observation.ts +66 -0
- package/src/codex/catalog/publication-observer.ts +19 -0
- package/src/codex/catalog/remote.ts +13 -15
- package/src/codex/catalog/restore.ts +19 -6
- package/src/codex/catalog/retained-sync.ts +58 -10
- package/src/codex/catalog/routed-removal.ts +116 -0
- package/src/codex/catalog/write-audit-contract.ts +21 -0
- package/src/codex/catalog/write-audit.ts +171 -0
- package/src/codex/catalog-self-heal.ts +284 -0
- package/src/codex/catalog-write-serialization.ts +71 -7
- package/src/codex/codex-home-owner.ts +124 -0
- package/src/codex/convergence.ts +55 -15
- package/src/codex/features.ts +8 -1
- package/src/codex/inject/restore.ts +46 -4
- package/src/codex/inject.ts +11 -1
- package/src/codex/internal/catalog-writer.ts +120 -13
- package/src/codex/journal.ts +49 -13
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/quota-types.ts +3 -0
- package/src/codex/quota.ts +13 -3
- package/src/codex/refresh.ts +20 -9
- package/src/codex/runtime.ts +7 -1
- package/src/codex/shim-diagnostics.ts +80 -0
- package/src/codex/shim-fingerprint.ts +7 -2
- package/src/codex/shim-migration.ts +153 -0
- package/src/codex/shim-overlay.ts +509 -0
- package/src/codex/shim-path-resolution.ts +14 -2
- package/src/codex/shim-probe.ts +4 -3
- package/src/codex/shim-state-file.ts +93 -7
- package/src/codex/shim.ts +15 -68
- package/src/codex/sync.ts +24 -4
- package/src/combos/failover.ts +19 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/atomic-write.ts +13 -8
- package/src/config/diagnostics.ts +19 -1
- package/src/config/load-degrade.ts +3 -0
- package/src/config/schema/chatgpt-desktop.ts +22 -0
- package/src/config/schema/config-schema.ts +32 -14
- package/src/config/schema/leaf-validators.ts +3 -2
- package/src/generated/compatibility-version.json +445 -129
- package/src/integrations/merge.ts +13 -5
- package/src/integrations/mutation-plan.ts +46 -3
- package/src/integrations/omp-yaml-source.ts +181 -1
- package/src/integrations/ownership-policy.ts +66 -0
- package/src/integrations/registry.ts +50 -1
- package/src/integrations/state.ts +46 -21
- package/src/integrations/target.ts +25 -2
- package/src/integrations/writer.ts +89 -31
- package/src/lib/package-tree-integrity.ts +42 -1
- package/src/lib/proxy-env.ts +3 -0
- package/src/lib/test-home-guard.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +19 -8
- package/src/oauth/anthropic-routing.ts +17 -12
- package/src/oauth/anthropic.ts +27 -2
- package/src/oauth/index.ts +6 -8
- package/src/oauth/pool-settings-capability.ts +9 -2
- package/src/protocols/plan-snapshot.ts +1 -1
- package/src/protocols/settings.ts +28 -4
- package/src/server/caller-session-identity.ts +32 -0
- package/src/server/claude-messages.ts +11 -4
- package/src/server/index/serve-options.ts +12 -7
- package/src/server/index.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +6 -5
- package/src/server/management/context.ts +8 -1
- package/src/server/management/cursor-integration-routes.ts +3 -3
- package/src/server/management/integration-routes.ts +5 -5
- package/src/server/management/model-routes.ts +3 -3
- package/src/server/management/native-integration-routes.ts +2 -2
- package/src/server/management/oauth-account-routes.ts +96 -30
- package/src/server/management/route-registry.ts +25 -24
- package/src/server/messages-native-eligibility.ts +3 -6
- package/src/server/messages-native-oauth.ts +82 -35
- package/src/server/messages-native.ts +78 -16
- package/src/server/responses/adapter-dispatch.ts +53 -28
- package/src/server/responses/core-auth.ts +10 -4
- package/src/server/responses/core-combo.ts +46 -6
- package/src/service/cli.ts +6 -1
- package/src/service/ownership-mutation-lease.d.mts +26 -0
- package/src/service/ownership-mutation-lease.mjs +76 -1
- package/src/types/config.ts +2 -0
- package/src/types/provider.ts +10 -10
- package/src/update/index.ts +2 -1
- package/src/update/update-failure-guidance.d.mts +6 -0
- package/src/update/update-failure-guidance.mjs +30 -5
- package/gui/dist/assets/App-COdEeqSj.css +0 -1
- package/gui/dist/assets/App-DEpCJEP_.js +0 -54
- package/gui/dist/assets/index-Cd7ittMv.css +0 -1
- package/gui/dist/assets/index-DwJKFJm5.js +0 -94
- package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
import type { ProxyRestartRequestOutcome } from "./tray-proxy";
|
|
27
27
|
import { packageVersion } from "./help";
|
|
28
28
|
import { computeVersionSkew } from "./version-skew";
|
|
29
|
+
import { readUpdateRestartHome } from "./update-restart-home";
|
|
30
|
+
import { UpdateRestartRequired } from "./update-restart-candidate";
|
|
29
31
|
|
|
30
32
|
export const SYSTEM_RESTART_REQUEST_TIMEOUT_MS = 5_000;
|
|
31
33
|
export const SYSTEM_RESTART_ATTESTATION_TIMEOUT_MS = 4_000;
|
|
@@ -38,6 +40,7 @@ export interface BoundSystemRestartDeps {
|
|
|
38
40
|
now?: () => number;
|
|
39
41
|
/** Invoking CLI version for the skew guard; defaults to this bundle's package version. */
|
|
40
42
|
cliVersion?: string;
|
|
43
|
+
readUpdateHome?: typeof readUpdateRestartHome;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
function rejected(code: string): ProxyRestartRequestOutcome {
|
|
@@ -90,6 +93,8 @@ export async function requestBoundSystemRestart(
|
|
|
90
93
|
return rejected("restart_target_runtime_mismatch");
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
let updateHome: ReturnType<typeof readUpdateRestartHome> | undefined;
|
|
97
|
+
try { updateHome = (deps.readUpdateHome ?? readUpdateRestartHome)(); } catch { /* normal in-place restart remains available */ }
|
|
93
98
|
const attestationBudget = remaining(deadlineAt, now, SYSTEM_RESTART_ATTESTATION_TIMEOUT_MS);
|
|
94
99
|
if (attestationBudget <= 0) return rejected("restart_deadline_expired");
|
|
95
100
|
|
|
@@ -143,9 +148,9 @@ export async function requestBoundSystemRestart(
|
|
|
143
148
|
const proxyVersion = fenced
|
|
144
149
|
? body.installedVersion as string
|
|
145
150
|
: typeof body.version === "string" ? body.version : undefined;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
151
|
+
const cliVersion = deps.cliVersion ?? ownCliVersion();
|
|
152
|
+
const skew = computeVersionSkew(cliVersion, proxyVersion);
|
|
153
|
+
if (skew.skewed && skew.relation !== "cli-newer") return rejected("restart_version_skew");
|
|
149
154
|
|
|
150
155
|
let observed: LiveProxy | null;
|
|
151
156
|
try {
|
|
@@ -154,6 +159,18 @@ export async function requestBoundSystemRestart(
|
|
|
154
159
|
return rejected("restart_target_recheck_failed");
|
|
155
160
|
}
|
|
156
161
|
if (!sameRestartTarget(target, observed)) return rejected("restart_target_changed");
|
|
162
|
+
if (skew.relation === "cli-newer") {
|
|
163
|
+
if (!updateHome) return rejected("update_restart_home_unverified");
|
|
164
|
+
const current = readRuntime(target.pid);
|
|
165
|
+
if (!current || current.pid !== runtime.pid || current.port !== runtime.port
|
|
166
|
+
|| current.hostname !== runtime.hostname || current.attestationSecret !== runtime.attestationSecret) {
|
|
167
|
+
return rejected("restart_target_changed");
|
|
168
|
+
}
|
|
169
|
+
return { accepted: false, uncertain: false, error: new UpdateRestartRequired({
|
|
170
|
+
target: { ...target, pid: target.pid, version: proxyVersion },
|
|
171
|
+
runtime: { ...runtime, attestationSecret: runtime.attestationSecret }, cliVersion, home: updateHome,
|
|
172
|
+
}) };
|
|
173
|
+
}
|
|
157
174
|
|
|
158
175
|
const capability = createSystemRestartCapability(
|
|
159
176
|
runtime.attestationSecret,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { runCatalogAction } from "./catalog-command-result";
|
|
2
|
+
import { serializeManagementJson } from "./json-input";
|
|
3
|
+
import { printSettingsResult, projectSettingsApply } from "./settings-result";
|
|
4
|
+
import { CliUsageError, runtimeBaseUrl, runtimeRequest, takeFlag, takeOptionWithSyntax, type RuntimeApiDeps } from "./runtime-api";
|
|
5
|
+
|
|
6
|
+
export const SYSTEM_PARITY_OPTIONS = ["--show-codex-credits", "--account-picker", "--main-account-hard-lock", "--ultra-fast-tier", "--fast-rows"] as const;
|
|
7
|
+
const BOOLEAN_FIELDS: Readonly<Record<string, string>> = {
|
|
8
|
+
"--show-codex-credits": "showCodexCredits", "--account-picker": "codexAccountPickerEnabled",
|
|
9
|
+
"--main-account-hard-lock": "codexMainAccountHardLock", "--ultra-fast-tier": "ultraFastTier", "--fast-rows": "fastRows",
|
|
10
|
+
"--auto-start": "codexAutoStart", "--desktop-authless": "codexDesktopAuthless", "--client-compaction": "codexClientCompaction",
|
|
11
|
+
};
|
|
12
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function boolean(raw: string, option: string): boolean {
|
|
16
|
+
if (["on", "true", "yes", "1", "enabled"].includes(raw.toLowerCase())) return true;
|
|
17
|
+
if (["off", "false", "no", "0", "disabled"].includes(raw.toLowerCase())) return false;
|
|
18
|
+
throw new CliUsageError(`${option} must be on or off`);
|
|
19
|
+
}
|
|
20
|
+
function desktopState(value: unknown): Record<string, unknown> | null {
|
|
21
|
+
if (!record(value) || typeof value.stored !== "boolean" || (value.effective !== null && typeof value.effective !== "boolean")
|
|
22
|
+
|| (value.inertReason !== undefined && value.inertReason !== "client_role" && value.inertReason !== "non_loopback_bind_requires_admission_token")) return null;
|
|
23
|
+
return { stored: value.stored, effective: value.effective, ...(value.inertReason ? { inertReason: value.inertReason } : {}) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** New-option writes; the original no-new-option settings output remains compatible. */
|
|
27
|
+
export async function handleSystemSettingsParity(argsInput: string[], deps: RuntimeApiDeps = {}): Promise<number> {
|
|
28
|
+
return runCatalogAction(async () => {
|
|
29
|
+
const args = [...argsInput], wantsJson = takeFlag(args, "--json");
|
|
30
|
+
const body: Record<string, unknown> = {};
|
|
31
|
+
for (const [option, field] of Object.entries(BOOLEAN_FIELDS)) {
|
|
32
|
+
const value = takeOptionWithSyntax(args, option)?.value;
|
|
33
|
+
if (value !== undefined) body[field] = boolean(value, option);
|
|
34
|
+
}
|
|
35
|
+
const mode = takeOptionWithSyntax(args, "--stream-mode")?.value;
|
|
36
|
+
if (mode !== undefined) {
|
|
37
|
+
if (!["auto", "legacy-tee", "eager-relay"].includes(mode)) throw new CliUsageError("Invalid --stream-mode");
|
|
38
|
+
body.streamMode = mode;
|
|
39
|
+
}
|
|
40
|
+
if (args.length || !Object.keys(body).length) throw new CliUsageError("Provide supported system setting options and optional --json");
|
|
41
|
+
const pinned = { ...deps, baseUrl: await runtimeBaseUrl(deps) };
|
|
42
|
+
const response = await runtimeRequest("/api/settings", { method: "PUT", redirect: "error", body: serializeManagementJson(body) }, pinned);
|
|
43
|
+
if (!record(response) || response.ok !== true) throw new Error("Invalid settings receipt");
|
|
44
|
+
const observed: Record<string, unknown> = {};
|
|
45
|
+
const unverified: string[] = [];
|
|
46
|
+
for (const [key, requested] of Object.entries(body)) {
|
|
47
|
+
if (key === "ultraFastTier") continue;
|
|
48
|
+
const actual = response[key];
|
|
49
|
+
if (typeof actual === typeof requested && (key !== "streamMode" || ["auto", "legacy-tee", "eager-relay"].includes(actual as string))) observed[key] = actual;
|
|
50
|
+
if (actual !== requested) unverified.push(key);
|
|
51
|
+
}
|
|
52
|
+
if (Object.hasOwn(body, "ultraFastTier")) {
|
|
53
|
+
try {
|
|
54
|
+
const readBack = await runtimeRequest("/api/settings", { redirect: "error" }, pinned);
|
|
55
|
+
if (record(readBack) && typeof readBack.ultraFastTier === "boolean") observed.ultraFastTier = readBack.ultraFastTier;
|
|
56
|
+
} catch { /* Acceptance already happened; retain safe unknown verification, not raw error. */ }
|
|
57
|
+
if (observed.ultraFastTier !== body.ultraFastTier) unverified.push("ultraFastTier");
|
|
58
|
+
}
|
|
59
|
+
const data: Record<string, unknown> = { ok: true, settings: observed };
|
|
60
|
+
let nativePending = false;
|
|
61
|
+
if (Object.hasOwn(body, "codexDesktopAuthless") || Object.hasOwn(body, "codexClientCompaction")) {
|
|
62
|
+
const switches = record(response.codexDesktopSwitches) ? response.codexDesktopSwitches : {};
|
|
63
|
+
const report: Record<string, unknown> = {};
|
|
64
|
+
for (const key of ["codexDesktopAuthless", "codexClientCompaction"]) {
|
|
65
|
+
if (!Object.hasOwn(body, key)) continue;
|
|
66
|
+
const state = desktopState(switches[key]);
|
|
67
|
+
if (state) report[key] = state; else unverified.push(`${key}.effective`);
|
|
68
|
+
}
|
|
69
|
+
const apply = projectSettingsApply(switches.apply);
|
|
70
|
+
if (apply) {
|
|
71
|
+
report.apply = apply;
|
|
72
|
+
nativePending = !apply.applied && apply.reason !== "not_requested";
|
|
73
|
+
} else unverified.push("desktopApply");
|
|
74
|
+
data.desktop = report;
|
|
75
|
+
}
|
|
76
|
+
if (unverified.length) { data.verification = "unverified"; data.unverifiedFields = unverified; }
|
|
77
|
+
const lines = ["System settings accepted.", ...Object.entries(observed).map(([key, value]) => `${key}: ${value}`)];
|
|
78
|
+
if (unverified.length) lines.push(`Read-back is unverified for: ${unverified.join(", ")}.`);
|
|
79
|
+
if (nativePending) lines.push("Native client application is deferred or refused; the settings remain saved.");
|
|
80
|
+
const pendingCode = printSettingsResult(data, response.catalogRefreshPending, wantsJson, lines);
|
|
81
|
+
return unverified.length || nativePending ? 1 : pendingCode;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RuntimePortState } from "../config/process-state";
|
|
2
|
+
import type { LiveProxy } from "../server/proxy-liveness";
|
|
3
|
+
|
|
4
|
+
import type { UpdateRestartHome } from "./update-restart-home";
|
|
5
|
+
|
|
6
|
+
export interface UpdateRestartCandidate {
|
|
7
|
+
home: UpdateRestartHome;
|
|
8
|
+
target: LiveProxy & { pid: number };
|
|
9
|
+
runtime: RuntimePortState & { attestationSecret: string };
|
|
10
|
+
cliVersion: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Private in-process evidence; diagnostics and JSON never expose the proof key. */
|
|
14
|
+
export class UpdateRestartRequired extends Error {
|
|
15
|
+
#candidate: UpdateRestartCandidate;
|
|
16
|
+
constructor(candidate: UpdateRestartCandidate) {
|
|
17
|
+
super("restart_version_skew_cli_newer");
|
|
18
|
+
this.#candidate = candidate;
|
|
19
|
+
}
|
|
20
|
+
candidate(): UpdateRestartCandidate { return this.#candidate; }
|
|
21
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { getRuntimePortPath, readRuntimePort } from "../config/process-state";
|
|
3
|
+
import { packageVersion } from "../lib/package-version";
|
|
4
|
+
import { parseStrictSemver } from "../lib/strict-semver";
|
|
5
|
+
import { acquireOwnershipMutationLease, OWNERSHIP_MUTATION_LEASE_TOKEN_ENV } from "../service/ownership-mutation-lease.mjs";
|
|
6
|
+
import { serviceStatePaths } from "../service/state";
|
|
7
|
+
import { assertUpdateRestartConfiguration, assertUpdateRestartHome, type UpdateRestartHome } from "./update-restart-home";
|
|
8
|
+
|
|
9
|
+
export const UPDATE_RESTART_CHILD_ENV = "OCX_UPDATE_RESTART_CHILD";
|
|
10
|
+
export interface UpdateRestartChildMarker { home: UpdateRestartHome; version: string; port: number; hostname: string; deadlineAt: number }
|
|
11
|
+
export interface UpdateRestartChildGuard { check(port?: number, hostname?: string): void; complete(): void }
|
|
12
|
+
|
|
13
|
+
function parseMarker(raw: string): UpdateRestartChildMarker {
|
|
14
|
+
if (raw.length > 8192) throw new Error("update_restart_child_marker_invalid");
|
|
15
|
+
const value = JSON.parse(raw) as UpdateRestartChildMarker;
|
|
16
|
+
if (!value || !Number.isSafeInteger(value.deadlineAt) || !Number.isInteger(value.port)
|
|
17
|
+
|| typeof value.hostname !== "string" || value.port < 1 || value.port > 65535 || typeof value.version !== "string"
|
|
18
|
+
|| value.version === "0.0.0" || !parseStrictSemver(value.version) || !value.home
|
|
19
|
+
|| !Number.isSafeInteger(value.home.revision)) throw new Error("update_restart_child_marker_invalid");
|
|
20
|
+
for (const directory of [value.home.config, value.home.codex]) {
|
|
21
|
+
if (!directory || typeof directory.path !== "string" || !directory.path
|
|
22
|
+
|| !Number.isSafeInteger(directory.dev) || !Number.isSafeInteger(directory.ino)) {
|
|
23
|
+
throw new Error("update_restart_child_marker_invalid");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UpdateRestartChildIo {
|
|
30
|
+
env?: NodeJS.ProcessEnv;
|
|
31
|
+
now?: () => number;
|
|
32
|
+
version?: () => string;
|
|
33
|
+
checkHome?: (home: UpdateRestartHome) => void;
|
|
34
|
+
acquire?: (waitMs: number) => { release(): void };
|
|
35
|
+
onExit?: (release: () => void) => void;
|
|
36
|
+
checkState?: (marker: UpdateRestartChildMarker) => void;
|
|
37
|
+
armDeadline?: (remainingMs: number) => () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Admit the replacement before CLI preflight; custody belongs to this child PID. */
|
|
41
|
+
export function admitUpdateRestartChild(argv: string[], io: UpdateRestartChildIo = {}): UpdateRestartChildGuard | undefined {
|
|
42
|
+
const env = io.env ?? process.env;
|
|
43
|
+
const raw = env[UPDATE_RESTART_CHILD_ENV];
|
|
44
|
+
if (raw === undefined) return undefined;
|
|
45
|
+
delete env[UPDATE_RESTART_CHILD_ENV];
|
|
46
|
+
delete env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV];
|
|
47
|
+
const marker = parseMarker(raw);
|
|
48
|
+
if (argv.length !== 3 || argv[0] !== "start" || argv[1] !== "--port" || argv[2] !== String(marker.port)) {
|
|
49
|
+
throw new Error("update_restart_child_command_changed");
|
|
50
|
+
}
|
|
51
|
+
const now = io.now ?? Date.now;
|
|
52
|
+
const check = (port = marker.port, hostname = marker.hostname) => {
|
|
53
|
+
if (now() >= marker.deadlineAt) throw new Error("update_restart_deadline_expired");
|
|
54
|
+
const bindHost = (host: string) => !host.trim() || /^localhost\.?$/i.test(host.trim()) ? "127.0.0.1" : host.trim();
|
|
55
|
+
if (port !== marker.port || bindHost(hostname) !== bindHost(marker.hostname) || (io.version ?? packageVersion)() !== marker.version) throw new Error("update_restart_child_identity_changed");
|
|
56
|
+
(io.checkHome ?? assertUpdateRestartHome)(marker.home);
|
|
57
|
+
(io.checkState ?? (expected => {
|
|
58
|
+
assertUpdateRestartConfiguration(expected.hostname);
|
|
59
|
+
const current = readRuntimePort();
|
|
60
|
+
if (existsSync(getRuntimePortPath()) && (!current || current.pid !== process.pid
|
|
61
|
+
|| current.port !== expected.port || current.siblingOfPort !== undefined)) {
|
|
62
|
+
throw new Error("update_restart_competing_runtime");
|
|
63
|
+
}
|
|
64
|
+
}))(marker);
|
|
65
|
+
};
|
|
66
|
+
check();
|
|
67
|
+
const lease = (io.acquire ?? (waitMs => acquireOwnershipMutationLease(serviceStatePaths(), { waitMs })))(Math.min(2000, marker.deadlineAt - now()));
|
|
68
|
+
let released = false;
|
|
69
|
+
let cancelDeadline: (() => void) | undefined;
|
|
70
|
+
const release = () => { if (!released) { released = true; cancelDeadline?.(); lease.release(); } };
|
|
71
|
+
try {
|
|
72
|
+
check();
|
|
73
|
+
cancelDeadline = (io.armDeadline ?? (remainingMs => {
|
|
74
|
+
const timer = setTimeout(() => process.exit(1), remainingMs);
|
|
75
|
+
timer.unref();
|
|
76
|
+
return () => clearTimeout(timer);
|
|
77
|
+
}))(marker.deadlineAt - now());
|
|
78
|
+
(io.onExit ?? (callback => { process.once("exit", callback); }))(release);
|
|
79
|
+
return { check, complete: () => { check(); release(); } };
|
|
80
|
+
} catch (error) { release(); throw error; }
|
|
81
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { readClientConnectionState } from "../client/state";
|
|
3
|
+
import { readConfigDiagnostics } from "../config/diagnostics";
|
|
4
|
+
import { getConfigDir } from "../config/paths";
|
|
5
|
+
import { currentCodexHome, resolveServiceOwnership, resolveServiceState } from "../service/state";
|
|
6
|
+
|
|
7
|
+
export interface UpdateRestartHome {
|
|
8
|
+
config: { path: string; dev: number; ino: number };
|
|
9
|
+
codex: { path: string; dev: number; ino: number };
|
|
10
|
+
revision: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function physicalDirectory(path: string): UpdateRestartHome["config"] {
|
|
14
|
+
const canonical = realpathSync.native(path);
|
|
15
|
+
const stat = statSync(canonical);
|
|
16
|
+
if (!stat.isDirectory()) throw new Error("update_restart_home_unverified");
|
|
17
|
+
return { path: canonical, dev: stat.dev, ino: stat.ino };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Only an unclaimed standalone home is eligible for this narrow update path. */
|
|
21
|
+
export function readUpdateRestartHome(): UpdateRestartHome {
|
|
22
|
+
const state = resolveServiceState();
|
|
23
|
+
const owner = resolveServiceOwnership();
|
|
24
|
+
if (state.kind !== "none" || owner.kind !== "none") throw new Error("update_restart_owner_unverified");
|
|
25
|
+
return { config: physicalDirectory(getConfigDir()), codex: physicalDirectory(currentCodexHome()), revision: owner.revision };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertUpdateRestartHome(expected: UpdateRestartHome): void {
|
|
29
|
+
const current = readUpdateRestartHome();
|
|
30
|
+
if (JSON.stringify(current) !== JSON.stringify(expected)) throw new Error("update_restart_home_changed");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read-only eligibility shared by parent pre-stop and child admission. */
|
|
34
|
+
export function assertUpdateRestartConfiguration(hostname: string): void {
|
|
35
|
+
const diagnostics = readConfigDiagnostics();
|
|
36
|
+
if (readClientConnectionState().kind !== "disconnected" || diagnostics.error
|
|
37
|
+
|| (diagnostics.config.hostname ?? "") !== hostname) throw new Error("update_restart_configuration_changed");
|
|
38
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { Agent, request, type ClientRequest, type IncomingMessage } from "node:http";
|
|
2
|
+
import { connect, type Socket } from "node:net";
|
|
3
|
+
import type { Duplex } from "node:stream";
|
|
4
|
+
import {
|
|
5
|
+
LOCAL_ATTESTATION_CHALLENGE_HEADER,
|
|
6
|
+
LOCAL_ATTESTATION_PROOF_HEADER,
|
|
7
|
+
createLocalAttestationChallenge,
|
|
8
|
+
isLocalAttestationSecret,
|
|
9
|
+
verifyLocalAttestationProof,
|
|
10
|
+
} from "../lib/local-management-attestation";
|
|
11
|
+
import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract";
|
|
12
|
+
import { isOpencodexHealthz, probeHostname, type LiveProxy } from "../server/proxy-liveness";
|
|
13
|
+
import { computeVersionSkew } from "./version-skew";
|
|
14
|
+
|
|
15
|
+
const FAILURE = "update_restart_stop_failed";
|
|
16
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
17
|
+
|
|
18
|
+
/** This agent owns exactly one direct TCP connection, including on Bun's HTTP layer. */
|
|
19
|
+
class AttestedAgent extends Agent {
|
|
20
|
+
socket?: Socket;
|
|
21
|
+
private connected = false;
|
|
22
|
+
|
|
23
|
+
constructor(private readonly hostname: string, private readonly port: number) {
|
|
24
|
+
super({ keepAlive: true, maxSockets: 1, maxTotalSockets: 1, maxFreeSockets: 1 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
override createConnection(
|
|
28
|
+
_options: unknown,
|
|
29
|
+
callback?: (error: Error | null, stream: Duplex) => void,
|
|
30
|
+
): Socket | undefined {
|
|
31
|
+
if (this.connected) {
|
|
32
|
+
// Returning no socket with an error also refuses queued reconnect attempts.
|
|
33
|
+
callback?.(new Error(FAILURE), this.socket!);
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
this.connected = true;
|
|
37
|
+
this.socket = connect({ host: this.hostname, port: this.port, autoSelectFamily: false });
|
|
38
|
+
return this.socket;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function checkDeadline(deadlineAt: number): void {
|
|
43
|
+
if (!Number.isFinite(deadlineAt) || Date.now() >= deadlineAt) throw new Error(FAILURE);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readJson(response: IncomingMessage): Promise<Record<string, unknown>> {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const chunks: Buffer[] = [];
|
|
49
|
+
let size = 0;
|
|
50
|
+
const fail = () => { reject(new Error(FAILURE)); response.destroy(); };
|
|
51
|
+
response.on("error", fail);
|
|
52
|
+
response.on("aborted", fail);
|
|
53
|
+
response.on("data", (chunk: Buffer) => {
|
|
54
|
+
size += chunk.length;
|
|
55
|
+
if (size > MAX_RESPONSE_BYTES) { fail(); return; }
|
|
56
|
+
chunks.push(chunk);
|
|
57
|
+
});
|
|
58
|
+
response.on("end", () => {
|
|
59
|
+
try {
|
|
60
|
+
const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
61
|
+
if (!response.complete || value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
62
|
+
throw new Error(FAILURE);
|
|
63
|
+
}
|
|
64
|
+
resolve(value as Record<string, unknown>);
|
|
65
|
+
} catch { reject(new Error(FAILURE)); }
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type AttestedTarget = { target: LiveProxy & { pid: number }; secret: string; deadlineAt: number };
|
|
71
|
+
type JsonReply = { response: IncomingMessage; body: Record<string, unknown> };
|
|
72
|
+
|
|
73
|
+
class AttestedSession {
|
|
74
|
+
readonly agent: AttestedAgent;
|
|
75
|
+
readonly challenge = createLocalAttestationChallenge();
|
|
76
|
+
private readonly hostname: string;
|
|
77
|
+
private readonly timer: ReturnType<typeof setTimeout>;
|
|
78
|
+
private current?: ClientRequest;
|
|
79
|
+
|
|
80
|
+
constructor(private readonly options: AttestedTarget) {
|
|
81
|
+
const { target, secret, deadlineAt } = options;
|
|
82
|
+
checkDeadline(deadlineAt);
|
|
83
|
+
if (target.source !== "runtime" || target.role !== undefined || target.packageTreeFenced
|
|
84
|
+
|| !Number.isSafeInteger(target.pid) || target.pid <= 0
|
|
85
|
+
|| !Number.isInteger(target.port) || target.port < 1 || target.port > 65535
|
|
86
|
+
|| !isLocalAttestationSecret(secret)) throw new Error(FAILURE);
|
|
87
|
+
const host = probeHostname(target.hostname);
|
|
88
|
+
this.hostname = host.startsWith("[") ? host.slice(1, -1) : host;
|
|
89
|
+
this.agent = new AttestedAgent(this.hostname, target.port);
|
|
90
|
+
this.timer = setTimeout(() => {
|
|
91
|
+
this.current?.destroy(new Error(FAILURE));
|
|
92
|
+
this.agent.socket?.destroy(new Error(FAILURE));
|
|
93
|
+
}, Math.min(deadlineAt - Date.now(), 2_147_483_647));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
exchange(adminToken?: string): Promise<JsonReply> {
|
|
97
|
+
const stop = adminToken !== undefined;
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const req = request({
|
|
100
|
+
hostname: this.hostname, port: this.options.target.port, agent: this.agent,
|
|
101
|
+
method: stop ? "POST" : "GET", path: stop ? "/api/stop" : "/healthz",
|
|
102
|
+
maxHeaderSize: MAX_RESPONSE_BYTES,
|
|
103
|
+
headers: stop ? { "content-length": "0" } : { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: this.challenge },
|
|
104
|
+
});
|
|
105
|
+
this.current = req;
|
|
106
|
+
req.on("error", reject);
|
|
107
|
+
req.on("response", response => {
|
|
108
|
+
readJson(response).then(body => resolve({ response, body }), reject);
|
|
109
|
+
});
|
|
110
|
+
req.once("socket", (socket: Socket) => {
|
|
111
|
+
try {
|
|
112
|
+
checkDeadline(this.options.deadlineAt);
|
|
113
|
+
if (socket !== this.agent.socket || socket.destroyed || !socket.writable
|
|
114
|
+
|| (stop && (socket.connecting || socket.readableEnded || socket.writableEnded))) {
|
|
115
|
+
throw new Error(FAILURE);
|
|
116
|
+
}
|
|
117
|
+
// No end/flushHeaders/write, or credential header, before this socket check.
|
|
118
|
+
if (adminToken !== undefined) req.setHeader("X-OpenCodex-API-Key", adminToken);
|
|
119
|
+
req.end();
|
|
120
|
+
} catch { req.destroy(new Error(FAILURE)); }
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
validateHealth(health: JsonReply): void {
|
|
126
|
+
const { target, secret, deadlineAt } = this.options;
|
|
127
|
+
const proof = health.response.headers[LOCAL_ATTESTATION_PROOF_HEADER];
|
|
128
|
+
checkDeadline(deadlineAt);
|
|
129
|
+
if (health.response.statusCode !== 200 || !isOpencodexHealthz(health.body)
|
|
130
|
+
|| health.body.status !== "ok" || health.body.role !== undefined
|
|
131
|
+
|| health.body.pid !== target.pid || health.body.port !== target.port
|
|
132
|
+
|| !verifyLocalAttestationProof(secret, this.challenge, target.pid, target.port, typeof proof === "string" ? proof : null)) {
|
|
133
|
+
throw new Error(FAILURE);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
close(): void {
|
|
138
|
+
clearTimeout(this.timer);
|
|
139
|
+
this.current?.destroy();
|
|
140
|
+
this.agent.destroy();
|
|
141
|
+
this.agent.socket?.destroy();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Proof and credential delivery must remain on one established channel; never retry. */
|
|
146
|
+
export async function stopAttestedUpdateTarget(options: AttestedTarget & {
|
|
147
|
+
cliVersion: string;
|
|
148
|
+
beforeStop: () => void;
|
|
149
|
+
adminToken: string;
|
|
150
|
+
}): Promise<void> {
|
|
151
|
+
let session: AttestedSession | undefined;
|
|
152
|
+
try {
|
|
153
|
+
const { cliVersion, deadlineAt, beforeStop, adminToken } = options;
|
|
154
|
+
if (!adminToken) throw new Error(FAILURE);
|
|
155
|
+
session = new AttestedSession(options);
|
|
156
|
+
const health = await session.exchange();
|
|
157
|
+
session.validateHealth(health);
|
|
158
|
+
if (health.body.restartCapability !== SYSTEM_RESTART_CAPABILITY_VERSION
|
|
159
|
+
|| computeVersionSkew(cliVersion, typeof health.body.version === "string" ? health.body.version : undefined).relation !== "cli-newer"
|
|
160
|
+
|| /\bclose\b/i.test(health.response.headers.connection ?? "")) throw new Error(FAILURE);
|
|
161
|
+
checkDeadline(deadlineAt);
|
|
162
|
+
beforeStop();
|
|
163
|
+
// Synchronous candidate revalidation and POST enqueue: no intervening await.
|
|
164
|
+
const stopped = await session.exchange(adminToken);
|
|
165
|
+
checkDeadline(deadlineAt);
|
|
166
|
+
if (stopped.response.statusCode !== 200 || stopped.body.success !== true
|
|
167
|
+
|| stopped.body.sharedTeardown !== "performed") throw new Error(FAILURE);
|
|
168
|
+
} catch {
|
|
169
|
+
// Never expose parser/network/callback errors (which may contain credentials).
|
|
170
|
+
throw new Error(FAILURE);
|
|
171
|
+
} finally {
|
|
172
|
+
session?.close();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Observe only the caller's exact newly started runtime; ordinary liveness is not proof. */
|
|
177
|
+
export async function observeAttestedUpdateReplacement(options: AttestedTarget & {
|
|
178
|
+
version: string;
|
|
179
|
+
}): Promise<LiveProxy> {
|
|
180
|
+
let session: AttestedSession | undefined;
|
|
181
|
+
try {
|
|
182
|
+
session = new AttestedSession(options);
|
|
183
|
+
const health = await session.exchange();
|
|
184
|
+
session.validateHealth(health);
|
|
185
|
+
if (health.body.version !== options.version) throw new Error(FAILURE);
|
|
186
|
+
return { pid: options.target.pid, port: options.target.port, hostname: options.target.hostname,
|
|
187
|
+
source: "runtime", version: options.version };
|
|
188
|
+
} catch {
|
|
189
|
+
throw new Error("update_restart_replacement_observation_failed");
|
|
190
|
+
} finally {
|
|
191
|
+
session?.close();
|
|
192
|
+
}
|
|
193
|
+
}
|