@bitkyc08/opencodex 2.81.0 → 2.83.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 +166 -73
- package/gui/dist/assets/App-Ba8uG696.js +54 -0
- package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-IImbNPRy.js} +1 -1
- package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
- package/gui/dist/assets/{index-RsoaAxXY.js → index-BHRZdJuv.js} +13 -13
- package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DiJO6sYY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/cursor/catalog.ts +2 -2
- package/src/adapters/cursor/effort-map.ts +1 -1
- package/src/adapters/devin/live-models.ts +2 -2
- package/src/adapters/google-truncation.ts +22 -0
- package/src/adapters/google.ts +7 -3
- package/src/adapters/ollama-native.ts +18 -14
- package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
- package/src/adapters/openai-responses/passthrough.ts +11 -4
- package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
- package/src/adapters/openai-responses/reasoning.ts +11 -1
- package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
- package/src/bridge/errors.ts +7 -1
- package/src/claude/agents-inject.ts +14 -3
- package/src/claude/context-windows.ts +22 -20
- package/src/claude/desktop-3p.ts +88 -12
- package/src/claude/desktop-picker.ts +1 -1
- package/src/claude/desktop-profile.ts +2 -1
- package/src/claude/gateway-cache.ts +18 -1
- package/src/claude/inbound.ts +77 -3
- package/src/claude/intercept/cli-catalog.ts +3 -0
- package/src/claude/intercept/picker-ca-persistence.ts +7 -6
- package/src/claude/intercept/picker-models.ts +14 -6
- package/src/claude/intercept/settings.ts +3 -0
- package/src/claude/long-context.ts +66 -0
- package/src/claude/model-info.ts +19 -16
- package/src/claude/outbound.ts +94 -9
- package/src/claude/subagent-model.ts +40 -11
- package/src/cli/account-auth.ts +20 -12
- package/src/cli/agent.ts +9 -5
- package/src/cli/aside-profile-recovery.ts +33 -0
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-integrations.ts +2 -2
- package/src/cli/cli-path-diagnostics.ts +132 -0
- package/src/cli/desktop-runtime-guidance.ts +65 -0
- package/src/cli/dispatch.ts +5 -0
- package/src/cli/doctor.ts +16 -3
- package/src/cli/index.ts +14 -9
- package/src/cli/integration-aside-sync.ts +2 -1
- package/src/cli/integrations.ts +16 -3
- package/src/cli/resolve.ts +26 -3
- package/src/cli/root.ts +4 -2
- package/src/cli/status.ts +64 -12
- package/src/cli/update-restart-child.ts +4 -2
- package/src/cli/update-restart-home.ts +28 -10
- package/src/cli/update-restart-service-record.ts +116 -0
- package/src/cli/update-restart-supervision.ts +96 -0
- package/src/cli/update-restart.ts +59 -13
- package/src/cli/v2.ts +27 -20
- package/src/cli/version-skew-notice.ts +77 -0
- package/src/clients/config-export/model-metadata.ts +16 -0
- package/src/clients/config-export/zcode-store.ts +46 -11
- package/src/clients/config-export/zcode.ts +3 -5
- package/src/clients/config-export.ts +1 -1
- package/src/clients/lazycodex.ts +1 -1
- package/src/clients/omo-role-models.ts +32 -7
- package/src/codex/auth-api/account-selection.ts +107 -0
- package/src/codex/auth-api/pool-mode-gate.ts +4 -5
- package/src/codex/auth-api/routes.ts +10 -49
- package/src/codex/auth-context.ts +28 -4
- package/src/codex/autostart-health.ts +12 -3
- package/src/codex/catalog/build-entries.ts +18 -0
- package/src/codex/catalog/bundled.ts +155 -26
- package/src/codex/catalog/combo-member.ts +3 -2
- package/src/codex/catalog/effort.ts +26 -5
- package/src/codex/catalog/gather-capture.ts +5 -0
- package/src/codex/catalog/model-hints.ts +15 -8
- package/src/codex/catalog/model-visibility.ts +3 -1
- package/src/codex/catalog/native-models.ts +1 -1
- package/src/codex/catalog/operator-tiers.ts +38 -0
- package/src/codex/catalog/provider-models.ts +68 -35
- package/src/codex/catalog/retained-sync.ts +7 -5
- package/src/codex/catalog/routed-gather.ts +15 -1
- package/src/codex/catalog-auto-refresh-sources.ts +3 -4
- package/src/codex/catalog-auto-refresh.ts +19 -0
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-home-owner.ts +3 -0
- package/src/codex/config-write-lock.ts +333 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/desktop-app/darwin.ts +1 -1
- package/src/codex/desktop-app/handoff.ts +1 -1
- package/src/codex/desktop-app/linux.ts +1 -1
- package/src/codex/desktop-app/lock.ts +1 -1
- package/src/codex/desktop-app/types.ts +1 -1
- package/src/codex/desktop-app/windows.ts +1 -1
- package/src/codex/desktop-app-restart.ts +1 -1
- package/src/codex/features.ts +257 -200
- package/src/codex/inject/config-write-section.ts +34 -0
- package/src/codex/inject/multi-agent-v2.ts +14 -6
- package/src/codex/inject/remove.ts +36 -11
- package/src/codex/inject/restore.ts +85 -20
- package/src/codex/inject-coordination.ts +15 -6
- package/src/codex/inject.ts +62 -20
- package/src/codex/journal.ts +62 -17
- package/src/codex/main-account-cache.ts +43 -0
- package/src/codex/main-account.ts +6 -7
- package/src/codex/paths.ts +2 -0
- package/src/codex/prepared-home.ts +18 -0
- package/src/codex/prompt-journal.ts +25 -12
- package/src/codex/prompt-layers.ts +49 -26
- package/src/codex/prompt-lock-claim.ts +134 -0
- package/src/codex/prompt-lock-evidence.ts +26 -0
- package/src/codex/prompt-lock-io.ts +16 -0
- package/src/codex/prompt-lock-owner.ts +130 -0
- package/src/codex/prompt-lock.ts +84 -53
- package/src/codex/routing/failure-window.ts +146 -0
- package/src/codex/routing/selection.ts +4 -1
- package/src/codex/routing.ts +13 -17
- package/src/codex/runtime.ts +106 -18
- package/src/codex/shim.ts +45 -19
- package/src/combos/jev-service-exchange.ts +281 -0
- package/src/combos/jev.ts +34 -220
- package/src/config/atomic-write.ts +1 -1
- package/src/config/codex-ws-reuse-setting.ts +10 -0
- package/src/config/live-reconcile.ts +17 -0
- package/src/config/load-degrade.ts +8 -0
- package/src/config/rebase-provenance.ts +11 -0
- package/src/config/schema/config-schema.ts +4 -0
- package/src/generated/compatibility-version.json +375 -227
- package/src/generated/model-metadata.ts +2 -2
- package/src/grok/inject.ts +276 -206
- package/src/grok/reset-coupon-ledger.ts +223 -57
- package/src/grok/reset-coupons.ts +13 -6
- package/src/grok/sync.ts +2 -1
- package/src/images/loop.ts +29 -11
- package/src/integrations/aside-profiles.ts +10 -3
- package/src/integrations/catalog-refresh.ts +18 -2
- package/src/integrations/cursor-detect.ts +1 -1
- package/src/integrations/writer.ts +9 -3
- package/src/lib/bun-path-runtime.d.mts +32 -0
- package/src/lib/bun-path-runtime.mjs +95 -0
- package/src/lib/bun-runtime-preflight.ts +140 -0
- package/src/lib/desktop-cli-handoff.d.mts +20 -0
- package/src/lib/desktop-cli-handoff.mjs +92 -0
- package/src/lib/desktop-cli-record.d.mts +29 -0
- package/src/lib/desktop-cli-record.mjs +102 -0
- package/src/lib/errors.ts +25 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/request-execution-budget.ts +8 -0
- package/src/lib/synced-state-location.ts +46 -6
- package/src/lib/test-home-guard.ts +87 -1
- package/src/lib/translator-budget.ts +17 -4
- package/src/lib/windows-atomic-replace.ts +38 -16
- package/src/lib/windows-elevation.ts +6 -0
- package/src/lib/windows-secret-acl.ts +92 -25
- package/src/lib/winsw.ts +2 -3
- package/src/oauth/anthropic-account-refusal.ts +39 -1
- package/src/oauth/anthropic-routing.ts +1 -1
- package/src/oauth/anthropic-send-ownership.ts +3 -3
- package/src/oauth/chatgpt.ts +113 -41
- package/src/oauth/index.ts +7 -3
- package/src/oauth/meta-muse-device.ts +1 -1
- package/src/oauth/store.ts +1 -1
- package/src/oauth/types.ts +1 -1
- package/src/providers/anthropic-fast.ts +1 -1
- package/src/providers/anthropic-reset-grant-ledger.ts +1 -1
- package/src/providers/azure-model-metadata.ts +115 -0
- package/src/providers/derive.ts +21 -0
- package/src/providers/devin-cli-authmode-migration.ts +1 -1
- package/src/providers/devin-provider-merge-migration.ts +1 -1
- package/src/providers/github-copilot-auto.ts +169 -0
- package/src/providers/model-presets.ts +1 -1
- package/src/providers/muse-subscription-usage.ts +1 -1
- package/src/providers/quota/vendor-probes-key.ts +127 -17
- package/src/providers/quota.ts +1 -1
- package/src/providers/registry/entries-core.ts +6 -6
- package/src/providers/registry/entries-extended.ts +4 -0
- package/src/providers/registry/model-seeds.ts +3 -3
- package/src/providers/xai-fast-model.ts +1 -1
- package/src/responses/reasoning-envelope.ts +29 -1
- package/src/responses/reasoning-replay-cache.ts +17 -1
- package/src/responses/spill-store.ts +15 -2
- package/src/responses/state/metrics.ts +8 -0
- package/src/responses/state/spill-failure.ts +12 -0
- package/src/responses/state/spill-queue.ts +12 -5
- package/src/responses/state.ts +18 -10
- package/src/responses/task-input.ts +17 -0
- package/src/server/background-lifecycle.ts +27 -0
- package/src/server/chat-completions.ts +1 -0
- package/src/server/chat-native.ts +28 -12
- package/src/server/claude-messages.ts +43 -6
- package/src/server/inbound-body-admission.ts +25 -7
- package/src/server/index/claude-intercept-lifecycle.ts +3 -1
- package/src/server/index/link-listener.ts +30 -17
- package/src/server/index/serve-options.ts +18 -4
- package/src/server/index/spend-ledger-lifecycle.ts +2 -1
- package/src/server/index/startup-warnings.ts +19 -0
- package/src/server/index.ts +2 -1
- package/src/server/lifecycle.ts +31 -4
- package/src/server/management/agent-settings-routes.ts +93 -32
- package/src/server/management/codex-agent-role-routes.ts +4 -2
- package/src/server/management/codex-prompt-routes.ts +1 -0
- package/src/server/management/codex-role-auto-assign.ts +13 -2
- package/src/server/management/config-routes.ts +59 -8
- package/src/server/management/context.ts +2 -2
- package/src/server/management/grok-coupon-routes.ts +277 -52
- package/src/server/management/link-routes.ts +42 -20
- package/src/server/management/route-registry.ts +2 -2
- package/src/server/management/shared.ts +3 -3
- package/src/server/messages-native-oauth.ts +9 -8
- package/src/server/messages-native.ts +49 -13
- package/src/server/messages-response-headers.ts +14 -0
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +18 -7
- package/src/server/request-log-conversation.ts +48 -5
- package/src/server/request-log-failure-attribution.ts +3 -0
- package/src/server/request-log-terminal-status.ts +186 -0
- package/src/server/request-log.ts +15 -89
- package/src/server/responses/adapter-continuation.ts +11 -3
- package/src/server/responses/adapter-dispatch.ts +99 -19
- package/src/server/responses/codex-ws-exchange.ts +17 -2
- package/src/server/responses/codex-ws-pool.ts +100 -13
- package/src/server/responses/codex-ws-wire.ts +9 -0
- package/src/server/responses/combo-requested-effort.ts +3 -1
- package/src/server/responses/compact.ts +3 -2
- package/src/server/responses/core-codex-account.ts +22 -1
- package/src/server/responses/core-combo-native.ts +20 -3
- package/src/server/responses/core-combo.ts +22 -14
- package/src/server/responses/core-lifetime.ts +3 -36
- package/src/server/responses/core-opaque-recovery.ts +5 -5
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +73 -0
- package/src/server/responses/core.ts +9 -9
- package/src/server/responses/empty-completion-guard.ts +18 -2
- package/src/server/responses/fetch-helpers.ts +26 -3
- package/src/server/responses/jev-model-invoke.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +24 -2
- package/src/server/responses/passthrough-dispatch.ts +134 -62
- package/src/server/responses/passthrough-execution.ts +3 -3
- package/src/server/responses/request-prepare.ts +4 -0
- package/src/server/responses/request-send-budget.ts +112 -49
- package/src/server/responses/request-transport.ts +125 -5
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/run-turn-execution.ts +15 -5
- package/src/server/responses/sidecar-execution.ts +30 -16
- package/src/server/responses/sidecar-send-budget.ts +58 -0
- package/src/server/responses/spend-storage-error.ts +33 -0
- package/src/server/responses/ws-upstream.ts +19 -6
- package/src/server/startup-health-cache.ts +2 -1
- package/src/server/workflow-refusal.ts +3 -0
- package/src/service/cli.ts +20 -4
- package/src/service/desktop-command-guard.ts +32 -0
- package/src/service/desktop-startup.ts +66 -40
- package/src/service/desktop-supervision.d.mts +26 -0
- package/src/service/desktop-supervision.mjs +140 -0
- package/src/service/managing-cli.ts +1 -0
- package/src/service/orchestration.ts +46 -20
- package/src/service/repair.ts +25 -9
- package/src/service/state.ts +4 -3
- package/src/service/systemd.ts +17 -8
- package/src/service/windows-ops.ts +61 -22
- package/src/storage/policy-job.ts +5 -1
- package/src/storage/policy-worker.ts +3 -1
- package/src/storage/policy.ts +9 -0
- package/src/types/config.ts +21 -0
- package/src/types/request.ts +10 -0
- package/src/types/wire.ts +1 -0
- package/src/update/index.ts +57 -9
- package/src/update/restart-ownership.ts +10 -3
- package/src/update/runtime-ownership.d.mts +6 -0
- package/src/update/runtime-ownership.mjs +23 -7
- package/src/usage/expected-prices.ts +1 -1
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-7yhoys8a.js +0 -54
package/src/cli/integrations.ts
CHANGED
|
@@ -13,13 +13,14 @@ import {
|
|
|
13
13
|
type RuntimeApiDeps,
|
|
14
14
|
} from "./runtime-api";
|
|
15
15
|
import { clientIntegrationPath, validateAsideProfile } from "./integration-input";
|
|
16
|
+
import { asideProfileRecoveryLines } from "./aside-profile-recovery";
|
|
16
17
|
import type { IntegrationClientId } from "../integrations/registry";
|
|
17
18
|
|
|
18
19
|
const CLAUDE_USAGE = `Usage:
|
|
19
20
|
ocx claude config [status] [--json]
|
|
20
21
|
ocx claude config set [--enabled <on|off>] [--auth-mode <auto|proxy|subscription>]
|
|
21
22
|
[--system-env <on|off>] [--fast-mode <on|off>] [--auto-context <on|off>]
|
|
22
|
-
[--compact-window <tokens|default>] [--inject-agents <on|off>]
|
|
23
|
+
[--compact-window <tokens|default>] [--context-accounting <1m|200k>] [--inject-agents <on|off>]
|
|
23
24
|
[--small-fast-model <id|->] [--model-map <from=to,from=to|->]
|
|
24
25
|
[--blocked-skills <name,name|->] [--web-model <id|->] [--web-backend <openai|anthropic|xai|gemini|exa|->]
|
|
25
26
|
[--vision-model <id|->] [--vision-backend <openai|anthropic|->] [--json]
|
|
@@ -75,6 +76,7 @@ export async function handleClaudeConfigCommand(argv: string[], deps: RuntimeApi
|
|
|
75
76
|
const fastMode = takeBooleanOption(args, "--fast-mode");
|
|
76
77
|
const autoContext = takeBooleanOption(args, "--auto-context");
|
|
77
78
|
const compact = takeOption(args, "--compact-window");
|
|
79
|
+
const accounting = takeOption(args, "--context-accounting");
|
|
78
80
|
const injectAgents = takeBooleanOption(args, "--inject-agents");
|
|
79
81
|
const smallFastModel = takeOption(args, "--small-fast-model");
|
|
80
82
|
const modelMap = takeOption(args, "--model-map");
|
|
@@ -97,6 +99,11 @@ export async function handleClaudeConfigCommand(argv: string[], deps: RuntimeApi
|
|
|
97
99
|
body.autoCompactWindow = value;
|
|
98
100
|
}
|
|
99
101
|
}
|
|
102
|
+
if (accounting !== undefined) {
|
|
103
|
+
const value = accounting.toLowerCase();
|
|
104
|
+
if (value !== "1m" && value !== "200k") throw new CliUsageError("--context-accounting must be 1m or 200k", CLAUDE_USAGE);
|
|
105
|
+
body.contextAccounting = value;
|
|
106
|
+
}
|
|
100
107
|
if (injectAgents !== undefined) body.injectAgents = injectAgents;
|
|
101
108
|
if (smallFastModel !== undefined) body.smallFastModel = smallFastModel === "-" ? "" : smallFastModel;
|
|
102
109
|
if (modelMap !== undefined) body.modelMap = parseMap(modelMap);
|
|
@@ -194,7 +201,10 @@ function raycastBlock(result: unknown): RaycastStatusBlock | null {
|
|
|
194
201
|
*/
|
|
195
202
|
function singleClientStatusLines(result: unknown): string[] {
|
|
196
203
|
const raycast = raycastBlock(result);
|
|
197
|
-
if (!raycast)
|
|
204
|
+
if (!raycast) {
|
|
205
|
+
const aside = (result as { clientId?: unknown } | null)?.clientId === "aside";
|
|
206
|
+
return [...summaryLines(result), ...(aside ? asideProfileRecoveryLines([result]) : [])];
|
|
207
|
+
}
|
|
198
208
|
const rest = Object.fromEntries(Object.entries(result as Record<string, unknown>).filter(([key]) => key !== "raycast"));
|
|
199
209
|
const lines = [...summaryLines(rest), `plan: ${raycast.plan}`];
|
|
200
210
|
if (!raycast.aiDirPresent) {
|
|
@@ -252,7 +262,10 @@ export async function handleClientIntegrationCommand(
|
|
|
252
262
|
const profiles = (result as { profiles?: Array<Record<string, unknown>> }).profiles;
|
|
253
263
|
printData(result, wantsJson, profiles
|
|
254
264
|
? profiles.length > 0
|
|
255
|
-
?
|
|
265
|
+
? [
|
|
266
|
+
...profiles.map(row => `${String(row.profileId)} ${String(row.name ?? "Aside")}: ${row.enabled ? "on" : "off"} (${String(row.state)})${row.current ? " [current]" : ""}`),
|
|
267
|
+
...asideProfileRecoveryLines(profiles),
|
|
268
|
+
]
|
|
256
269
|
: [String((result as { error?: string }).error ?? "No Aside profiles found.")]
|
|
257
270
|
: rows
|
|
258
271
|
/*
|
package/src/cli/resolve.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `ocx resolve` — the machine-readable runtime resolution surface for an embedding shell.
|
|
3
3
|
*
|
|
4
|
-
* D5 of devlog/
|
|
4
|
+
* D5 of devlog/_fin/260921_app_runtime_ownership/: the desktop shell must stop resolving
|
|
5
5
|
* the config home, the port and liveness itself. The tuned probe budgets in
|
|
6
6
|
* src/server/proxy-liveness.ts exist because a shell-side reimplementation answered
|
|
7
7
|
* "nobody listening" twice and started duplicate proxies; this verb exposes that module's
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
} from "../service/ownership-compatibility";
|
|
60
60
|
import { observeManagingClis } from "../service/managing-cli";
|
|
61
61
|
import { SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION } from "../service/install-state-contract.mjs";
|
|
62
|
+
import { inspectDesktopSupervision } from "../service/desktop-supervision.mjs";
|
|
62
63
|
import { computeVersionSkew, type VersionSkew } from "./version-skew";
|
|
63
64
|
|
|
64
65
|
/** Wire version of the resolve document. Bump only on an incompatible shape change. */
|
|
@@ -123,6 +124,12 @@ export interface ResolveJson {
|
|
|
123
124
|
* takeover. Additive within ocx-resolve/1; older shells ignore it and read as unknown.
|
|
124
125
|
*/
|
|
125
126
|
versionSkew?: VersionSkew;
|
|
127
|
+
supervisor?: {
|
|
128
|
+
kind: "desktop" | "none" | "unknown" | "unsupported";
|
|
129
|
+
supervisorPid?: number;
|
|
130
|
+
runtimePid?: number;
|
|
131
|
+
app?: string;
|
|
132
|
+
};
|
|
126
133
|
}
|
|
127
134
|
|
|
128
135
|
export type ResolveTakeover =
|
|
@@ -160,6 +167,7 @@ export interface ResolveIo {
|
|
|
160
167
|
observeManagers?: (
|
|
161
168
|
state: ServiceInstallState | null,
|
|
162
169
|
) => Readonly<Record<ManagingCliRole, ManagingCliObservation>>;
|
|
170
|
+
inspectSupervision?: typeof inspectDesktopSupervision;
|
|
163
171
|
stdout?: { log: (s: string) => void };
|
|
164
172
|
stderr?: { error: (s: string) => void };
|
|
165
173
|
}
|
|
@@ -187,6 +195,7 @@ export function buildResolveJson(
|
|
|
187
195
|
cliVersion: string,
|
|
188
196
|
ownership: ServiceOwnershipResolution,
|
|
189
197
|
takeover: ResolveTakeover,
|
|
198
|
+
supervision?: ReturnType<typeof inspectDesktopSupervision>,
|
|
190
199
|
): ResolveJson {
|
|
191
200
|
const configured = config.port ?? RESOLVE_DEFAULT_PORT;
|
|
192
201
|
return {
|
|
@@ -199,6 +208,9 @@ export function buildResolveJson(
|
|
|
199
208
|
source: live ? live.source : "config",
|
|
200
209
|
},
|
|
201
210
|
liveness: livenessJson(live),
|
|
211
|
+
...(live && supervision ? { supervisor: supervision.kind === "desktop"
|
|
212
|
+
? { kind: supervision.kind, supervisorPid: supervision.supervisorPid, runtimePid: supervision.runtimePid, app: supervision.app }
|
|
213
|
+
: { kind: supervision.kind } } : {}),
|
|
202
214
|
ownership,
|
|
203
215
|
takeover,
|
|
204
216
|
// A proven absence has nothing to skew against; the field is omitted there rather
|
|
@@ -216,7 +228,8 @@ function reportHuman(json: ResolveJson, stdout: { log: (s: string) => void }): v
|
|
|
216
228
|
if (live.status === "live") {
|
|
217
229
|
const pidText = live.pid === null ? "unknown" : String(live.pid);
|
|
218
230
|
const versionText = live.version ?? "unknown version";
|
|
219
|
-
|
|
231
|
+
const supervisor = json.supervisor?.kind === "desktop" ? `; supervisor: desktop (pid ${json.supervisor.supervisorPid})` : "";
|
|
232
|
+
stdout.log(`Proxy live on port ${json.port.effective} (PID ${pidText}, ${versionText}); effective port ${json.port.effective}${supervisor}.`);
|
|
220
233
|
} else {
|
|
221
234
|
stdout.log(`No live proxy (absence proven); effective port ${json.port.effective} (configured).`);
|
|
222
235
|
}
|
|
@@ -353,7 +366,17 @@ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise
|
|
|
353
366
|
};
|
|
354
367
|
}
|
|
355
368
|
}
|
|
356
|
-
|
|
369
|
+
let supervision: ReturnType<typeof inspectDesktopSupervision> | undefined;
|
|
370
|
+
if (live) {
|
|
371
|
+
try {
|
|
372
|
+
supervision = live.pid === null ? { kind: "unknown", reason: "pid-unavailable", desktopSeen: false }
|
|
373
|
+
: (io.inspectSupervision ?? inspectDesktopSupervision)({ targetPid: live.pid });
|
|
374
|
+
} catch {
|
|
375
|
+
// Supervision is independent evidence; its failure cannot invalidate established liveness.
|
|
376
|
+
supervision = { kind: "unknown", reason: "probe-failed", desktopSeen: false };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const json = buildResolveJson(diagnostics.config, live, configHome, cliVersion(), ownership, takeover, supervision);
|
|
357
380
|
if (args.json) stdout.log(JSON.stringify(json));
|
|
358
381
|
else reportHuman(json, stdout);
|
|
359
382
|
return 0;
|
package/src/cli/root.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI head: version/help early exits,
|
|
3
|
-
* Codex-shim auto-restore preflight
|
|
2
|
+
* CLI head: version/help early exits, ready/resolve pre-parse, lifecycle version
|
|
3
|
+
* notice, then bounded Codex-shim auto-restore preflight (Phase 1 of the CLI
|
|
4
4
|
* deepening — moved out of src/cli/index.ts).
|
|
5
5
|
*
|
|
6
6
|
* `parseCliHead` is pure (no I/O, no process access) so the ordering and the
|
|
@@ -16,6 +16,7 @@ import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore";
|
|
|
16
16
|
import { findCommand } from "./registry";
|
|
17
17
|
import { printUnknownCommand } from "./help-recovery";
|
|
18
18
|
import { noteCredentialArgv, redactSecretArgs } from "./secret-args";
|
|
19
|
+
import { maybeNoticeVersionSkew } from "./version-skew-notice";
|
|
19
20
|
|
|
20
21
|
export interface CliHead {
|
|
21
22
|
kind: "version" | "help" | "ready" | "resolve" | "command";
|
|
@@ -141,6 +142,7 @@ export async function runCli(argv: string[]): Promise<CliHead> {
|
|
|
141
142
|
console.error(uninstallError);
|
|
142
143
|
process.exit(2);
|
|
143
144
|
}
|
|
145
|
+
await maybeNoticeVersionSkew(head.command, head.args);
|
|
144
146
|
maybeAutoRestoreCodexShim(head.command, head.args);
|
|
145
147
|
return head;
|
|
146
148
|
}
|
package/src/cli/status.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { collectCliPathDiagnostics, formatCliStatusHealthLabel, type CliPathDiagnostics } from "./cli-path-diagnostics";
|
|
1
2
|
import type { CodexMainAccountPolicyHealth } from "../oauth/health";
|
|
2
3
|
import { durableBunRuntime } from "../lib/bun-runtime";
|
|
3
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -7,6 +8,7 @@ import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../cod
|
|
|
7
8
|
import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
|
|
8
9
|
import type { OcxConfig } from "../types";
|
|
9
10
|
import { diagnoseService, serviceLogPath } from "../service";
|
|
11
|
+
import { inspectDesktopSupervision } from "../service/desktop-supervision.mjs";
|
|
10
12
|
import { collectStartupHealth, startupHealthReadBudgetMs, type StartupHealth } from "../codex/autostart-health";
|
|
11
13
|
import { getCodexRoutingKind } from "../codex/inject";
|
|
12
14
|
import { missingOwnedCatalogPath } from "../codex/inject/config-toml";
|
|
@@ -215,6 +217,8 @@ export type CliStatusJson = {
|
|
|
215
217
|
* ignores the key is unaffected, and `proxyVersion` is null when nothing is live.
|
|
216
218
|
*/
|
|
217
219
|
versionSkew: VersionSkew;
|
|
220
|
+
cliCommand: CliPathDiagnostics;
|
|
221
|
+
startupSource: "live" | "local" | "local-supervision-override";
|
|
218
222
|
};
|
|
219
223
|
|
|
220
224
|
export type CliStatusView = {
|
|
@@ -259,12 +263,28 @@ export async function fetchLiveStartupHealth(
|
|
|
259
263
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
260
264
|
const row = payload as Record<string, unknown>;
|
|
261
265
|
if (row.status !== "native" && row.status !== "protected" && row.status !== "at-risk") return null;
|
|
262
|
-
if (row.protection !== "service" && row.protection !== "shim" && row.protection !== "none") return null;
|
|
266
|
+
if (row.protection !== "service" && row.protection !== "desktop" && row.protection !== "shim" && row.protection !== "none") return null;
|
|
263
267
|
if (row.routingKind !== "native" && row.routingKind !== "opencodex-local"
|
|
264
268
|
&& row.routingKind !== "custom-local" && row.routingKind !== "custom-remote" && row.routingKind !== "unknown") return null;
|
|
265
269
|
if (row.shimCoverage !== "full" && row.shimCoverage !== "cli-only" && row.shimCoverage !== "none") return null;
|
|
266
270
|
if (typeof row.platform !== "string") return null;
|
|
267
271
|
if (row.recommendedCommand !== null && typeof row.recommendedCommand !== "string") return null;
|
|
272
|
+
if (row.recommendedAction !== undefined && row.recommendedAction !== null
|
|
273
|
+
&& (typeof row.recommendedAction !== "string" || row.recommendedAction.length > 512)) return null;
|
|
274
|
+
if (row.desktop !== undefined) {
|
|
275
|
+
if (!row.desktop || typeof row.desktop !== "object" || Array.isArray(row.desktop)) return null;
|
|
276
|
+
const desktop = row.desktop as Record<string, unknown>;
|
|
277
|
+
for (const key of ["owned", "loginEnabled", "running", "viable"] as const) {
|
|
278
|
+
if (typeof desktop[key] !== "boolean") return null;
|
|
279
|
+
}
|
|
280
|
+
if (desktop.supervisor !== undefined) {
|
|
281
|
+
if (!desktop.supervisor || typeof desktop.supervisor !== "object" || Array.isArray(desktop.supervisor)) return null;
|
|
282
|
+
const supervisor = desktop.supervisor as Record<string, unknown>;
|
|
283
|
+
if (typeof supervisor.supervisorPid !== "number" || !Number.isFinite(supervisor.supervisorPid)
|
|
284
|
+
|| typeof supervisor.runtimePid !== "number" || !Number.isFinite(supervisor.runtimePid)
|
|
285
|
+
|| typeof supervisor.app !== "string") return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
268
288
|
if (!row.commands || typeof row.commands !== "object" || Array.isArray(row.commands)) return null;
|
|
269
289
|
for (const key of ["installService", "repairService", "installShim", "restoreNative"] as const) {
|
|
270
290
|
if (typeof (row.commands as Record<string, unknown>)[key] !== "string") return null;
|
|
@@ -286,12 +306,36 @@ export async function fetchLiveStartupHealth(
|
|
|
286
306
|
return payload as StartupHealth;
|
|
287
307
|
}
|
|
288
308
|
|
|
289
|
-
/**
|
|
309
|
+
/** Older runtimes cannot report supervision; only that missing capability permits a local override. */
|
|
290
310
|
export function selectStatusStartupHealth(
|
|
291
311
|
liveStartup: StartupHealth | null,
|
|
292
312
|
fallback: () => StartupHealth,
|
|
293
|
-
)
|
|
294
|
-
|
|
313
|
+
supervision?: () => ReturnType<typeof inspectDesktopSupervision>,
|
|
314
|
+
livePid?: number | null,
|
|
315
|
+
): { startup: StartupHealth; startupSource: CliStatusJson["startupSource"] } {
|
|
316
|
+
if (!liveStartup) return { startup: fallback(), startupSource: "local" };
|
|
317
|
+
if (liveStartup.desktop === undefined && supervision) {
|
|
318
|
+
let evidence: ReturnType<typeof inspectDesktopSupervision> | undefined;
|
|
319
|
+
try { evidence = supervision(); }
|
|
320
|
+
catch { /* Failed optional evidence cannot replace an attested live verdict. */ }
|
|
321
|
+
if (evidence?.kind === "desktop" && evidence.runtimePid === livePid) {
|
|
322
|
+
const local = fallback();
|
|
323
|
+
const supervisor = local.desktop?.supervisor;
|
|
324
|
+
// The local probe runs later; it must still describe the same unowned process pair.
|
|
325
|
+
if (local.desktop?.owned === false && supervisor
|
|
326
|
+
&& supervisor.runtimePid === evidence.runtimePid && supervisor.supervisorPid === evidence.supervisorPid) {
|
|
327
|
+
return { startup: local, startupSource: "local-supervision-override" };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { startup: liveStartup, startupSource: "live" };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function runtimeSupervisorLine(startup: StartupHealth): string | null {
|
|
335
|
+
const supervisor = startup.desktop?.supervisor;
|
|
336
|
+
return supervisor
|
|
337
|
+
? `Runtime supervisor: OpenCodex Desktop (pid ${supervisor.supervisorPid}, ${supervisor.app}); durable owner: ${startup.desktop?.owned ? "desktop" : "none"}`
|
|
338
|
+
: null;
|
|
295
339
|
}
|
|
296
340
|
|
|
297
341
|
/** Build the service summary from the same startup source that `ocx status` selected. */
|
|
@@ -299,18 +343,23 @@ export function statusServiceSummary(
|
|
|
299
343
|
liveStartup: StartupHealth | null,
|
|
300
344
|
service: Pick<ReturnType<typeof diagnoseService>, "installed" | "summary">,
|
|
301
345
|
live: boolean,
|
|
346
|
+
// Which verdict was selected. A live proxy can still be described by the local reading when
|
|
347
|
+
// the runtime predates supervision reporting, so the label follows the verdict, not liveness.
|
|
348
|
+
startupSource?: CliStatusJson["startupSource"],
|
|
302
349
|
): string {
|
|
303
|
-
if (liveStartup) {
|
|
350
|
+
if (live && liveStartup) {
|
|
351
|
+
const supervisor = liveStartup.desktop?.supervisor ? "OpenCodex Desktop supervises the running proxy; " : "";
|
|
304
352
|
if (liveStartup.protection === "service" && liveStartup.serviceViable) {
|
|
305
|
-
return
|
|
353
|
+
return `${supervisor}running under the live managed service (logs: ${serviceLogPath()})`;
|
|
306
354
|
}
|
|
307
355
|
const state = [
|
|
308
356
|
liveStartup.serviceInstalled ? "installed" : "absent",
|
|
309
357
|
liveStartup.serviceRunning ? "running" : "not running",
|
|
310
358
|
liveStartup.serviceViable ? "viable" : "not viable",
|
|
311
359
|
].join(", ");
|
|
312
|
-
const action = liveStartup.recommendedCommand ? `; run '${liveStartup.recommendedCommand}'` : "";
|
|
313
|
-
|
|
360
|
+
const action = !liveStartup.desktop?.supervisor && liveStartup.recommendedCommand ? `; run '${liveStartup.recommendedCommand}'` : "";
|
|
361
|
+
const label = (startupSource ?? (live ? "live" : "local")) === "live" ? "live" : "local";
|
|
362
|
+
return `${supervisor}${label} startup reports service ${state}${action} (logs: ${serviceLogPath()})`;
|
|
314
363
|
}
|
|
315
364
|
return service.installed && !live
|
|
316
365
|
? `${service.summary} — registered but NOT serving; see ${serviceLogPath()} and re-run 'ocx service repair'`
|
|
@@ -680,6 +729,7 @@ export function missingCodexCatalogLines(missingCatalogPath: string | null): str
|
|
|
680
729
|
|
|
681
730
|
/** `mainAccountPolicy`: only `--json` asks; the human report reads the same accounts once via OAuth health. */
|
|
682
731
|
export async function collectStatus(options: { mainAccountPolicy?: boolean } = {}): Promise<CliStatusView> {
|
|
732
|
+
const cliCommand = collectCliPathDiagnostics();
|
|
683
733
|
const configDiagnostics = readConfigDiagnostics();
|
|
684
734
|
const config = configDiagnostics.config;
|
|
685
735
|
const claudeDesktop = {
|
|
@@ -757,14 +807,14 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = {
|
|
|
757
807
|
// A service can be registered and still not serve: the manager reports the job
|
|
758
808
|
// either way. When the identity-probed live proxy provides an attested startup verdict,
|
|
759
809
|
// prefer it over a shell-local service-manager probe that lacks the service environment.
|
|
760
|
-
const serviceSummary = statusServiceSummary(liveStartup, service, Boolean(live));
|
|
761
810
|
const codexShim = diagnoseCodexShim();
|
|
762
811
|
const codexShimSummary = codexShim.summary;
|
|
763
|
-
const startup = selectStatusStartupHealth(liveStartup, () => collectStartupHealth(config, {
|
|
812
|
+
const { startup, startupSource } = selectStatusStartupHealth(liveStartup, () => collectStartupHealth(config, {
|
|
764
813
|
service,
|
|
765
814
|
shim: codexShim,
|
|
766
815
|
routingKind: getCodexRoutingKind(),
|
|
767
|
-
}));
|
|
816
|
+
}), live?.pid != null ? () => inspectDesktopSupervision({ targetPid: live.pid! }) : undefined, live?.pid);
|
|
817
|
+
const serviceSummary = statusServiceSummary(startup, service, Boolean(live), startupSource);
|
|
768
818
|
const codexPlugins = diagnoseCodexBundledPlugins();
|
|
769
819
|
const lastClamp = loadLastEffortClamp();
|
|
770
820
|
const clampActive = effortClampAppliesToRuntime(lastClamp, resolvedRuntime.runtime);
|
|
@@ -869,7 +919,7 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = {
|
|
|
869
919
|
|
|
870
920
|
return {
|
|
871
921
|
proxyLabel,
|
|
872
|
-
healthLabel: health.label,
|
|
922
|
+
healthLabel: formatCliStatusHealthLabel(health.label, cliCommand, remoteHub.connected),
|
|
873
923
|
json: {
|
|
874
924
|
...(mainAccountHardLock ? { mainAccountHardLock } : {}),
|
|
875
925
|
schemaVersion: 1,
|
|
@@ -932,6 +982,8 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = {
|
|
|
932
982
|
// fact about this install, not about the Codex runtime, and filing it there would
|
|
933
983
|
// print it under the wrong heading (#2701).
|
|
934
984
|
versionSkew,
|
|
985
|
+
cliCommand,
|
|
986
|
+
startupSource,
|
|
935
987
|
},
|
|
936
988
|
};
|
|
937
989
|
}
|
|
@@ -16,7 +16,8 @@ function parseMarker(raw: string): UpdateRestartChildMarker {
|
|
|
16
16
|
if (!value || !Number.isSafeInteger(value.deadlineAt) || !Number.isInteger(value.port)
|
|
17
17
|
|| typeof value.hostname !== "string" || value.port < 1 || value.port > 65535 || typeof value.version !== "string"
|
|
18
18
|
|| value.version === "0.0.0" || !parseStrictSemver(value.version) || !value.home
|
|
19
|
-
|| !Number.isSafeInteger(value.home.revision)
|
|
19
|
+
|| !Number.isSafeInteger(value.home.revision) || value.home.serviceRecord?.schema !== 1
|
|
20
|
+
|| typeof value.home.serviceRecord.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.home.serviceRecord.digest)) throw new Error("update_restart_child_marker_invalid");
|
|
20
21
|
for (const directory of [value.home.config, value.home.codex]) {
|
|
21
22
|
// Windows reports 64-bit NTFS file ids (MFT sequence in the high 16 bits) as doubles above
|
|
22
23
|
// 2^53. Parent and child read them through the same stat API, so any finite integer is a
|
|
@@ -56,7 +57,7 @@ export function admitUpdateRestartChild(argv: string[], io: UpdateRestartChildIo
|
|
|
56
57
|
if (now() >= marker.deadlineAt) throw new Error("update_restart_deadline_expired");
|
|
57
58
|
const bindHost = (host: string) => !host.trim() || /^localhost\.?$/i.test(host.trim()) ? "127.0.0.1" : host.trim();
|
|
58
59
|
if (port !== marker.port || bindHost(hostname) !== bindHost(marker.hostname) || (io.version ?? packageVersion)() !== marker.version) throw new Error("update_restart_child_identity_changed");
|
|
59
|
-
(io.checkHome ?? assertUpdateRestartHome)(marker.home);
|
|
60
|
+
(io.checkHome ?? (home => assertUpdateRestartHome(home, marker.deadlineAt)))(marker.home);
|
|
60
61
|
(io.checkState ?? (expected => {
|
|
61
62
|
assertUpdateRestartConfiguration(expected.hostname);
|
|
62
63
|
const current = readRuntimePort();
|
|
@@ -65,6 +66,7 @@ export function admitUpdateRestartChild(argv: string[], io: UpdateRestartChildIo
|
|
|
65
66
|
throw new Error("update_restart_competing_runtime");
|
|
66
67
|
}
|
|
67
68
|
}))(marker);
|
|
69
|
+
if (now() >= marker.deadlineAt) throw new Error("update_restart_deadline_expired");
|
|
68
70
|
};
|
|
69
71
|
check();
|
|
70
72
|
const lease = (io.acquire ?? (waitMs => acquireOwnershipMutationLease(serviceStatePaths(), { waitMs })))(Math.min(2000, marker.deadlineAt - now()));
|
|
@@ -2,12 +2,15 @@ import { realpathSync, statSync } from "node:fs";
|
|
|
2
2
|
import { readClientConnectionState } from "../client/state";
|
|
3
3
|
import { readConfigDiagnostics } from "../config/diagnostics";
|
|
4
4
|
import { getConfigDir } from "../config/paths";
|
|
5
|
-
import { currentCodexHome
|
|
5
|
+
import { currentCodexHome } from "../service/state";
|
|
6
|
+
import { captureUpdateRestartServiceRecord, type UpdateRestartServiceRecord, type UpdateRestartServiceRecordDeps } from "./update-restart-service-record";
|
|
7
|
+
import { probeUpdateRestartSupervision, type UpdateRestartSupervisionDeps } from "./update-restart-supervision";
|
|
6
8
|
|
|
7
9
|
export interface UpdateRestartHome {
|
|
8
10
|
config: { path: string; dev: number; ino: number };
|
|
9
11
|
codex: { path: string; dev: number; ino: number };
|
|
10
12
|
revision: number;
|
|
13
|
+
serviceRecord: UpdateRestartServiceRecord;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
function physicalDirectory(path: string): UpdateRestartHome["config"] {
|
|
@@ -17,17 +20,32 @@ function physicalDirectory(path: string): UpdateRestartHome["config"] {
|
|
|
17
20
|
return { path: canonical, dev: stat.dev, ino: stat.ino };
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 };
|
|
23
|
+
export interface UpdateRestartHomeDeps {
|
|
24
|
+
record?: UpdateRestartServiceRecordDeps;
|
|
25
|
+
supervision?: UpdateRestartSupervisionDeps;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
/** Capture eligibility from the same bytes that supply the frozen fingerprint. */
|
|
29
|
+
export function readUpdateRestartHome(deps: UpdateRestartHomeDeps = {}): UpdateRestartHome {
|
|
30
|
+
const { serviceRecord, state, owner } = captureUpdateRestartServiceRecord(deps.record);
|
|
31
|
+
const platform = deps.record?.platform ?? process.platform;
|
|
32
|
+
if (owner.kind !== "none" || (state.kind === "state" && platform !== "darwin" && platform !== "linux")) {
|
|
33
|
+
throw new Error("update_restart_owner_unverified");
|
|
34
|
+
}
|
|
35
|
+
return { config: physicalDirectory(getConfigDir()), codex: physicalDirectory(currentCodexHome()), revision: owner.revision, serviceRecord };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function assertUpdateRestartHome(expected: UpdateRestartHome, deadlineAt = Date.now() + 2000, deps: UpdateRestartHomeDeps = {}): void {
|
|
39
|
+
try {
|
|
40
|
+
const current = readUpdateRestartHome(deps);
|
|
41
|
+
if (JSON.stringify(current) !== JSON.stringify(expected)) throw new Error("changed");
|
|
42
|
+
} catch { throw new Error("update_restart_home_changed"); }
|
|
43
|
+
if (probeUpdateRestartSupervision(deadlineAt, deps.supervision) !== "inactive") throw new Error("update_restart_supervision_unverified");
|
|
44
|
+
// Manager commands may yield to an external writer even though this guard is synchronous.
|
|
45
|
+
try {
|
|
46
|
+
if (JSON.stringify(readUpdateRestartHome(deps)) !== JSON.stringify(expected)) throw new Error("changed");
|
|
47
|
+
} catch { throw new Error("update_restart_home_changed"); }
|
|
48
|
+
if ((deps.supervision?.now ?? Date.now)() >= deadlineAt) throw new Error("update_restart_deadline_expired");
|
|
31
49
|
}
|
|
32
50
|
|
|
33
51
|
/** Read-only eligibility shared by parent pre-stop and child admission. */
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, type BigIntStats } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { plistPath, resolveServiceOwnership, resolveServiceState, serviceStatePaths, type ServiceStateEvidence } from "../service/state";
|
|
5
|
+
import { unitPath } from "../service/systemd";
|
|
6
|
+
import { inspectInstallStateBytes } from "../service/install-state-contract.mjs";
|
|
7
|
+
|
|
8
|
+
export interface UpdateRestartServiceRecord { schema: 1; digest: string }
|
|
9
|
+
export interface UpdateRestartServiceRecordDeps {
|
|
10
|
+
platform?: NodeJS.Platform;
|
|
11
|
+
paths?: () => readonly string[];
|
|
12
|
+
definitionPath?: () => string;
|
|
13
|
+
open?: (path: string, flags: number) => number;
|
|
14
|
+
fstat?: (fd: number) => BigIntStats;
|
|
15
|
+
read?: (fd: number, buffer: Buffer, offset: number, length: number, position: number) => number;
|
|
16
|
+
lstat?: (path: string) => BigIntStats;
|
|
17
|
+
}
|
|
18
|
+
/** Maximum bytes per state candidate or platform definition: 1 MiB, including growth during read. */
|
|
19
|
+
export const UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES = 1024 * 1024;
|
|
20
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
21
|
+
const FAILURE = "update_restart_service_record_unverified";
|
|
22
|
+
const hash = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex");
|
|
23
|
+
const metadata = (stat: BigIntStats) => [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.mode, stat.uid, stat.gid].map(String);
|
|
24
|
+
const absent = (error: unknown) => !!error && typeof error === "object" && "code" in error && error.code === "ENOENT";
|
|
25
|
+
|
|
26
|
+
// Canonicalize directories, never the record itself: a record symlink is a refusal.
|
|
27
|
+
function canonicalPath(path: string): string {
|
|
28
|
+
const parent = dirname(path);
|
|
29
|
+
try { return join(realpathSync.native(parent), basename(path)); }
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (!absent(error) || parent === path) throw new Error(FAILURE);
|
|
32
|
+
return join(canonicalPath(parent), basename(path));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readBoundedRecord(fd: number, read: NonNullable<UpdateRestartServiceRecordDeps["read"]>): Buffer {
|
|
37
|
+
const chunk = Buffer.alloc(READ_CHUNK_BYTES);
|
|
38
|
+
const chunks: Buffer[] = [];
|
|
39
|
+
let total = 0;
|
|
40
|
+
for (;;) {
|
|
41
|
+
// At the limit, a one-byte read distinguishes EOF from concurrent growth.
|
|
42
|
+
const length = Math.min(chunk.length, UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES - total + 1);
|
|
43
|
+
const count = read(fd, chunk, 0, length, total);
|
|
44
|
+
if (!Number.isInteger(count) || count < 0 || count > length) throw new Error(FAILURE);
|
|
45
|
+
if (count === 0) return Buffer.concat(chunks, total);
|
|
46
|
+
total += count;
|
|
47
|
+
if (total > UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES) throw new Error(FAILURE);
|
|
48
|
+
chunks.push(Buffer.from(chunk.subarray(0, count)));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Read all authority/mirror candidates and the platform definition as one frozen record. */
|
|
53
|
+
export function captureUpdateRestartServiceRecord(deps: UpdateRestartServiceRecordDeps = {}) {
|
|
54
|
+
try {
|
|
55
|
+
const platform = deps.platform ?? process.platform;
|
|
56
|
+
const paths = (deps.paths ?? serviceStatePaths)();
|
|
57
|
+
const definition = platform === "darwin" ? (deps.definitionPath ?? plistPath)()
|
|
58
|
+
: platform === "linux" ? (deps.definitionPath ?? unitPath)() : undefined;
|
|
59
|
+
const statPath = deps.lstat ?? (path => lstatSync(path, { bigint: true }));
|
|
60
|
+
const captures = [...paths, ...(definition ? [definition] : [])].map((path, index) => {
|
|
61
|
+
const canonical = canonicalPath(path);
|
|
62
|
+
let before: BigIntStats;
|
|
63
|
+
try { before = statPath(path); }
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (!absent(error)) throw error;
|
|
66
|
+
return { path, canonical, index, identity: null, bytes: undefined };
|
|
67
|
+
}
|
|
68
|
+
if (!before.isFile() || before.isSymbolicLink()) throw new Error(FAILURE);
|
|
69
|
+
const fd = (deps.open ?? openSync)(path, constants.O_RDONLY | (platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK));
|
|
70
|
+
try {
|
|
71
|
+
const statFd = deps.fstat ?? (handle => fstatSync(handle, { bigint: true }));
|
|
72
|
+
const opened = statFd(fd);
|
|
73
|
+
const identity = metadata(before);
|
|
74
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino
|
|
75
|
+
|| opened.size < 0n || opened.size > BigInt(UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES)
|
|
76
|
+
|| JSON.stringify(identity) !== JSON.stringify(metadata(opened))) throw new Error(FAILURE);
|
|
77
|
+
const bytes = readBoundedRecord(fd, deps.read ?? readSync);
|
|
78
|
+
const after = statFd(fd);
|
|
79
|
+
if (!after.isFile() || JSON.stringify(identity) !== JSON.stringify(metadata(after))
|
|
80
|
+
|| BigInt(bytes.length) !== after.size) throw new Error(FAILURE);
|
|
81
|
+
return { path, canonical, index, identity, bytes };
|
|
82
|
+
} finally { closeSync(fd); }
|
|
83
|
+
});
|
|
84
|
+
// Pin path-to-descriptor identity after every read; also confirm each ENOENT again.
|
|
85
|
+
for (const entry of captures) {
|
|
86
|
+
let current: BigIntStats;
|
|
87
|
+
try { current = statPath(entry.path); }
|
|
88
|
+
catch (error) { if (entry.identity === null && absent(error)) continue; throw error; }
|
|
89
|
+
if (!current.isFile() || JSON.stringify(metadata(current)) !== JSON.stringify(entry.identity)
|
|
90
|
+
|| canonicalPath(entry.path) !== entry.canonical) throw new Error(FAILURE);
|
|
91
|
+
}
|
|
92
|
+
const evidence: ServiceStateEvidence[] = captures.slice(0, paths.length).map(entry => {
|
|
93
|
+
if (!entry.bytes) return { path: entry.canonical, kind: "absent" };
|
|
94
|
+
const parsed = inspectInstallStateBytes(entry.canonical, () => entry.bytes!.toString("utf8"));
|
|
95
|
+
if (parsed.kind !== "valid") throw new Error(FAILURE);
|
|
96
|
+
return parsed as ServiceStateEvidence;
|
|
97
|
+
});
|
|
98
|
+
const state = resolveServiceState(evidence);
|
|
99
|
+
const owner = resolveServiceOwnership(evidence);
|
|
100
|
+
if (state.kind === "unknown" || owner.kind === "unknown") throw new Error(FAILURE);
|
|
101
|
+
const serviceRecord: UpdateRestartServiceRecord = { schema: 1, digest: hash(JSON.stringify({
|
|
102
|
+
platform, authority: paths.length - 1,
|
|
103
|
+
records: captures.map(entry => [entry.canonical, entry.index, entry.identity, entry.bytes ? hash(entry.bytes) : null]),
|
|
104
|
+
})) };
|
|
105
|
+
return { serviceRecord, state, owner };
|
|
106
|
+
} catch { throw new Error(FAILURE); }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Drift is terminal; never replace the transaction's original digest. */
|
|
110
|
+
export function assertUpdateRestartServiceRecord(expected: UpdateRestartServiceRecord, deps: UpdateRestartServiceRecordDeps = {}) {
|
|
111
|
+
try {
|
|
112
|
+
const current = captureUpdateRestartServiceRecord(deps);
|
|
113
|
+
if (expected.schema !== 1 || current.serviceRecord.digest !== expected.digest) throw new Error(FAILURE);
|
|
114
|
+
return current;
|
|
115
|
+
} catch { throw new Error("update_restart_home_changed"); }
|
|
116
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { statSync, type Stats } from "node:fs";
|
|
3
|
+
import { systemdUserBusEnvironment } from "../service/systemd";
|
|
4
|
+
import { assertLiveServiceManagerAllowed } from "../service/guards";
|
|
5
|
+
import { LABEL, TASK } from "../service/state";
|
|
6
|
+
|
|
7
|
+
export type UpdateRestartSupervision = "inactive" | "active" | "unknown";
|
|
8
|
+
export interface UpdateRestartSupervisorReply { status: number | null; stdout: string; stderr: string }
|
|
9
|
+
export interface UpdateRestartSupervisionDeps {
|
|
10
|
+
platform?: NodeJS.Platform;
|
|
11
|
+
now?: () => number;
|
|
12
|
+
uid?: number;
|
|
13
|
+
stat?: (path: string) => Pick<Stats, "isFile" | "mode">;
|
|
14
|
+
environment?: NodeJS.ProcessEnv;
|
|
15
|
+
exists?: (path: string) => boolean;
|
|
16
|
+
run?: (command: string, args: string[], timeoutMs: number, environment: NodeJS.ProcessEnv) => UpdateRestartSupervisorReply;
|
|
17
|
+
}
|
|
18
|
+
const PROBE_TIMEOUT_MS = 2000;
|
|
19
|
+
export const UPDATE_RESTART_SYSTEMD_ARGS = ["--user", "show", "-p", "LoadState", "-p", "ActiveState", "-p", "MainPID", "-p", "FragmentPath", "-p", "NeedDaemonReload", TASK];
|
|
20
|
+
|
|
21
|
+
function run(command: string, args: string[], timeout: number, env: NodeJS.ProcessEnv): UpdateRestartSupervisorReply {
|
|
22
|
+
assertLiveServiceManagerAllowed("update restart supervision");
|
|
23
|
+
try {
|
|
24
|
+
return { status: 0, stdout: execFileSync(command, args, { encoding: "utf8", timeout, env,
|
|
25
|
+
killSignal: "SIGKILL", maxBuffer: 64 * 1024, stdio: ["ignore", "pipe", "pipe"] }), stderr: "" };
|
|
26
|
+
} catch (error) {
|
|
27
|
+
const failure = error as { status?: number | null; signal?: string; code?: string; stdout?: Buffer; stderr?: Buffer };
|
|
28
|
+
if (failure.signal || failure.code || !Number.isInteger(failure.status)) throw new Error("update_restart_supervision_unverified");
|
|
29
|
+
return { status: failure.status!, stdout: failure.stdout?.toString() ?? "", stderr: failure.stderr?.toString() ?? "" };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Ignore PATH; usr-merged systemctl symlinks may resolve to a regular trusted-location target. */
|
|
34
|
+
export function resolveUpdateRestartSupervisor(deps: UpdateRestartSupervisionDeps = {}): string | null {
|
|
35
|
+
const platform = deps.platform ?? process.platform;
|
|
36
|
+
const paths = platform === "linux" ? ["/usr/bin/systemctl", "/bin/systemctl"] : platform === "darwin" ? ["/bin/launchctl"] : [];
|
|
37
|
+
for (const path of paths) {
|
|
38
|
+
try {
|
|
39
|
+
const stat = (deps.stat ?? statSync)(path);
|
|
40
|
+
if (stat.isFile() && (stat.mode & 0o002) === 0 && (stat.mode & 0o111) !== 0) return path;
|
|
41
|
+
} catch { /* Try the next trusted location; no PATH fallback. */ }
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** All manager evidence shares the transaction deadline, including the retained PID-bound probe. */
|
|
47
|
+
export function runBoundedUpdateRestartSupervisor(command: string, args: string[], deadlineAt: number, deps: UpdateRestartSupervisionDeps = {}) {
|
|
48
|
+
const now = deps.now ?? Date.now;
|
|
49
|
+
const remaining = deadlineAt - now();
|
|
50
|
+
if (!Number.isFinite(remaining) || remaining <= 0) throw new Error("update_restart_supervision_unverified");
|
|
51
|
+
const environment = (deps.platform ?? process.platform) === "linux"
|
|
52
|
+
? systemdUserBusEnvironment(deps.environment ?? process.env, { uid: deps.uid, exists: deps.exists })
|
|
53
|
+
: { ...(deps.environment ?? process.env) };
|
|
54
|
+
const result = (deps.run ?? run)(command, args, Math.min(PROBE_TIMEOUT_MS, remaining), environment);
|
|
55
|
+
if (now() >= deadlineAt) throw new Error("update_restart_supervision_unverified");
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Registration presence is independent of liveness; only positive inactivity admits. */
|
|
60
|
+
export function probeUpdateRestartSupervision(deadlineAt: number, deps: UpdateRestartSupervisionDeps = {}, command = resolveUpdateRestartSupervisor(deps)): UpdateRestartSupervision {
|
|
61
|
+
const platform = deps.platform ?? process.platform;
|
|
62
|
+
if (!command) return "unknown";
|
|
63
|
+
if (platform === "darwin") {
|
|
64
|
+
const uid = deps.uid ?? process.getuid?.() ?? 0;
|
|
65
|
+
const states = [`gui/${uid}/${LABEL}`, `user/${uid}/${LABEL}`].map(target => {
|
|
66
|
+
try {
|
|
67
|
+
const result = runBoundedUpdateRestartSupervisor(command, ["print", target], deadlineAt, deps);
|
|
68
|
+
return result.status === 112 || result.status === 113 ? "inactive" : result.status === 0 ? "active" : "unknown";
|
|
69
|
+
} catch { return "unknown"; }
|
|
70
|
+
});
|
|
71
|
+
return states.includes("unknown") ? "unknown" : states.includes("active") ? "active" : "inactive";
|
|
72
|
+
}
|
|
73
|
+
if (platform === "linux") {
|
|
74
|
+
try {
|
|
75
|
+
const result = runBoundedUpdateRestartSupervisor(command, UPDATE_RESTART_SYSTEMD_ARGS, deadlineAt, deps);
|
|
76
|
+
return classifyUpdateRestartSystemdSupervision(result);
|
|
77
|
+
} catch { return "unknown"; }
|
|
78
|
+
}
|
|
79
|
+
return "unknown";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The retained manager observation must satisfy the same positive-inactivity contract. */
|
|
83
|
+
export function classifyUpdateRestartSystemdSupervision(result: UpdateRestartSupervisorReply): UpdateRestartSupervision {
|
|
84
|
+
if (result.status !== 0) return "unknown";
|
|
85
|
+
const fields = new Map<string, string>();
|
|
86
|
+
for (const line of result.stdout.trim().split(/\r?\n/)) {
|
|
87
|
+
const match = /^([A-Za-z]+)=(.*)$/.exec(line);
|
|
88
|
+
if (!match || fields.has(match[1]!)) return "unknown";
|
|
89
|
+
fields.set(match[1]!, match[2]!);
|
|
90
|
+
}
|
|
91
|
+
const pid = fields.get("MainPID");
|
|
92
|
+
if (!pid || !/^\d+$/.test(pid) || !Number.isSafeInteger(Number(pid))) return "unknown";
|
|
93
|
+
if (!["loaded", "not-found"].includes(fields.get("LoadState") ?? "")) return "unknown";
|
|
94
|
+
if (fields.get("ActiveState") === "inactive" && pid === "0") return "inactive";
|
|
95
|
+
return fields.get("ActiveState") === "active" || Number(pid) > 0 ? "active" : "unknown";
|
|
96
|
+
}
|