@bitkyc08/opencodex 2.82.0 → 2.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/App-CZtqiGcw.js +56 -0
- package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
- package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/coding-agent/protocol.ts +15 -8
- 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 +47 -15
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -0
- 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/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +16 -14
- package/src/bridge/errors.ts +26 -1
- package/src/bridge.ts +1 -1
- package/src/claude/context-windows.ts +57 -4
- package/src/claude/desktop-picker.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/picker-ca-persistence.ts +3 -1
- package/src/claude/intercept/picker-listener.ts +49 -1
- package/src/claude/intercept/settings-no-proxy.ts +146 -0
- package/src/claude/intercept/settings.ts +101 -9
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +44 -12
- package/src/cli/account.ts +41 -5
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-base.ts +13 -1
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/claude.ts +15 -6
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +16 -3
- package/src/cli/export-command.ts +14 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +13 -4
- package/src/cli/link-child.ts +104 -0
- package/src/cli/link-enrollment.ts +96 -0
- package/src/cli/link.ts +39 -24
- package/src/cli/observe-stream.ts +6 -1
- package/src/cli/observe.ts +10 -2
- package/src/cli/provider.ts +12 -0
- package/src/cli/registry.ts +5 -4
- package/src/cli/resolve.ts +1 -1
- package/src/cli/runtime-api.ts +27 -2
- package/src/cli/status.ts +104 -20
- package/src/client/connect.ts +39 -4
- package/src/client/hub-client.ts +14 -7
- package/src/client/hub-state.ts +25 -14
- package/src/client/link-home.ts +248 -0
- package/src/client/link-management-relay.ts +61 -0
- package/src/client/link-relay.ts +6 -1
- package/src/client/link-status.ts +14 -0
- package/src/client/machine-api.ts +14 -7
- package/src/client/machine-listener.ts +29 -4
- 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 +47 -17
- package/src/clients/omo-role-models.ts +6 -3
- package/src/codex/agent-role-models.ts +26 -8
- package/src/codex/auth-api/account-list.ts +17 -2
- package/src/codex/auth-api/account-selection.ts +17 -7
- package/src/codex/auth-api/pool-mode-gate.ts +4 -5
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/auth-context.ts +8 -3
- package/src/codex/autostart-health.ts +3 -1
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/effort.ts +2 -2
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/native-models.ts +17 -3
- package/src/codex/catalog/provider-models.ts +25 -0
- 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/main-account.ts +6 -7
- package/src/codex/native-profile-startup.ts +4 -1
- package/src/codex/prompt-journal.ts +20 -2
- package/src/codex/prompt-layers/encoding.ts +49 -0
- package/src/codex/prompt-layers/import-source.ts +30 -0
- package/src/codex/prompt-layers/toml-edit.ts +269 -37
- package/src/codex/prompt-layers/toml-read.ts +52 -4
- package/src/codex/prompt-layers.ts +383 -20
- package/src/codex/prompt-lock-claim.ts +13 -3
- package/src/codex/prompt-lock-evidence.ts +3 -3
- package/src/codex/prompt-lock-owner.ts +14 -6
- package/src/codex/prompt-text-probe.ts +34 -5
- package/src/codex/quota.ts +13 -6
- package/src/codex/role-auto-assign.ts +2 -5
- package/src/codex/routing/active-account.ts +21 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-overlay.ts +8 -4
- package/src/codex/shim.ts +1 -1
- package/src/combos/failover.ts +3 -0
- package/src/config/api-key-reconcile.ts +34 -0
- package/src/config/diagnostics.ts +16 -0
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +43 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +2 -1
- package/src/config/paths.ts +3 -0
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +2 -0
- package/src/generated/compatibility-version.json +373 -217
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/inject.ts +245 -221
- package/src/grok/reset-coupon-ledger.ts +293 -57
- package/src/grok/reset-coupons.ts +15 -6
- package/src/integrations/cursor-detect.ts +1 -1
- package/src/integrations/mutation-plan.ts +13 -7
- package/src/integrations/registry.ts +45 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +14 -6
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/errors.ts +25 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/shadow-call.ts +44 -5
- package/src/link/codex-settings.ts +155 -0
- package/src/link/management-routes.ts +89 -0
- package/src/link/remote-probe.ts +38 -0
- package/src/link/ssh-argv.ts +26 -5
- package/src/link/ssh-runner.ts +58 -27
- package/src/link/status-projection.ts +22 -4
- package/src/link/supervisor.ts +144 -49
- package/src/link/tunnel-state.ts +11 -4
- package/src/oauth/anthropic-continuity.ts +4 -3
- package/src/oauth/anthropic-identity.ts +13 -2
- package/src/oauth/anthropic-model-quota.ts +13 -1
- package/src/oauth/anthropic-routing.ts +230 -23
- package/src/oauth/anthropic.ts +18 -4
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +25 -16
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro.ts +48 -5
- package/src/oauth/meta-muse-device.ts +1 -1
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +3 -1
- package/src/plugins/loader.ts +11 -0
- package/src/providers/anthropic-billing-ownership.ts +74 -0
- package/src/providers/anthropic-billing-recovery.ts +31 -0
- package/src/providers/anthropic-billing-refusal.ts +55 -0
- package/src/providers/anthropic-fast.ts +1 -1
- package/src/providers/anthropic-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
- package/src/providers/api-key-resolve.ts +17 -2
- 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/key-failover.ts +52 -5
- package/src/providers/model-presets.ts +1 -1
- package/src/providers/model-rename-startup.ts +5 -3
- package/src/providers/muse-subscription-usage.ts +1 -1
- package/src/providers/quota/account-cache.ts +11 -0
- package/src/providers/quota/anthropic-account-quota.ts +4 -2
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota.ts +4 -1
- package/src/providers/registry/entries-core.ts +20 -20
- package/src/providers/registry/entries-extended.ts +4 -1
- package/src/providers/registry/model-seeds.ts +20 -3
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/providers/xai-fast-model.ts +1 -1
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +7 -1
- package/src/server/auth-cors.ts +42 -0
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/inbound-body-admission.ts +24 -7
- package/src/server/index/link-listener.ts +30 -17
- package/src/server/index/link-management.ts +58 -0
- package/src/server/index/optional-listeners.ts +5 -1
- package/src/server/index/serve-options.ts +21 -2
- package/src/server/index.ts +1 -1
- package/src/server/lifecycle.ts +33 -5
- package/src/server/management/agent-settings-routes.ts +28 -10
- package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
- package/src/server/management/codex-agent-role-routes.ts +19 -6
- package/src/server/management/codex-prompt-routes.ts +136 -5
- package/src/server/management/codex-role-auto-assign.ts +1 -1
- package/src/server/management/grok-coupon-routes.ts +333 -57
- package/src/server/management/link-enrollment.ts +192 -0
- package/src/server/management/link-route-state.ts +185 -0
- package/src/server/management/link-routes.ts +92 -390
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/provider-overwrite-carry.ts +10 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/management/route-registry.ts +7 -5
- package/src/server/management/shared.ts +3 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native.ts +68 -42
- package/src/server/relay-eager.ts +4 -0
- package/src/server/relay.ts +18 -7
- package/src/server/request-log-failure-attribution.ts +3 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +243 -0
- package/src/server/request-log.ts +15 -118
- package/src/server/responses/adapter-continuation.ts +9 -1
- package/src/server/responses/adapter-dispatch.ts +293 -81
- package/src/server/responses/code-mode-collab-surface.ts +205 -0
- package/src/server/responses/codex-ws-exchange.ts +147 -6
- package/src/server/responses/codex-ws-wire.ts +203 -0
- package/src/server/responses/collaboration.ts +36 -7
- package/src/server/responses/compact.ts +129 -50
- package/src/server/responses/context-overflow.ts +13 -1
- package/src/server/responses/core-codex-account.ts +105 -1
- package/src/server/responses/core-combo-failure.ts +14 -0
- package/src/server/responses/core-combo.ts +7 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-opaque-recovery.ts +5 -5
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core.ts +6 -6
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/empty-completion-guard.ts +18 -2
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/jev-model-invoke.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +4 -1
- package/src/server/responses/passthrough-dispatch.ts +130 -64
- package/src/server/responses/passthrough-execution.ts +3 -3
- package/src/server/responses/request-prepare.ts +6 -4
- package/src/server/responses/request-transport.ts +131 -13
- package/src/server/responses/run-turn-execution.ts +13 -5
- package/src/server/responses/sidecar-execution.ts +16 -2
- package/src/server/responses/spend-storage-error.ts +33 -0
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/startup-action-control.ts +22 -2
- package/src/server/startup-health-cache.ts +4 -0
- package/src/server/system-env-shell.ts +7 -2
- package/src/server/system-env.ts +11 -1
- package/src/service/cli.ts +5 -1
- package/src/service/desktop-startup.ts +147 -34
- package/src/service/desktop-supervision.d.mts +27 -1
- package/src/service/desktop-supervision.mjs +111 -5
- package/src/service/health-sentinel-launch.ts +176 -0
- package/src/service/health-sentinel-main.ts +447 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +54 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/types/config.ts +5 -0
- package/src/types/request.ts +5 -0
- package/src/usage/expected-prices.ts +5 -1
- package/src/usage/log.ts +19 -1
- package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
- package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
- package/gui/dist/assets/index-D3FBCNiP.js +0 -94
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { accessSync, constants, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { accessSync, constants, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve, win32 } from "node:path";
|
|
5
5
|
import { readPid } from "../config/process-state";
|
|
6
6
|
import { resolveServiceOwnership, sameServiceOwnershipSubject, type ServiceOwnershipResolution } from "./state";
|
|
7
|
-
import { inspectDesktopSupervision, processIdentity, procfs, type ProcReader } from "./desktop-supervision.mjs";
|
|
7
|
+
import { inspectDesktopSupervision, processIdentity, procfs, type DesktopSupervisionDeps, type ProcReader } from "./desktop-supervision.mjs";
|
|
8
8
|
|
|
9
9
|
export interface DesktopStartupDiagnostic {
|
|
10
10
|
/** Durable desktop claim; failed supervision does not release ownership. */
|
|
11
11
|
owned: boolean;
|
|
12
12
|
loginEnabled: boolean;
|
|
13
|
+
/** Verified enabled macOS registration; absence means registration is not credited. */
|
|
14
|
+
loginRegistration?: "loaded" | "registered-not-loaded";
|
|
13
15
|
running: boolean;
|
|
14
16
|
viable: boolean;
|
|
15
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Proven live parentage; never converts supervision into ownership. macOS and Linux report it
|
|
19
|
+
* only without a durable claim. Windows also reports it beside a durable Desktop claim, because
|
|
20
|
+
* older Windows runtimes never published a Desktop diagnostic that status could defer to.
|
|
21
|
+
*/
|
|
16
22
|
supervisor?: { supervisorPid: number; runtimePid: number; app: string };
|
|
17
23
|
}
|
|
18
24
|
|
|
@@ -21,6 +27,14 @@ export function deriveDesktopStartup(facts: Omit<DesktopStartupDiagnostic, "viab
|
|
|
21
27
|
return { ...facts, viable: (facts.owned || facts.supervisor !== undefined) && facts.loginEnabled && facts.running };
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
export function desktopLoginStatusSummary(desktop: DesktopStartupDiagnostic): string {
|
|
31
|
+
if (!desktop.loginEnabled) return "Desktop Start at Login: not registered, disabled, or could not be verified.";
|
|
32
|
+
if (desktop.loginRegistration === "registered-not-loaded") {
|
|
33
|
+
return "Desktop Start at Login: registered and enabled for next login; not loaded in this session.";
|
|
34
|
+
}
|
|
35
|
+
return `Desktop Start at Login: enabled${desktop.loginRegistration === "loaded" ? " and loaded in this session" : ""}.`;
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
function run(command: string, args: string[]): string {
|
|
25
39
|
return execFileSync(command, args, { encoding: "utf8", timeout: 750, maxBuffer: 128 * 1024, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
26
40
|
}
|
|
@@ -35,12 +49,16 @@ interface DesktopStartupDeps {
|
|
|
35
49
|
run?: typeof run;
|
|
36
50
|
env?: NodeJS.ProcessEnv;
|
|
37
51
|
proc?: ProcReader;
|
|
52
|
+
windowsProcesses?: DesktopSupervisionDeps["windowsProcesses"];
|
|
53
|
+
now?: () => number;
|
|
54
|
+
/** The Desktop's own install id (Tauri app_config_dir); null when unreadable. */
|
|
55
|
+
readWindowsInstallId?: () => string | null;
|
|
38
56
|
}
|
|
39
57
|
|
|
40
58
|
/** Cheap ownership-only snapshot: no launchd/process probes on the server request path. */
|
|
41
59
|
export function desktopStartupOwnership(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined {
|
|
42
60
|
const platform = deps.platform ?? process.platform;
|
|
43
|
-
if (platform !== "darwin" && platform !== "linux") return undefined;
|
|
61
|
+
if (platform !== "darwin" && platform !== "linux" && platform !== "win32") return undefined;
|
|
44
62
|
const owner = (deps.ownership ?? resolveServiceOwnership)();
|
|
45
63
|
return owner.kind === "owned" && owner.ownership.owner === "desktop"
|
|
46
64
|
? deriveDesktopStartup({ owned: true, loginEnabled: false, running: false }) : undefined;
|
|
@@ -49,33 +67,85 @@ export function desktopStartupOwnership(deps: DesktopStartupDeps = {}): DesktopS
|
|
|
49
67
|
function macLoginRegistration(deps: DesktopStartupDeps) {
|
|
50
68
|
const execute = deps.run ?? run;
|
|
51
69
|
const path = join(deps.home ?? homedir(), "Library", "LaunchAgents", "OpenCodex.plist");
|
|
52
|
-
const plist = JSON.parse(execute("/usr/bin/plutil", ["-convert", "json", "-o", "-", path]));
|
|
53
|
-
const args = plist.ProgramArguments;
|
|
54
|
-
if (plist.Label !== "OpenCodex" || plist.RunAtLoad !== true || !Array.isArray(args)
|
|
55
|
-
|| args.length !== 2 || args[1] !== "--autostart" || typeof args[0] !== "string"
|
|
56
|
-
|| !args[0].endsWith("/Contents/MacOS/opencodex-desktop")
|
|
57
|
-
|| (plist.Program !== undefined && plist.Program !== args[0])) return null;
|
|
58
|
-
const app = realpathSync(args[0]);
|
|
59
|
-
const proxy = realpathSync(join(dirname(app), "ocx"));
|
|
60
|
-
accessSync(app, constants.X_OK);
|
|
61
|
-
accessSync(proxy, constants.X_OK);
|
|
62
70
|
const domain = `gui/${deps.uid ?? process.getuid!()}`;
|
|
63
|
-
const
|
|
64
|
-
|
|
71
|
+
const identity = (file: string) => {
|
|
72
|
+
const stat = lstatSync(file, { bigint: true });
|
|
73
|
+
return [stat.dev, stat.ino, stat.size, stat.mode, stat.mtimeNs, stat.ctimeNs].map(String).join(":");
|
|
74
|
+
};
|
|
75
|
+
const capture = () => {
|
|
76
|
+
const registrationPath = realpathSync(path);
|
|
77
|
+
const plistIdentity = identity(path), targetIdentity = identity(registrationPath);
|
|
78
|
+
const definition = execute("/usr/bin/plutil", ["-convert", "json", "-o", "-", path]);
|
|
79
|
+
const plist = JSON.parse(definition);
|
|
80
|
+
const args = plist.ProgramArguments;
|
|
81
|
+
if (plist.Label !== "OpenCodex" || plist.RunAtLoad !== true || !Array.isArray(args)
|
|
82
|
+
|| args.length !== 2 || args[1] !== "--autostart" || typeof args[0] !== "string"
|
|
83
|
+
|| !args[0].endsWith("/Contents/MacOS/opencodex-desktop")
|
|
84
|
+
|| (plist.Program !== undefined && plist.Program !== args[0])
|
|
85
|
+
|| (plist.Disabled !== undefined && typeof plist.Disabled !== "boolean")) return null;
|
|
86
|
+
const app = realpathSync(args[0]);
|
|
87
|
+
const proxy = realpathSync(join(dirname(app), "ocx"));
|
|
88
|
+
accessSync(app, constants.X_OK);
|
|
89
|
+
accessSync(proxy, constants.X_OK);
|
|
90
|
+
const appIdentity = identity(app), proxyIdentity = identity(proxy);
|
|
91
|
+
const disabled = execute("/bin/launchctl", ["print-disabled", domain]);
|
|
92
|
+
const mentions = disabled.match(/"OpenCodex"/g)?.length ?? 0;
|
|
93
|
+
const override = /"OpenCodex"\s*=>\s*(enabled|false|disabled|true)(?=\s*(?:\r?\n|[,;}]|$))/.exec(disabled)?.[1];
|
|
94
|
+
const loginEnabled = /^\s*disabled services = \{[\s\S]*\}\s*$/.test(disabled)
|
|
95
|
+
&& (mentions === 0 ? plist.Disabled !== true : mentions === 1 && (override === "enabled" || override === "false"));
|
|
96
|
+
if (registrationPath !== realpathSync(path) || plistIdentity !== identity(path) || targetIdentity !== identity(registrationPath)
|
|
97
|
+
|| app !== realpathSync(args[0]) || proxy !== realpathSync(join(dirname(app), "ocx"))
|
|
98
|
+
|| appIdentity !== identity(app) || proxyIdentity !== identity(proxy)) return null;
|
|
99
|
+
return { app, proxy, loginEnabled, registrationPath, plistIdentity, targetIdentity, definition, appIdentity, proxyIdentity, disabled };
|
|
100
|
+
};
|
|
101
|
+
const first = capture();
|
|
102
|
+
if (!first) return null;
|
|
103
|
+
const { app, proxy } = first;
|
|
104
|
+
if (!first.loginEnabled) return { app, proxy, loginEnabled: false };
|
|
105
|
+
let loaded: string;
|
|
106
|
+
try { loaded = execute("/bin/launchctl", ["print", `${domain}/OpenCodex`]); }
|
|
107
|
+
catch (error) {
|
|
108
|
+
// execFileSync's specific service-not-found result is absence, not a failed probe.
|
|
109
|
+
// Timeout, permission denial, missing tools and another domain remain unverified.
|
|
110
|
+
const failure = error as { status?: number; stderr?: unknown; signal?: unknown; code?: unknown } | null;
|
|
111
|
+
const stderr = typeof failure?.stderr === "string" ? failure.stderr
|
|
112
|
+
: Buffer.isBuffer(failure?.stderr) ? failure.stderr.toString("utf8") : "";
|
|
113
|
+
const message = `Could not find service "OpenCodex" in domain for user gui: ${domain.slice(4)}`;
|
|
114
|
+
const missing = failure?.status === 113 && !failure.signal && !failure.code
|
|
115
|
+
&& [message, `Bad request.\n${message}`].includes(stderr.trim());
|
|
116
|
+
if (!missing) throw error;
|
|
117
|
+
// The absent job supplies no durable registration evidence. Re-read the entire
|
|
118
|
+
// registration and enabled state; replacement or ambiguity revokes login credit.
|
|
119
|
+
const final = capture();
|
|
120
|
+
if (!final || JSON.stringify(first) !== JSON.stringify(final)) return { app, proxy, loginEnabled: false };
|
|
121
|
+
return { app, proxy, loginEnabled: true, loginRegistration: "registered-not-loaded" as const };
|
|
122
|
+
}
|
|
65
123
|
const program = /^\s*program = (.+)$/m.exec(loaded)?.[1];
|
|
66
124
|
const loadedPath = /^\s*path = (.+)$/m.exec(loaded)?.[1];
|
|
67
|
-
const loginEnabled =
|
|
68
|
-
&&
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
125
|
+
const loginEnabled = program !== undefined && realpathSync(program) === app
|
|
126
|
+
&& loadedPath !== undefined && realpathSync(loadedPath) === first.registrationPath;
|
|
127
|
+
return { app, proxy, loginEnabled, ...(loginEnabled ? { loginRegistration: "loaded" as const } : {}) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type DesktopLoginFacts = Pick<DesktopStartupDiagnostic, "loginEnabled" | "loginRegistration">;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Start at Login credit for one Desktop app copy. The tray's Start at Login item ports this
|
|
134
|
+
* judgment (desktop/src-tauri/src/login_registration.rs); both run the cases in
|
|
135
|
+
* tests/fixtures/desktop-login-registration-cases.json. Throws when evidence is unreadable.
|
|
136
|
+
*/
|
|
137
|
+
export function macLoginFactsFor(app: string, deps: DesktopStartupDeps): DesktopLoginFacts {
|
|
138
|
+
const login = macLoginRegistration(deps);
|
|
139
|
+
return login !== null && login.app === app
|
|
140
|
+
? { loginEnabled: login.loginEnabled, ...(login.loginRegistration ? { loginRegistration: login.loginRegistration } : {}) }
|
|
141
|
+
: { loginEnabled: false };
|
|
72
142
|
}
|
|
73
143
|
|
|
74
|
-
function diagnoseSupervisedStartup(deps: DesktopStartupDeps, ownerRevision: number, loginFor: (app: string) =>
|
|
144
|
+
function diagnoseSupervisedStartup(deps: DesktopStartupDeps, ownerRevision: number, loginFor: (app: string) => DesktopLoginFacts): DesktopStartupDiagnostic | undefined {
|
|
75
145
|
const evidence = inspectDesktopSupervision(deps);
|
|
76
146
|
if (evidence.kind !== "desktop") return undefined;
|
|
77
|
-
let
|
|
78
|
-
try {
|
|
147
|
+
let login: DesktopLoginFacts = { loginEnabled: false };
|
|
148
|
+
try { login = loginFor(evidence.app); }
|
|
79
149
|
catch { /* Login failure revokes restart viability, not the observed live supervisor. */ }
|
|
80
150
|
if ((deps.ownership ?? resolveServiceOwnership)().kind !== "none") return undefined;
|
|
81
151
|
// Login probes can outlive the runtime or its parent; only the same live chain gets credit.
|
|
@@ -86,7 +156,7 @@ function diagnoseSupervisedStartup(deps: DesktopStartupDeps, ownerRevision: numb
|
|
|
86
156
|
const finalOwner = (deps.ownership ?? resolveServiceOwnership)();
|
|
87
157
|
if (finalOwner.kind !== "none" || finalOwner.revision !== ownerRevision) return undefined;
|
|
88
158
|
const { supervisorPid, runtimePid, app } = evidence;
|
|
89
|
-
return deriveDesktopStartup({ owned: false,
|
|
159
|
+
return deriveDesktopStartup({ owned: false, ...login, running: true, supervisor: { supervisorPid, runtimePid, app } });
|
|
90
160
|
}
|
|
91
161
|
|
|
92
162
|
/** Read-only macOS desktop ownership, login registration and live parent/child checks. */
|
|
@@ -94,12 +164,9 @@ export function diagnoseMacDesktopStartup(deps: DesktopStartupDeps = {}): Deskto
|
|
|
94
164
|
if ((deps.platform ?? process.platform) !== "darwin") return undefined;
|
|
95
165
|
const ownership = deps.ownership ?? resolveServiceOwnership;
|
|
96
166
|
const owner = ownership();
|
|
97
|
-
if (owner.kind === "none") return diagnoseSupervisedStartup(deps, owner.revision, app =>
|
|
98
|
-
const login = macLoginRegistration(deps);
|
|
99
|
-
return login !== null && login.app === app && login.loginEnabled;
|
|
100
|
-
});
|
|
167
|
+
if (owner.kind === "none") return diagnoseSupervisedStartup(deps, owner.revision, app => macLoginFactsFor(app, deps));
|
|
101
168
|
if (owner.kind !== "owned" || owner.ownership.owner !== "desktop") return undefined;
|
|
102
|
-
const facts = { owned: true, loginEnabled: false, running: false };
|
|
169
|
+
const facts: Omit<DesktopStartupDiagnostic, "viable"> = { owned: true, loginEnabled: false, running: false };
|
|
103
170
|
const execute = deps.run ?? run;
|
|
104
171
|
const pidReader = deps.readPid ?? readPid;
|
|
105
172
|
try {
|
|
@@ -110,6 +177,7 @@ export function diagnoseMacDesktopStartup(deps: DesktopStartupDeps = {}): Deskto
|
|
|
110
177
|
if (!login) return deriveDesktopStartup(facts);
|
|
111
178
|
const { app, proxy } = login;
|
|
112
179
|
facts.loginEnabled = login.loginEnabled;
|
|
180
|
+
if (login.loginRegistration) facts.loginRegistration = login.loginRegistration;
|
|
113
181
|
const pid = pidReader();
|
|
114
182
|
if (pid !== null) {
|
|
115
183
|
const child = processIdentity(pid, execute);
|
|
@@ -156,10 +224,10 @@ export function diagnoseLinuxDesktopStartup(deps: DesktopStartupDeps = {}): Desk
|
|
|
156
224
|
const configured = (deps.env ?? process.env).XDG_CONFIG_HOME;
|
|
157
225
|
const config = configured && isAbsolute(configured) ? configured : join(home, ".config");
|
|
158
226
|
// auto-launch's HOME entry must remain inside the session's XDG autostart search path.
|
|
159
|
-
if (resolve(config) !== resolve(home, ".config")) return false;
|
|
227
|
+
if (resolve(config) !== resolve(home, ".config")) return { loginEnabled: false };
|
|
160
228
|
const entry = readFileSync(join(home, ".config", "autostart", "OpenCodex.desktop"), "utf8");
|
|
161
229
|
const login = linuxLoginApp(entry);
|
|
162
|
-
return login !== null && realpathSync(login) === app;
|
|
230
|
+
return { loginEnabled: login !== null && realpathSync(login) === app };
|
|
163
231
|
});
|
|
164
232
|
if (owner.kind !== "owned" || owner.ownership.owner !== "desktop") return undefined;
|
|
165
233
|
const facts = { owned: true, loginEnabled: false, running: false };
|
|
@@ -212,5 +280,50 @@ export function diagnoseLinuxDesktopStartup(deps: DesktopStartupDeps = {}): Desk
|
|
|
212
280
|
|
|
213
281
|
/** Platform dispatch for the full (probing) desktop startup diagnostic. */
|
|
214
282
|
export function diagnoseDesktopStartup(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined {
|
|
215
|
-
|
|
283
|
+
const platform = deps.platform ?? process.platform;
|
|
284
|
+
if (platform === "linux") return diagnoseLinuxDesktopStartup(deps);
|
|
285
|
+
if (platform === "win32") return diagnoseWindowsDesktopStartup(deps);
|
|
286
|
+
return diagnoseMacDesktopStartup(deps);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Tauri resolves app_config_dir to the roaming AppData folder plus the bundle identifier. */
|
|
290
|
+
function readWindowsInstallId(env: NodeJS.ProcessEnv): string | null {
|
|
291
|
+
const appData = env.APPDATA;
|
|
292
|
+
if (!appData || !/^[A-Za-z]:[\\/]/.test(appData)) return null;
|
|
293
|
+
try {
|
|
294
|
+
return readFileSync(win32.join(appData, "com.opencodex.desktop", "install-id"), "utf8").trim() || null;
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Read-only Windows desktop ownership and live parent/child checks. Windows Start at Login is
|
|
302
|
+
* not verified, so it is never credited and Desktop never reads as startup protection here;
|
|
303
|
+
* the diagnostic still names the supervisor so status and doctor stop recommending service repairs.
|
|
304
|
+
*/
|
|
305
|
+
export function diagnoseWindowsDesktopStartup(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined {
|
|
306
|
+
if ((deps.platform ?? process.platform) !== "win32") return undefined;
|
|
307
|
+
const ownership = deps.ownership ?? resolveServiceOwnership;
|
|
308
|
+
const owner = ownership();
|
|
309
|
+
if (owner.kind === "none") return diagnoseSupervisedStartup(deps, owner.revision, () => ({ loginEnabled: false }));
|
|
310
|
+
if (owner.kind !== "owned" || owner.ownership.owner !== "desktop") return undefined;
|
|
311
|
+
const facts = { owned: true, loginEnabled: false, running: false };
|
|
312
|
+
try {
|
|
313
|
+
const installId = deps.readWindowsInstallId ? deps.readWindowsInstallId() : readWindowsInstallId(deps.env ?? process.env);
|
|
314
|
+
if (installId !== owner.ownership.installId) return deriveDesktopStartup(facts);
|
|
315
|
+
const evidence = inspectDesktopSupervision(deps);
|
|
316
|
+
if (evidence.kind !== "desktop") return deriveDesktopStartup(facts);
|
|
317
|
+
// Only the same live chain, read again after the claim, earns running and the supervisor.
|
|
318
|
+
const final = inspectDesktopSupervision({ ...deps, targetPid: evidence.runtimePid });
|
|
319
|
+
const current = ownership();
|
|
320
|
+
if (final.kind !== "desktop" || final.runtimePid !== evidence.runtimePid
|
|
321
|
+
|| final.supervisorPid !== evidence.supervisorPid || final.app !== evidence.app
|
|
322
|
+
|| current.kind === "unknown" || !sameServiceOwnershipSubject(owner, current)) return deriveDesktopStartup(facts);
|
|
323
|
+
const { supervisorPid, runtimePid, app } = final;
|
|
324
|
+
return deriveDesktopStartup({ ...facts, running: true, supervisor: { supervisorPid, runtimePid, app } });
|
|
325
|
+
} catch {
|
|
326
|
+
// Unreadable evidence never grants running or releases the claim.
|
|
327
|
+
return deriveDesktopStartup(facts);
|
|
328
|
+
}
|
|
216
329
|
}
|
|
@@ -9,18 +9,44 @@ export interface ProcReader {
|
|
|
9
9
|
parent(pid: number): number;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** One Win32_Process row; `created` is UTC ticks as a decimal string (exceeds 2^53). */
|
|
13
|
+
export interface WindowsProcessRow {
|
|
14
|
+
pid: number;
|
|
15
|
+
parent: number;
|
|
16
|
+
exe: string | null;
|
|
17
|
+
session: number;
|
|
18
|
+
created: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WindowsProcessPair {
|
|
22
|
+
child: WindowsProcessRow | null;
|
|
23
|
+
parent: WindowsProcessRow | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WindowsProcessReadOptions {
|
|
27
|
+
/** Called with the parent image before row validation, so partial Desktop evidence keeps its veto. */
|
|
28
|
+
onExecutable?: (path: string) => void;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
env?: NodeJS.ProcessEnv;
|
|
31
|
+
}
|
|
32
|
+
|
|
12
33
|
export interface DesktopSupervisionDeps {
|
|
13
34
|
targetPid?: number;
|
|
14
35
|
platform?: NodeJS.Platform;
|
|
15
|
-
run?: (command: string, args: string[]) => string;
|
|
36
|
+
run?: (command: string, args: string[], timeoutMs?: number) => string;
|
|
16
37
|
proc?: ProcReader;
|
|
17
38
|
readPid?: () => number | null;
|
|
18
39
|
readRuntimePortPid?: () => number | null;
|
|
40
|
+
windowsProcesses?: (pid: number, options: Pick<WindowsProcessReadOptions, "onExecutable" | "timeoutMs">) => WindowsProcessPair | null;
|
|
41
|
+
env?: NodeJS.ProcessEnv;
|
|
42
|
+
now?: () => number;
|
|
19
43
|
}
|
|
20
44
|
|
|
21
45
|
export function desktopSupervisionPaths(): { pid: string; runtimePort: string };
|
|
22
46
|
export const procfs: ProcReader;
|
|
23
47
|
export function processIdentity(pid: number, execute?: DesktopSupervisionDeps["run"], onExecutable?: (path: string) => void):
|
|
24
48
|
{ parent: number; executable: string } | null;
|
|
49
|
+
export function windowsPowerShellPath(env?: NodeJS.ProcessEnv): string;
|
|
50
|
+
export function windowsProcessPair(pid: number, execute?: DesktopSupervisionDeps["run"], options?: WindowsProcessReadOptions): WindowsProcessPair;
|
|
25
51
|
export function inspectDesktopSupervision(deps?: DesktopSupervisionDeps): SupervisionEvidence;
|
|
26
52
|
export function createSupervisionLatch(): { observe(evidence: SupervisionEvidence): boolean };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { accessSync, constants, readFileSync, readlinkSync, realpathSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, join, resolve, win32 } from "node:path";
|
|
5
5
|
|
|
6
6
|
// Node >=18 also imports this module from bin/ocx.mjs; do not import TS or Bun APIs.
|
|
7
|
-
function run(command, args) {
|
|
7
|
+
function run(command, args, timeout = 750) {
|
|
8
8
|
return execFileSync(command, args, {
|
|
9
|
-
encoding: "utf8", timeout
|
|
9
|
+
encoding: "utf8", timeout, maxBuffer: 128 * 1024, stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
|
|
10
10
|
}).trim();
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -69,17 +69,92 @@ export function processIdentity(pid, execute = run, onExecutable) {
|
|
|
69
69
|
return { parent: Number(row[1]), executable: realpathSync(row[2]) };
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Windows has no ps or procfs that Node can read, so one bounded Win32_Process read
|
|
74
|
+
* answers the runtime row and its parent's row. Each inspection shares one budget
|
|
75
|
+
* across its two snapshots; the startup diagnostic runs two inspections, and both
|
|
76
|
+
* must fit the 15 s Windows startup probe (src/codex/autostart-health.ts).
|
|
77
|
+
*/
|
|
78
|
+
const WINDOWS_INSPECTION_BUDGET_MS = 6_000;
|
|
79
|
+
const WINDOWS_ACQUISITION_TIMEOUT_MS = 3_000;
|
|
80
|
+
const WINDOWS_MIN_ACQUISITION_MS = 250;
|
|
81
|
+
const WINDOWS_DESKTOP_IMAGE = "opencodex-desktop.exe";
|
|
82
|
+
|
|
83
|
+
/** Mirrors src/update/npm-invocation.mjs: plain ESM cannot reach the FFI system-directory resolver. */
|
|
84
|
+
export function windowsPowerShellPath(env = process.env) {
|
|
85
|
+
const root = env.SystemRoot ?? env.windir;
|
|
86
|
+
const base = root && /^[A-Za-z]:\\/.test(root.replaceAll("/", "\\")) ? root : "C:\\Windows";
|
|
87
|
+
return win32.join(base, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function windowsProcessScript(pid) {
|
|
91
|
+
// pid is a validated safe integer; it only ever reaches a numeric WQL filter.
|
|
92
|
+
return [
|
|
93
|
+
"$ErrorActionPreference='Stop'",
|
|
94
|
+
"function Row($p) { if ($null -eq $p) { return $null }; [ordered]@{ pid=[string]$p.ProcessId; parent=[string]$p.ParentProcessId; exe=$p.ExecutablePath; session=[string]$p.SessionId; created=$(if ($p.CreationDate) { [string]$p.CreationDate.ToUniversalTime().Ticks } else { $null }) } }",
|
|
95
|
+
`$c=Get-CimInstance -ClassName Win32_Process -Filter 'ProcessId=${pid}'`,
|
|
96
|
+
"$p=$null",
|
|
97
|
+
"if ($c -and [uint32]$c.ParentProcessId -gt 0) { $p=Get-CimInstance -ClassName Win32_Process -Filter ('ProcessId=' + [uint32]$c.ParentProcessId) }",
|
|
98
|
+
"[ordered]@{ child=(Row $c); parent=(Row $p) } | ConvertTo-Json -Compress",
|
|
99
|
+
].join("; ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function windowsInteger(value) {
|
|
103
|
+
const text = typeof value === "number" || typeof value === "string" ? String(value) : "";
|
|
104
|
+
if (!/^\d{1,10}$/.test(text)) throw new Error("invalid process field");
|
|
105
|
+
return Number(text);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function windowsRow(row) {
|
|
109
|
+
if (row === null || row === undefined) return null;
|
|
110
|
+
if (typeof row !== "object" || Array.isArray(row)) throw new Error("invalid process row");
|
|
111
|
+
// Ticks exceed 2^53; they stay decimal strings and are compared as BigInt.
|
|
112
|
+
if (typeof row.created !== "string" || !/^\d{1,20}$/.test(row.created)) throw new Error("invalid creation time");
|
|
113
|
+
if (row.exe !== null && row.exe !== undefined && typeof row.exe !== "string") throw new Error("invalid image");
|
|
114
|
+
return {
|
|
115
|
+
pid: windowsInteger(row.pid), parent: windowsInteger(row.parent), exe: row.exe ?? null,
|
|
116
|
+
session: windowsInteger(row.session), created: row.created,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One bounded Win32_Process read of a process and its parent. */
|
|
121
|
+
export function windowsProcessPair(pid, execute = run, options = {}) {
|
|
122
|
+
if (!validPid(pid)) throw new Error("invalid pid");
|
|
123
|
+
const output = execute(windowsPowerShellPath(options.env), [
|
|
124
|
+
"-NoProfile", "-NoLogo", "-NonInteractive", "-Command", windowsProcessScript(pid),
|
|
125
|
+
], options.timeoutMs ?? WINDOWS_ACQUISITION_TIMEOUT_MS);
|
|
126
|
+
const raw = JSON.parse(output);
|
|
127
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid process pair");
|
|
128
|
+
// A Desktop parent seen before a later field fails validation must keep its veto.
|
|
129
|
+
if (raw.parent && typeof raw.parent === "object" && typeof raw.parent.exe === "string") options.onExecutable?.(raw.parent.exe);
|
|
130
|
+
return { child: windowsRow(raw.child), parent: windowsRow(raw.parent) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Only a drive-absolute image proves an install directory; UNC, device and relative forms do not. */
|
|
134
|
+
function windowsImage(path) {
|
|
135
|
+
if (typeof path !== "string") return null;
|
|
136
|
+
const text = path.replaceAll("/", "\\");
|
|
137
|
+
return /^[A-Za-z]:\\[^\\]/.test(text) ? win32.normalize(text) : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
72
140
|
/** Live supervision is evidence, never a durable ownership claim. */
|
|
73
141
|
export function inspectDesktopSupervision(deps = {}) {
|
|
74
142
|
const platform = deps.platform ?? process.platform;
|
|
75
|
-
if (platform !== "darwin" && platform !== "linux") return { kind: "unsupported" };
|
|
143
|
+
if (platform !== "darwin" && platform !== "linux" && platform !== "win32") return { kind: "unsupported" };
|
|
76
144
|
let desktopSeen = false;
|
|
77
145
|
const unknown = reason => ({ kind: "unknown", reason, desktopSeen });
|
|
78
|
-
const seen = executable => {
|
|
146
|
+
const seen = executable => {
|
|
147
|
+
const name = platform === "win32" ? win32.basename(executable).toLowerCase() : basename(executable);
|
|
148
|
+
if (name === (platform === "win32" ? WINDOWS_DESKTOP_IMAGE : "opencodex-desktop")) desktopSeen = true;
|
|
149
|
+
};
|
|
79
150
|
const readPid = deps.readPid ?? readPidFile;
|
|
80
151
|
const readPortPid = deps.readRuntimePortPid ?? readRuntimePortPid;
|
|
81
152
|
const proc = deps.proc ?? procfs;
|
|
82
153
|
const execute = deps.run ?? run;
|
|
154
|
+
const now = deps.now ?? Date.now;
|
|
155
|
+
const deadline = now() + WINDOWS_INSPECTION_BUDGET_MS;
|
|
156
|
+
const readWindowsPair = deps.windowsProcesses
|
|
157
|
+
?? ((pid, options) => windowsProcessPair(pid, execute, { ...options, env: deps.env }));
|
|
83
158
|
function identity(pid, parent = false) {
|
|
84
159
|
if (platform === "darwin") return processIdentity(pid, execute, parent ? seen : undefined);
|
|
85
160
|
const executable = proc.exe(pid);
|
|
@@ -88,6 +163,33 @@ export function inspectDesktopSupervision(deps = {}) {
|
|
|
88
163
|
if (!Number.isSafeInteger(parentPid) || parentPid < 0) throw new Error("invalid parent pid");
|
|
89
164
|
return { parent: parentPid, executable: realpathSync(executable) };
|
|
90
165
|
}
|
|
166
|
+
function windowsCapture(pidFile, portPid, runtimePid) {
|
|
167
|
+
// Windows has no reaped-parent signal to fall back on, so both runtime records must agree.
|
|
168
|
+
if (pidFile === null || portPid === null) return { error: "pid-record-missing" };
|
|
169
|
+
const remaining = deadline - now();
|
|
170
|
+
if (remaining < WINDOWS_MIN_ACQUISITION_MS) return { error: "probe-timeout" };
|
|
171
|
+
const pair = readWindowsPair(runtimePid, {
|
|
172
|
+
onExecutable: seen, timeoutMs: Math.min(WINDOWS_ACQUISITION_TIMEOUT_MS, remaining),
|
|
173
|
+
});
|
|
174
|
+
const child = pair?.child ?? null;
|
|
175
|
+
const parent = pair?.parent ?? null;
|
|
176
|
+
if (!child) return { error: "process-missing" };
|
|
177
|
+
if (child.pid !== runtimePid) return { error: "probe-failed" };
|
|
178
|
+
if (!parent) return { error: "parent-missing" };
|
|
179
|
+
if (parent.pid !== child.parent) return { error: "probe-failed" };
|
|
180
|
+
const app = windowsImage(parent.exe);
|
|
181
|
+
const proxy = windowsImage(child.exe);
|
|
182
|
+
if (!app || !proxy) return { error: "image-unqualified" };
|
|
183
|
+
const snapshot = { pidFile, portPid, runtimePid, child, parent };
|
|
184
|
+
// A reused parent PID belongs to a process created after the runtime it claims to supervise;
|
|
185
|
+
// its image proves nothing about the real parent, so it cannot answer "none" either.
|
|
186
|
+
if (BigInt(parent.created) >= BigInt(child.created)) return { error: "creation-order" };
|
|
187
|
+
if (win32.basename(app).toLowerCase() !== WINDOWS_DESKTOP_IMAGE) return { ...snapshot, kind: "none" };
|
|
188
|
+
seen(app);
|
|
189
|
+
if (proxy.toLowerCase() !== win32.join(win32.dirname(app), "ocx.exe").toLowerCase()) return { error: "proxy-mismatch" };
|
|
190
|
+
if (child.session !== parent.session) return { error: "session-mismatch" };
|
|
191
|
+
return { ...snapshot, kind: "desktop", app, proxy };
|
|
192
|
+
}
|
|
91
193
|
function capture() {
|
|
92
194
|
const pidFile = readPid();
|
|
93
195
|
const portPid = readPortPid();
|
|
@@ -95,6 +197,10 @@ export function inspectDesktopSupervision(deps = {}) {
|
|
|
95
197
|
if (pids.some(pid => !validPid(pid))) return { error: "invalid-pid" };
|
|
96
198
|
if (pids.some(pid => pid !== pids[0])) return { error: "pid-mismatch" };
|
|
97
199
|
const runtimePid = pids[0] ?? null;
|
|
200
|
+
if (platform === "win32") {
|
|
201
|
+
// Missing records are never positive absence on Windows: they could clear a Desktop veto.
|
|
202
|
+
return runtimePid === null ? { error: "pid-record-missing" } : windowsCapture(pidFile, portPid, runtimePid);
|
|
203
|
+
}
|
|
98
204
|
const child = runtimePid !== null ? identity(runtimePid) : null;
|
|
99
205
|
const parent = child && child.parent > 1 ? identity(child.parent, true) : null;
|
|
100
206
|
const snapshot = { pidFile, portPid, runtimePid, child, parent };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start the opt-in service health sentinel beside a managed service proxy (#6671).
|
|
3
|
+
*
|
|
4
|
+
* The sentinel (health-sentinel-main.ts) is a separate process with its own event loop. It asks
|
|
5
|
+
* this proxy's `/healthz` and, if the proxy stays alive but stops answering, terminates it so the
|
|
6
|
+
* service manager restarts it. This module decides whether to start one, starts it, and stops it
|
|
7
|
+
* on shutdown. It never blocks startup: spawning is synchronous and fire-and-forget, and every
|
|
8
|
+
* failure is logged and ignored.
|
|
9
|
+
*
|
|
10
|
+
* Only a managed service child gets a sentinel. A foreground `ocx start` has nobody to restart it,
|
|
11
|
+
* a desktop sidecar has the app's own supervisor, and a sibling proxy is not the service.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
import type { OcxConfig } from "../types";
|
|
18
|
+
import { serviceHealthRecoveryEnabled } from "../config/feature-flags";
|
|
19
|
+
import { getConfigDir } from "../config/paths";
|
|
20
|
+
import { getRuntimePortPath, readRuntimePort } from "../config/process-state";
|
|
21
|
+
import { isManagedServiceEnvironment } from "../config/serving-runtimes";
|
|
22
|
+
import { didRunDrainStartHooks, registerDrainStartHook, registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks";
|
|
23
|
+
import { isStandaloneBinary } from "../lib/standalone";
|
|
24
|
+
import { HEALTH_SENTINEL_ENV, readRecoveryEntries, type RecoveryEntry } from "./health-sentinel-main";
|
|
25
|
+
import { serviceStatePaths } from "./state";
|
|
26
|
+
|
|
27
|
+
/** Private argv the compiled binary handles before CLI dispatch (src/cli/index.ts). */
|
|
28
|
+
export const HEALTH_SENTINEL_STANDALONE_ARG = "__health-sentinel";
|
|
29
|
+
export const HEALTH_SENTINEL_HISTORY_FILE = "service-health-recovery.json";
|
|
30
|
+
export const HEALTH_SENTINEL_LOG_FILE = "service-health-sentinel.log";
|
|
31
|
+
/** A recovery this recent is announced by the replacement's first log lines. */
|
|
32
|
+
const RECENT_RECOVERY_NOTICE_MS = 15 * 60_000;
|
|
33
|
+
|
|
34
|
+
/** Variables the sentinel may inherit. Credentials and proxy settings are deliberately absent. */
|
|
35
|
+
const INHERITED_ENV = [
|
|
36
|
+
"PATH", "Path", "SystemRoot", "SYSTEMROOT", "windir", "WINDIR", "SystemDrive", "ComSpec",
|
|
37
|
+
"TEMP", "TMP", "TMPDIR", "HOME", "USERPROFILE", "LOCALAPPDATA", "APPDATA", "LANG", "TZ",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
export type HealthSentinelLaunchInput = {
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
managedService: boolean;
|
|
43
|
+
standalone: boolean;
|
|
44
|
+
sibling: boolean;
|
|
45
|
+
execPath: string;
|
|
46
|
+
entryPath: string;
|
|
47
|
+
entryExists: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type HealthSentinelLaunchDecision =
|
|
51
|
+
| { launch: true; argv: string[] }
|
|
52
|
+
/** `reason` is null when the feature is simply off, so nothing is logged. */
|
|
53
|
+
| { launch: false; reason: string | null };
|
|
54
|
+
|
|
55
|
+
export function decideHealthSentinelLaunch(input: HealthSentinelLaunchInput): HealthSentinelLaunchDecision {
|
|
56
|
+
if (!input.enabled) return { launch: false, reason: null };
|
|
57
|
+
if (!input.managedService) return { launch: false, reason: "this proxy is not running as the managed service" };
|
|
58
|
+
if (input.sibling) return { launch: false, reason: "this proxy is a sibling of the service" };
|
|
59
|
+
if (input.standalone) return { launch: true, argv: [input.execPath, HEALTH_SENTINEL_STANDALONE_ARG] };
|
|
60
|
+
if (!input.entryExists) return { launch: false, reason: "the sentinel entry is missing from this installation" };
|
|
61
|
+
return { launch: true, argv: [input.execPath, input.entryPath] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function healthSentinelEnvironment(
|
|
65
|
+
parent: NodeJS.ProcessEnv,
|
|
66
|
+
paths: { targetPid: number; runtimePath: string; historyPath: string; logPath: string; statePaths: readonly string[] },
|
|
67
|
+
): Record<string, string> {
|
|
68
|
+
const env: Record<string, string> = {};
|
|
69
|
+
for (const key of INHERITED_ENV) {
|
|
70
|
+
const value = parent[key];
|
|
71
|
+
if (value !== undefined) env[key] = value;
|
|
72
|
+
}
|
|
73
|
+
env[HEALTH_SENTINEL_ENV.targetPid] = String(paths.targetPid);
|
|
74
|
+
env[HEALTH_SENTINEL_ENV.runtimePath] = paths.runtimePath;
|
|
75
|
+
env[HEALTH_SENTINEL_ENV.historyPath] = paths.historyPath;
|
|
76
|
+
env[HEALTH_SENTINEL_ENV.logPath] = paths.logPath;
|
|
77
|
+
env[HEALTH_SENTINEL_ENV.statePaths] = JSON.stringify(paths.statePaths);
|
|
78
|
+
const timing = parent[HEALTH_SENTINEL_ENV.timing];
|
|
79
|
+
if (timing !== undefined) env[HEALTH_SENTINEL_ENV.timing] = timing;
|
|
80
|
+
return env;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The line a replacement prints when the sentinel recently terminated its predecessor. */
|
|
84
|
+
export function recentRecoveryNotice(entries: readonly RecoveryEntry[], selfPid: number, now: number): string | null {
|
|
85
|
+
const latest = entries.at(-1);
|
|
86
|
+
if (!latest || latest.pid === selfPid || latest.state === "skipped" || latest.state === "failed") return null;
|
|
87
|
+
if (now - latest.at < 0 || now - latest.at > RECENT_RECOVERY_NOTICE_MS) return null;
|
|
88
|
+
const seconds = Math.round((now - latest.at) / 1000);
|
|
89
|
+
const what = latest.state === "terminated" ? "terminated" : "tried to terminate";
|
|
90
|
+
return `⚠️ The service health sentinel ${what} the previous proxy (pid ${latest.pid}) ${seconds}s ago because it stopped answering /healthz. Requests it held were not replayed and may already have reached the upstream.`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type SpawnedSentinel = { pid: number; stdin: unknown; kill(): void; unref(): void };
|
|
94
|
+
|
|
95
|
+
export type HealthSentinelHandle = { pid: number; stop(): void };
|
|
96
|
+
|
|
97
|
+
let active: { child: SpawnedSentinel; detachHooks: () => void } | null = null;
|
|
98
|
+
|
|
99
|
+
export function startServiceHealthSentinel(
|
|
100
|
+
config: Pick<OcxConfig, "serviceHealthRecovery">,
|
|
101
|
+
deps: {
|
|
102
|
+
env?: NodeJS.ProcessEnv;
|
|
103
|
+
spawn?: (argv: string[], env: Record<string, string>) => SpawnedSentinel;
|
|
104
|
+
log?: (line: string) => void;
|
|
105
|
+
now?: () => number;
|
|
106
|
+
} = {},
|
|
107
|
+
): HealthSentinelHandle | null {
|
|
108
|
+
const env = deps.env ?? process.env;
|
|
109
|
+
const log = deps.log ?? ((line: string) => console.log(line));
|
|
110
|
+
const enabled = serviceHealthRecoveryEnabled(config);
|
|
111
|
+
if (!enabled) return null;
|
|
112
|
+
// A drain that already began (a stop or restart during startup) must not gain a sentinel.
|
|
113
|
+
if (didRunDrainStartHooks()) return null;
|
|
114
|
+
const configDir = getConfigDir();
|
|
115
|
+
const historyPath = join(configDir, HEALTH_SENTINEL_HISTORY_FILE);
|
|
116
|
+
const notice = recentRecoveryNotice(readRecoveryEntries(historyPath) ?? [], process.pid, (deps.now ?? Date.now)());
|
|
117
|
+
if (notice) log(notice);
|
|
118
|
+
const entryPath = fileURLToPath(new URL("./health-sentinel-main.ts", import.meta.url));
|
|
119
|
+
const runtime = readRuntimePort(process.pid);
|
|
120
|
+
const decision = decideHealthSentinelLaunch({
|
|
121
|
+
enabled,
|
|
122
|
+
managedService: isManagedServiceEnvironment(env),
|
|
123
|
+
standalone: isStandaloneBinary(),
|
|
124
|
+
sibling: runtime?.siblingOfPort !== undefined,
|
|
125
|
+
execPath: process.execPath,
|
|
126
|
+
entryPath,
|
|
127
|
+
entryExists: existsSync(entryPath),
|
|
128
|
+
});
|
|
129
|
+
if (!decision.launch) {
|
|
130
|
+
if (decision.reason) log(`service health recovery is enabled but not started: ${decision.reason}`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
stopServiceHealthSentinel();
|
|
134
|
+
const childEnv = healthSentinelEnvironment(env, {
|
|
135
|
+
targetPid: process.pid,
|
|
136
|
+
runtimePath: getRuntimePortPath(),
|
|
137
|
+
historyPath,
|
|
138
|
+
logPath: join(configDir, HEALTH_SENTINEL_LOG_FILE),
|
|
139
|
+
statePaths: serviceStatePaths(),
|
|
140
|
+
});
|
|
141
|
+
let child: SpawnedSentinel;
|
|
142
|
+
try {
|
|
143
|
+
child = (deps.spawn ?? ((argv, childEnvironment) => Bun.spawn(argv, {
|
|
144
|
+
// The sentinel's stdin is this process's lifeline: it sees EOF when this process exits.
|
|
145
|
+
stdin: "pipe",
|
|
146
|
+
stdout: "ignore",
|
|
147
|
+
stderr: "ignore",
|
|
148
|
+
env: childEnvironment,
|
|
149
|
+
windowsHide: true,
|
|
150
|
+
})))(decision.argv, childEnv);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
log(`service health recovery could not start its sentinel: ${error instanceof Error ? error.name : "error"}`);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
child.unref();
|
|
156
|
+
// Keep the subprocess and its stdin sink referenced for this process's lifetime.
|
|
157
|
+
const handle = { child, detachHooks: () => {} };
|
|
158
|
+
active = handle;
|
|
159
|
+
// Withdraw as soon as any drain begins: a deliberate stop whose drain stalls must stay a stop.
|
|
160
|
+
const detachDrain = registerDrainStartHook("service-health-sentinel", stopServiceHealthSentinel);
|
|
161
|
+
const detachShutdown = registerOptionalShutdownHook("service-health-sentinel", stopServiceHealthSentinel);
|
|
162
|
+
handle.detachHooks = () => { detachDrain(); detachShutdown(); };
|
|
163
|
+
log(`service health recovery: sentinel pid ${child.pid} watches this proxy (pid ${process.pid})`);
|
|
164
|
+
return { pid: child.pid, stop: stopServiceHealthSentinel };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Stop the sentinel before a deliberate shutdown so it never judges a draining process. */
|
|
168
|
+
export function stopServiceHealthSentinel(): void {
|
|
169
|
+
const current = active;
|
|
170
|
+
if (!current) return;
|
|
171
|
+
active = null;
|
|
172
|
+
current.detachHooks();
|
|
173
|
+
try { current.child.kill(); } catch { /* already gone */ }
|
|
174
|
+
const sink = current.child.stdin as { end?: () => unknown } | null;
|
|
175
|
+
try { sink?.end?.(); } catch { /* already closed */ }
|
|
176
|
+
}
|