@bitkyc08/opencodex 2.63.0-preview.20260923 → 2.64.0-preview.20260923
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/AGENTS_INSTALL.md +4 -3
- package/README.md +46 -33
- package/assets/download-linux.svg +10 -0
- package/assets/download-macos.svg +10 -0
- package/assets/download-windows.svg +10 -0
- package/bin/ocx.mjs +12 -4
- package/gui/dist/assets/App-D5eN1TID.js +50 -0
- package/gui/dist/assets/App-I5AnaSLh.css +1 -0
- package/gui/dist/assets/{Tray-B03pW-Uf.js → Tray-Bh_ErQDh.js} +1 -1
- package/gui/dist/assets/index-BEq4OCOz.js +86 -0
- package/gui/dist/assets/index-DiBRuK-d.css +1 -0
- package/gui/dist/assets/usage-companion-chart-DBG_37kQ.js +1 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/src/protocol.rs +5 -0
- package/package.json +4 -1
- package/src/adapters/anthropic.ts +66 -7
- package/src/adapters/codebuddy/adapter.ts +112 -16
- package/src/adapters/codebuddy/mcp-server.ts +180 -0
- package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
- package/src/adapters/codebuddy/tool-bridge.ts +597 -0
- package/src/adapters/coding-agent/protocol.ts +123 -10
- package/src/adapters/coding-agent/turn.ts +324 -2
- package/src/adapters/command-code-restored-schema.ts +112 -0
- package/src/adapters/command-code-tool-text.ts +589 -0
- package/src/adapters/command-code.ts +80 -6
- package/src/adapters/cursor/catalog.ts +12 -0
- package/src/adapters/cursor/current-request.ts +46 -0
- package/src/adapters/cursor/discovery.ts +1 -1
- package/src/adapters/cursor/effort-map.ts +4 -0
- package/src/adapters/cursor/native-exec.ts +15 -0
- package/src/adapters/cursor/protobuf-events.ts +40 -12
- package/src/adapters/cursor/protobuf-request.ts +83 -18
- package/src/adapters/cursor/request-builder.ts +5 -4
- package/src/adapters/cursor/tool-guidance.ts +1 -1
- package/src/adapters/devin/live-models.ts +7 -0
- package/src/adapters/inline-think-tags.ts +251 -0
- package/src/adapters/kiro/adapter.ts +1 -0
- package/src/adapters/kiro/stream.ts +5 -3
- package/src/adapters/kiro/usage.ts +4 -3
- package/src/adapters/mimo-free.ts +37 -18
- package/src/adapters/openai-chat/messages.ts +5 -5
- package/src/adapters/openai-chat/tool-schema.ts +124 -2
- package/src/adapters/openai-chat.ts +13 -3
- package/src/adapters/openai-responses/passthrough.ts +15 -2
- package/src/adapters/openai-responses/reasoning.ts +14 -1
- package/src/adapters/openai-responses/request-strips.ts +29 -3
- package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
- package/src/adapters/qoder/adapter.ts +15 -12
- package/src/adapters/qoder/scaffold-guard.ts +45 -13
- package/src/bridge/internal.ts +20 -0
- package/src/bridge/sse.ts +14 -5
- package/src/claude/agents-inject.ts +2 -1
- package/src/claude/desktop-applied-marker.ts +44 -0
- package/src/claude/desktop-profile.ts +22 -12
- package/src/claude/inbound.ts +8 -3
- package/src/cli/access.ts +22 -5
- package/src/cli/account-api.ts +6 -0
- package/src/cli/account-auth.ts +5 -2
- package/src/cli/account.ts +5 -6
- package/src/cli/aside-profiles.ts +61 -1
- package/src/cli/claude-desktop.ts +3 -2
- package/src/cli/claude.ts +3 -1
- package/src/cli/codex-cli-update.ts +2 -2
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/dispatch.ts +12 -4
- package/src/cli/index.ts +98 -10
- package/src/cli/registry.ts +1 -1
- package/src/cli/resolve.ts +113 -1
- package/src/cli/root.ts +5 -0
- package/src/cli/stop-approval.ts +186 -0
- package/src/cli/stop-report.ts +1 -1
- package/src/cli/system-restart-client.ts +19 -4
- package/src/client/hub-client.ts +25 -18
- package/src/clients/config-export.ts +12 -4
- package/src/codex/account-auto-switch.ts +59 -0
- package/src/codex/account-lifecycle.ts +7 -0
- package/src/codex/account-priority.ts +10 -0
- package/src/codex/account-usability.ts +9 -0
- package/src/codex/auth-api/account-list.ts +5 -0
- package/src/codex/auth-api/login-flow.ts +15 -5
- package/src/codex/auth-api/login-state.ts +5 -16
- package/src/codex/auth-api/pool-mode-gate.ts +30 -7
- package/src/codex/auth-api/routes.ts +39 -3
- package/src/codex/auth-context.ts +145 -17
- package/src/codex/catalog/build-entries.ts +2 -1
- package/src/codex/catalog/effort.ts +8 -2
- package/src/codex/catalog/metadata.ts +33 -7
- package/src/codex/catalog/native-models.ts +102 -5
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-models.ts +19 -6
- package/src/codex/cli-installation-targets.ts +44 -40
- package/src/codex/codex-write-lock.ts +32 -2
- package/src/codex/inject/multi-agent-v2.ts +90 -0
- package/src/codex/inject/plan.ts +365 -0
- package/src/codex/inject.ts +237 -362
- package/src/codex/log-guard/maintenance.ts +30 -13
- package/src/codex/project-config-warnings.ts +47 -7
- package/src/codex/prompt-layers/encoding.ts +41 -0
- package/src/codex/prompt-layers/toml-read.ts +1 -1
- package/src/codex/prompt-layers.ts +17 -13
- package/src/codex/prompt-text-probe.ts +20 -10
- package/src/codex/quota-observation-freshness.ts +34 -0
- package/src/codex/quota-rejection.ts +8 -4
- package/src/codex/quota.ts +4 -2
- package/src/codex/routing/selection.ts +32 -9
- package/src/codex/routing.ts +53 -11
- package/src/codex/subagent-model-fallback.ts +4 -3
- package/src/codex/sync.ts +11 -0
- package/src/codex/windows-installation-files.ts +33 -11
- package/src/codex/write-coordination.ts +4 -0
- package/src/combos/request.ts +6 -2
- package/src/companion/settings.ts +6 -1
- package/src/config/atomic-write.ts +12 -1
- package/src/config/derived-registries.ts +29 -0
- package/src/config/diagnostics.ts +17 -0
- package/src/config/live-reconcile.ts +11 -2
- package/src/config/load-degrade.ts +9 -2
- package/src/config/persist-unlocked.ts +4 -3
- package/src/config/persisted-mutation.ts +94 -0
- package/src/config/provider-validation.ts +1 -1
- package/src/config/proxy-env.ts +62 -12
- package/src/config/rebase-provenance.ts +80 -1
- package/src/config/schema/config-schema.ts +5 -0
- package/src/config/schema/leaf-validators.ts +50 -1
- package/src/config/subagent-models.ts +41 -15
- package/src/config.ts +18 -95
- package/src/generated/compatibility-version.json +330 -218
- package/src/generated/model-metadata.ts +8 -5
- package/src/github/star-state.ts +46 -6
- package/src/grok/inject.ts +74 -40
- package/src/grok/status.ts +11 -4
- package/src/integrations/cursor-effort-table.ts +50 -13
- package/src/integrations/raycast-detect.ts +19 -4
- package/src/integrations/serialize.ts +11 -1
- package/src/lib/bounded-body.ts +30 -0
- package/src/lib/crash-guard.ts +52 -4
- package/src/lib/local-aside-sync-contract.ts +41 -0
- package/src/lib/package-tree-integrity.ts +181 -9
- package/src/lib/proxy-env.ts +18 -1
- package/src/lib/request-failure-attribution.ts +1 -0
- package/src/lib/request-failure-model.ts +3 -0
- package/src/lib/service-secrets.ts +99 -2
- package/src/lib/socks5-fetch.ts +43 -14
- package/src/lib/token-estimate.ts +17 -2
- package/src/oauth/command-code.ts +3 -2
- package/src/oauth/generic-account-failover.ts +29 -1
- package/src/oauth/index.ts +24 -16
- package/src/oauth/login-flow-state.ts +5 -5
- package/src/oauth/meta-muse-device.ts +49 -7
- package/src/oauth/store.ts +74 -9
- package/src/providers/alibaba-region-backup.ts +16 -1
- package/src/providers/anthropic-fast.ts +89 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
- package/src/providers/anthropic-reset-grants.ts +329 -0
- package/src/providers/claude-cli-identity.ts +12 -0
- package/src/providers/command-code-efforts.ts +184 -102
- package/src/providers/derive.ts +11 -3
- package/src/providers/fastwire.ts +16 -3
- package/src/providers/label.ts +8 -3
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/quota/vendor-probes-oauth.ts +2 -1
- package/src/providers/reasoning-metadata.ts +38 -15
- package/src/providers/registry/entries-core.ts +95 -7
- package/src/providers/registry/entries-extended.ts +21 -10
- package/src/providers/registry/model-ids.ts +1 -0
- package/src/providers/registry/model-seeds.ts +31 -3
- package/src/providers/registry/types.ts +3 -1
- package/src/providers/resolved-model-policy-merge.ts +38 -8
- package/src/providers/resolved-model-policy.ts +4 -2
- package/src/providers/service-tier.ts +3 -1
- package/src/reasoning-effort.ts +6 -5
- package/src/responses/code-mode-helper-compat.ts +14 -4
- package/src/responses/code-mode-shell-input.ts +54 -0
- package/src/responses/custom-tool-compat.ts +173 -5
- package/src/responses/parser.ts +38 -2
- package/src/responses/reasoning-replay-cache.ts +26 -0
- package/src/router.ts +11 -1
- package/src/routing/history/indexer.ts +7 -2
- package/src/routing/history/schema.ts +3 -1
- package/src/server/adapter-resolve.ts +2 -2
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-completions.ts +14 -1
- package/src/server/chat-native.ts +17 -0
- package/src/server/claude-messages.ts +4 -3
- package/src/server/direct-local-http.ts +45 -19
- package/src/server/gui-session.ts +6 -15
- package/src/server/index/package-tree-guard.ts +53 -0
- package/src/server/index/serve-options.ts +17 -3
- package/src/server/index/startup-warnings.ts +17 -0
- package/src/server/index.ts +15 -17
- package/src/server/lifecycle.ts +8 -0
- package/src/server/live.ts +54 -4
- package/src/server/management/agent-settings-routes.ts +44 -8
- package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
- package/src/server/management/codex-prompt-routes.ts +5 -1
- package/src/server/management/config-routes.ts +14 -4
- package/src/server/management/oauth-account-routes.ts +17 -1
- package/src/server/management/provider-overwrite-carry.ts +164 -0
- package/src/server/management/provider-routes.ts +36 -6
- package/src/server/management/remote-workspace-routes.ts +2 -2
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/management/shadow-call-validation.ts +39 -0
- package/src/server/management/system-restart.ts +72 -6
- package/src/server/management-api.ts +16 -2
- package/src/server/management-auth.ts +52 -8
- package/src/server/proxy-liveness.ts +110 -4
- package/src/server/request-log.ts +26 -9
- package/src/server/request-metrics.ts +5 -0
- package/src/server/responses/adapter-continuation.ts +6 -3
- package/src/server/responses/adapter-dispatch.ts +65 -1
- package/src/server/responses/compact.ts +31 -1
- package/src/server/responses/compaction-routing.ts +28 -3
- package/src/server/responses/core-codex-account.ts +85 -26
- package/src/server/responses/core-combo-failure.ts +16 -15
- package/src/server/responses/core-normalize.ts +5 -1
- package/src/server/responses/core-opaque-recovery.ts +50 -2
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core-replay.ts +7 -0
- package/src/server/responses/core.ts +1 -1
- package/src/server/responses/encrypted-payload.ts +23 -9
- package/src/server/responses/passthrough-delivery.ts +55 -35
- package/src/server/responses/passthrough-dispatch.ts +12 -13
- package/src/server/responses/policy-fallback.ts +12 -1
- package/src/server/responses/request-prepare.ts +46 -7
- package/src/server/responses/request-spend.ts +31 -15
- package/src/server/responses/run-turn-execution.ts +6 -5
- package/src/server/responses/shadow-target-availability.ts +61 -0
- package/src/server/responses-custom-tool-repair.ts +2 -0
- package/src/service/claim.ts +185 -0
- package/src/service/cli.ts +10 -1
- package/src/service/guarded-manager-target.ts +151 -0
- package/src/service/guards.ts +47 -48
- package/src/service/managing-cli.ts +170 -0
- package/src/service/orchestration.ts +3 -1
- package/src/service/state.ts +4 -0
- package/src/service/systemd.ts +16 -1
- package/src/service.ts +1 -1
- package/src/types/config.ts +8 -0
- package/src/types/provider.ts +16 -0
- package/src/types/request.ts +10 -0
- package/src/types/tools.ts +9 -1
- package/src/types/wire.ts +68 -4
- package/src/types.ts +1 -0
- package/src/update/job.ts +10 -16
- package/src/update/npm-invocation.mjs +17 -16
- package/src/update/transactional-install.d.mts +22 -1
- package/src/update/transactional-install.mjs +176 -19
- package/src/update/update-failure-guidance.d.mts +8 -0
- package/src/update/update-failure-guidance.mjs +47 -0
- package/src/usage/expected-prices.ts +54 -12
- package/src/usage/log.ts +117 -3
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/usage/timeline.ts +34 -6
- package/src/usage/user-cost-overlay-reconciler.ts +3 -3
- package/src/vision/reasoning.ts +2 -4
- package/src/web-search/xai-executor.ts +14 -4
- package/gui/dist/assets/App-EJxiUFMq.js +0 -50
- package/gui/dist/assets/index-BmJwNBHL.js +0 -86
- package/gui/dist/assets/index-DdDunwDb.css +0 -1
- package/gui/dist/assets/usage-companion-chart-IftE60UK.js +0 -1
- package/src/adapters/kiro-thinking.ts +0 -112
package/src/cli/index.ts
CHANGED
|
@@ -96,7 +96,18 @@ import {
|
|
|
96
96
|
import { createReadinessGate } from "../server/readiness";
|
|
97
97
|
import { isApiAuthRequired } from "../server/auth-cors";
|
|
98
98
|
import { runReady, type ReadyArgs } from "./ready";
|
|
99
|
-
import { runResolve, type ResolveArgs } from "./resolve";
|
|
99
|
+
import { runResolve, type ResolveArgs, type ResolveJson } from "./resolve";
|
|
100
|
+
import {
|
|
101
|
+
approvalChanged,
|
|
102
|
+
guardFinalStopSummary,
|
|
103
|
+
managerStillActive,
|
|
104
|
+
runApprovedStop,
|
|
105
|
+
runGuardedManagerStep,
|
|
106
|
+
settleApprovedTarget,
|
|
107
|
+
type GuardedStopSnapshot,
|
|
108
|
+
type StopApproval,
|
|
109
|
+
} from "./stop-approval";
|
|
110
|
+
import { inspectGuardedManagerTarget, observeGuardedManagerStopped } from "../service/guarded-manager-target";
|
|
100
111
|
import { summarizeStopRun, type StopOutcome, type StopRunRecord } from "./stop-report";
|
|
101
112
|
import { runCli } from "./root";
|
|
102
113
|
import { isProcessAlive, ProxyOwnershipRefusedError, refusalNextStep, stopProxy } from "../lib/process-control";
|
|
@@ -181,6 +192,16 @@ async function refreshOwnedRaycastCatalog(
|
|
|
181
192
|
|
|
182
193
|
initializeNodeLauncherContext();
|
|
183
194
|
|
|
195
|
+
// The compiled executable is also the capture-only MCP server's launcher.
|
|
196
|
+
// Handle this private entrypoint before CLI preflight or command dispatch.
|
|
197
|
+
if (process.argv[2] === "__codebuddy-mcp") {
|
|
198
|
+
const { runCodeBuddyMcpServer } = await import("../adapters/codebuddy/mcp-server");
|
|
199
|
+
await runCodeBuddyMcpServer(process.argv[3] ?? "");
|
|
200
|
+
// The MCP stdio loop owns this process until stdin closes; do not fall through
|
|
201
|
+
// to ordinary CLI dispatch or exit after the handshake completes.
|
|
202
|
+
await new Promise<never>(() => {});
|
|
203
|
+
}
|
|
204
|
+
|
|
184
205
|
// Head: version/help early exits, `ready` pre-parse (exit 64 before any
|
|
185
206
|
// preflight), and the bounded Codex-shim auto-restore preflight live in
|
|
186
207
|
// src/cli/root.ts (Phase 1 of the CLI deepening). runCli exits for
|
|
@@ -556,7 +577,7 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
556
577
|
}
|
|
557
578
|
|
|
558
579
|
const { server, serverModule, port, readinessGate, config } = boundStart;
|
|
559
|
-
const { drainAndShutdown, isRecyclingForExit } = serverModule;
|
|
580
|
+
const { drainAndShutdown, isRecyclingForExit, noteExplicitShutdownRequested } = serverModule;
|
|
560
581
|
// Records are visible now; background work may observe this runtime without a gap.
|
|
561
582
|
scheduleCatalogPrewarm();
|
|
562
583
|
installCrashGuards();
|
|
@@ -627,6 +648,7 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
627
648
|
}
|
|
628
649
|
shuttingDown = true;
|
|
629
650
|
shutdownStartedAt = now;
|
|
651
|
+
noteExplicitShutdownRequested(); // an automatic package restart must not hand off after this
|
|
630
652
|
console.log("\n🛑 Shutting down opencodex proxy...");
|
|
631
653
|
void (async () => {
|
|
632
654
|
let shutdownSucceeded = false;
|
|
@@ -882,6 +904,8 @@ function reportRestartFailure(result: Extract<ProxyRestartResult, { ok: false }>
|
|
|
882
904
|
} else if (code === "restart_version_skew") {
|
|
883
905
|
console.error("❌ The running proxy reports a different OpenCodex version than this CLI; restarting in place would respawn the old installation.");
|
|
884
906
|
console.error(" Run `ocx stop` and then `ocx start` from this installation instead.");
|
|
907
|
+
} else if (code === "restart_package_tree_unsettled") {
|
|
908
|
+
console.error("❌ The proxy's package files are still being replaced; wait for the install to finish, then run `ocx restart` again.");
|
|
885
909
|
} else {
|
|
886
910
|
console.error("❌ Proxy restart request could not be confirmed; no fallback stop/start was attempted.");
|
|
887
911
|
}
|
|
@@ -898,7 +922,7 @@ async function handleProxyRestart(
|
|
|
898
922
|
const deadlineAt = Date.now() + PROXY_RESTART_OBSERVE_MS;
|
|
899
923
|
const result = await runProxyRestart({
|
|
900
924
|
findLive: () => discoverStableProxyForRestart({
|
|
901
|
-
findLive: () => findLiveProxy({ deadlineAt, attempts: 2 }),
|
|
925
|
+
findLive: () => findLiveProxy({ deadlineAt, attempts: 2, acceptPackageTreeFenced: true }),
|
|
902
926
|
expired: () => Date.now() >= deadlineAt,
|
|
903
927
|
}),
|
|
904
928
|
startWhenStopped,
|
|
@@ -990,13 +1014,37 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole
|
|
|
990
1014
|
return { historyOnly, historyDeferred, other };
|
|
991
1015
|
}
|
|
992
1016
|
|
|
993
|
-
async function handleStop() {
|
|
1017
|
+
async function handleStop(approval?: StopApproval) {
|
|
994
1018
|
const lease = acquireOwnershipMutationLease(serviceStatePaths());
|
|
995
|
-
try {
|
|
1019
|
+
try {
|
|
1020
|
+
if (!approval) return await handleStopUnlocked();
|
|
1021
|
+
return await runApprovedStop(
|
|
1022
|
+
approval,
|
|
1023
|
+
async () => {
|
|
1024
|
+
const lines: string[] = [];
|
|
1025
|
+
const code = await runResolve({ json: true }, {
|
|
1026
|
+
stdout: { log: line => { lines.push(line); } },
|
|
1027
|
+
stderr: { error: () => {} },
|
|
1028
|
+
});
|
|
1029
|
+
if (code !== 0 || lines.length !== 1) return null;
|
|
1030
|
+
try { return JSON.parse(lines[0]!) as ResolveJson; }
|
|
1031
|
+
catch { return null; }
|
|
1032
|
+
},
|
|
1033
|
+
() => {
|
|
1034
|
+
const pid = readPid();
|
|
1035
|
+
const runtime = pid === null ? null : readRuntimePort(pid);
|
|
1036
|
+
return pid && runtime?.port ? {
|
|
1037
|
+
pid, port: runtime.port, hostname: runtime.hostname ?? "",
|
|
1038
|
+
} : null;
|
|
1039
|
+
},
|
|
1040
|
+
() => inspectGuardedManagerTarget(approval.pid, approval.port),
|
|
1041
|
+
snapshot => handleStopUnlocked(snapshot),
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
996
1044
|
finally { lease.release(); }
|
|
997
1045
|
}
|
|
998
1046
|
|
|
999
|
-
async function handleStopUnlocked() {
|
|
1047
|
+
async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) {
|
|
1000
1048
|
// The receipt must name the endpoint the owner was stopping — an obligation nobody can
|
|
1001
1049
|
// locate cannot be proven discharged. Only the runtime record knows it; a proxy started
|
|
1002
1050
|
// with an explicit --port is not on the configured one.
|
|
@@ -1137,8 +1185,23 @@ async function handleStopUnlocked() {
|
|
|
1137
1185
|
// to this process.
|
|
1138
1186
|
return graceful && !teardownNonce;
|
|
1139
1187
|
};
|
|
1188
|
+
const approvedEndpoint = snapshot
|
|
1189
|
+
? { hostname: probeHostname(snapshot.approval.hostname || undefined), port: snapshot.approval.port }
|
|
1190
|
+
: null;
|
|
1191
|
+
let guardedStep: Awaited<ReturnType<typeof runGuardedManagerStep>> | null = null;
|
|
1140
1192
|
try {
|
|
1141
|
-
const serviceStop =
|
|
1193
|
+
const serviceStop = snapshot
|
|
1194
|
+
? (guardedStep = await runGuardedManagerStep(snapshot, {
|
|
1195
|
+
revalidateManager: () => inspectGuardedManagerTarget(snapshot.approval.pid, snapshot.approval.port),
|
|
1196
|
+
stopManager: () => {
|
|
1197
|
+
if (approvedEndpoint) claimTeardown(approvedEndpoint, "exact");
|
|
1198
|
+
return stopServiceIfInstalledDetailed();
|
|
1199
|
+
},
|
|
1200
|
+
signalApproved: () => stopWithDeferral(snapshot.approval.pid, approvedEndpoint),
|
|
1201
|
+
settle: () => settleApprovedTarget(snapshot.approval),
|
|
1202
|
+
managerState: () => observeGuardedManagerStopped(snapshot.manager),
|
|
1203
|
+
})).service
|
|
1204
|
+
: stopServiceIfInstalledDetailed();
|
|
1142
1205
|
record.service = serviceStop;
|
|
1143
1206
|
stoppedService = serviceStop === "stopped" || serviceStop === "stopped-respawnable";
|
|
1144
1207
|
schedulerCanRespawn = serviceStop === "stopped-respawnable";
|
|
@@ -1171,6 +1234,25 @@ async function handleStopUnlocked() {
|
|
|
1171
1234
|
}
|
|
1172
1235
|
}
|
|
1173
1236
|
|
|
1237
|
+
if (snapshot) {
|
|
1238
|
+
if (guardedStep?.effect === "approval-changed") {
|
|
1239
|
+
return approvalChanged();
|
|
1240
|
+
}
|
|
1241
|
+
if (guardedStep?.effect === "manager-still-active") {
|
|
1242
|
+
return managerStillActive(record.service, record);
|
|
1243
|
+
}
|
|
1244
|
+
if (guardedStep?.effect === "stopped") {
|
|
1245
|
+
record.proxy = guardedStep.proxy;
|
|
1246
|
+
nativeRestoreHandledByProxy = guardedStep.handledByProxy;
|
|
1247
|
+
removePid(snapshot.approval.pid);
|
|
1248
|
+
removeRuntimePort(snapshot.approval.pid);
|
|
1249
|
+
} else {
|
|
1250
|
+
stopFailed = true;
|
|
1251
|
+
ownershipBlocked = true;
|
|
1252
|
+
record.proxy = "unknown";
|
|
1253
|
+
console.error("The approved runtime could not be verified after the guarded stop step.");
|
|
1254
|
+
}
|
|
1255
|
+
} else {
|
|
1174
1256
|
const pid = readPid();
|
|
1175
1257
|
if (pid) {
|
|
1176
1258
|
try {
|
|
@@ -1212,7 +1294,7 @@ async function handleStopUnlocked() {
|
|
|
1212
1294
|
const staleRuntimePid = readRuntimePort()?.pid ?? null;
|
|
1213
1295
|
// Orphan recovery: a live proxy can outlive its pid file (crash, manual delete,
|
|
1214
1296
|
// corrupt file). Identity-checked liveness still finds it via the runtime record.
|
|
1215
|
-
const live = await findLiveProxy();
|
|
1297
|
+
const live = await findLiveProxy({ acceptPackageTreeFenced: true });
|
|
1216
1298
|
if (live?.pid) {
|
|
1217
1299
|
try {
|
|
1218
1300
|
// The probe already found where it answers, and on this path the runtime record is
|
|
@@ -1260,6 +1342,7 @@ async function handleStopUnlocked() {
|
|
|
1260
1342
|
removeRuntimePortIfPidIs(staleRuntimePid);
|
|
1261
1343
|
}
|
|
1262
1344
|
}
|
|
1345
|
+
}
|
|
1263
1346
|
// Environment ownership is independent from service ownership. Always roll back
|
|
1264
1347
|
// current-home variables; the helper refuses foreign markers on its own.
|
|
1265
1348
|
try { revertSystemEnv(); } catch { /* best-effort */ }
|
|
@@ -1429,12 +1512,17 @@ async function handleStopUnlocked() {
|
|
|
1429
1512
|
? STOP_HISTORY_DEFERRED_EXIT_CODE
|
|
1430
1513
|
: 1;
|
|
1431
1514
|
}
|
|
1432
|
-
const
|
|
1515
|
+
const signals = {
|
|
1433
1516
|
failed: stopFailed,
|
|
1434
1517
|
historyOnly: historyOnlyFailure,
|
|
1435
1518
|
historyDeferred: historyDeferredNonces !== null,
|
|
1436
1519
|
exitCode: Number(process.exitCode ?? 0),
|
|
1437
|
-
}
|
|
1520
|
+
};
|
|
1521
|
+
const summary = summarizeStopRun(record, signals);
|
|
1522
|
+
if (snapshot && !stopFailed) {
|
|
1523
|
+
return guardFinalStopSummary(record, () => observeGuardedManagerStopped(snapshot.manager),
|
|
1524
|
+
() => ({ ok: !stopFailed, summary }));
|
|
1525
|
+
}
|
|
1438
1526
|
return { ok: !stopFailed, summary };
|
|
1439
1527
|
}
|
|
1440
1528
|
|
package/src/cli/registry.ts
CHANGED
|
@@ -83,7 +83,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
name: "service",
|
|
86
|
-
usage: "ocx service [install|repair|restart|start|stop|status|uninstall|remove]",
|
|
86
|
+
usage: "ocx service [install|repair|restart|start|stop|status|uninstall|remove|claim]",
|
|
87
87
|
summary: "Run as a background service.",
|
|
88
88
|
details: [
|
|
89
89
|
"With no subcommand, installs when absent or repairs an existing service.",
|
package/src/cli/resolve.ts
CHANGED
|
@@ -45,6 +45,20 @@ import {
|
|
|
45
45
|
type LiveProxy,
|
|
46
46
|
} from "../server/proxy-liveness";
|
|
47
47
|
import { endpointsToProve, everyEndpointProvenDownAsync, type ProbeEndpoint } from "./uninstall-plan";
|
|
48
|
+
import {
|
|
49
|
+
resolveServiceOwnership,
|
|
50
|
+
resolveServiceState,
|
|
51
|
+
type ServiceInstallState,
|
|
52
|
+
type ServiceOwnershipResolution,
|
|
53
|
+
type ServiceStateResolution,
|
|
54
|
+
} from "../service/state";
|
|
55
|
+
import {
|
|
56
|
+
assessServiceTakeoverCompatibility,
|
|
57
|
+
type ManagingCliObservation,
|
|
58
|
+
type ManagingCliRole,
|
|
59
|
+
} from "../service/ownership-compatibility";
|
|
60
|
+
import { observeManagingClis } from "../service/managing-cli";
|
|
61
|
+
import { SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION } from "../service/install-state-contract.mjs";
|
|
48
62
|
|
|
49
63
|
/** Wire version of the resolve document. Bump only on an incompatible shape change. */
|
|
50
64
|
export const RESOLVE_SCHEMA = "ocx-resolve/1";
|
|
@@ -86,8 +100,26 @@ export interface ResolveJson {
|
|
|
86
100
|
source: LiveProxy["source"];
|
|
87
101
|
};
|
|
88
102
|
liveness: ResolveLivenessJson;
|
|
103
|
+
/**
|
|
104
|
+
* The recorded runtime owner, in the CLI's own three answers. `unknown` is on the wire
|
|
105
|
+
* deliberately: it never changes the exit code — the liveness verdict is still
|
|
106
|
+
* trustworthy — and the embedding shell fails closed on it rather than asking consent
|
|
107
|
+
* against a record it could not read.
|
|
108
|
+
*/
|
|
109
|
+
ownership: ServiceOwnershipResolution;
|
|
110
|
+
/**
|
|
111
|
+
* Whether a desktop takeover can be offered, and the token that binds that approval to
|
|
112
|
+
* the exact subject and managing-CLI observations a later `ocx service claim` must find
|
|
113
|
+
* unchanged. `ownership-unknown` is produced only here: it is a wire reason, not a new
|
|
114
|
+
* member of `ServiceTakeoverCompatibility`'s union.
|
|
115
|
+
*/
|
|
116
|
+
takeover: ResolveTakeover;
|
|
89
117
|
}
|
|
90
118
|
|
|
119
|
+
export type ResolveTakeover =
|
|
120
|
+
| { kind: "supported"; protocolVersion: number; minimumCliVersion: string; token: string }
|
|
121
|
+
| { kind: "blocked"; reason: string; detail: string; minimumCliVersion: string };
|
|
122
|
+
|
|
91
123
|
export interface ResolveArgs {
|
|
92
124
|
json: boolean;
|
|
93
125
|
}
|
|
@@ -111,6 +143,14 @@ export interface ResolveIo {
|
|
|
111
143
|
/** Tri-state endpoint probe; production default runs in-process for compiled standalone binaries. */
|
|
112
144
|
probeEndpoint?: (endpoint: ProbeEndpoint) => EndpointLiveness | Promise<EndpointLiveness>;
|
|
113
145
|
cliVersion?: () => string;
|
|
146
|
+
/** Recorded-ownership resolver; production default is resolveServiceOwnership. */
|
|
147
|
+
resolveOwnership?: () => ServiceOwnershipResolution;
|
|
148
|
+
/** Full install-state resolver; production default is resolveServiceState. */
|
|
149
|
+
resolveState?: () => ServiceStateResolution;
|
|
150
|
+
/** Managing-CLI observer; production default is observeManagingClis. */
|
|
151
|
+
observeManagers?: (
|
|
152
|
+
state: ServiceInstallState | null,
|
|
153
|
+
) => Readonly<Record<ManagingCliRole, ManagingCliObservation>>;
|
|
114
154
|
stdout?: { log: (s: string) => void };
|
|
115
155
|
stderr?: { error: (s: string) => void };
|
|
116
156
|
}
|
|
@@ -136,6 +176,8 @@ export function buildResolveJson(
|
|
|
136
176
|
live: LiveProxy | null,
|
|
137
177
|
configHome: string,
|
|
138
178
|
cliVersion: string,
|
|
179
|
+
ownership: ServiceOwnershipResolution,
|
|
180
|
+
takeover: ResolveTakeover,
|
|
139
181
|
): ResolveJson {
|
|
140
182
|
const configured = config.port ?? RESOLVE_DEFAULT_PORT;
|
|
141
183
|
return {
|
|
@@ -148,6 +190,8 @@ export function buildResolveJson(
|
|
|
148
190
|
source: live ? live.source : "config",
|
|
149
191
|
},
|
|
150
192
|
liveness: livenessJson(live),
|
|
193
|
+
ownership,
|
|
194
|
+
takeover,
|
|
151
195
|
};
|
|
152
196
|
}
|
|
153
197
|
|
|
@@ -164,6 +208,19 @@ function reportHuman(json: ResolveJson, stdout: { log: (s: string) => void }): v
|
|
|
164
208
|
} else {
|
|
165
209
|
stdout.log(`No live proxy (absence proven); effective port ${json.port.effective} (configured).`);
|
|
166
210
|
}
|
|
211
|
+
const ownership = json.ownership;
|
|
212
|
+
if (ownership.kind === "owned") {
|
|
213
|
+
stdout.log(`Owner: ${ownership.ownership.owner} (install ${ownership.ownership.installId}, generation ${ownership.ownership.consentGeneration})`);
|
|
214
|
+
} else if (ownership.kind === "unknown") {
|
|
215
|
+
stdout.log(`Owner: unknown (${ownership.reason})`);
|
|
216
|
+
} else {
|
|
217
|
+
stdout.log("Owner: none recorded");
|
|
218
|
+
}
|
|
219
|
+
stdout.log(
|
|
220
|
+
json.takeover.kind === "supported"
|
|
221
|
+
? "Takeover: supported"
|
|
222
|
+
: `Takeover: blocked (${json.takeover.reason}: ${json.takeover.detail})`,
|
|
223
|
+
);
|
|
167
224
|
}
|
|
168
225
|
|
|
169
226
|
/**
|
|
@@ -181,6 +238,9 @@ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise
|
|
|
181
238
|
const readRuntime = io.readRuntime ?? readRuntimePort;
|
|
182
239
|
const probeEndpoint = io.probeEndpoint ?? probeEndpointLiveness;
|
|
183
240
|
const cliVersion = io.cliVersion ?? packageVersion;
|
|
241
|
+
const resolveOwnership = io.resolveOwnership ?? resolveServiceOwnership;
|
|
242
|
+
const resolveState = io.resolveState ?? resolveServiceState;
|
|
243
|
+
const observeManagers = io.observeManagers ?? observeManagingClis;
|
|
184
244
|
const configHome = configDir();
|
|
185
245
|
let diagnostics: ConfigDiagnostics;
|
|
186
246
|
try {
|
|
@@ -223,7 +283,59 @@ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise
|
|
|
223
283
|
return 1;
|
|
224
284
|
}
|
|
225
285
|
}
|
|
226
|
-
|
|
286
|
+
let ownership: ServiceOwnershipResolution;
|
|
287
|
+
try {
|
|
288
|
+
ownership = resolveOwnership();
|
|
289
|
+
} catch (error) {
|
|
290
|
+
ownership = { kind: "unknown", reason: error instanceof Error ? error.message : String(error) };
|
|
291
|
+
}
|
|
292
|
+
let takeover: ResolveTakeover;
|
|
293
|
+
if (!live) {
|
|
294
|
+
// A proven absence may authorize start, but there is no runtime to take over.
|
|
295
|
+
// Avoid synchronous managing-CLI version probes on this launch path.
|
|
296
|
+
takeover = {
|
|
297
|
+
kind: "blocked",
|
|
298
|
+
reason: "runtime-absent",
|
|
299
|
+
detail: "no live runtime is available for takeover",
|
|
300
|
+
minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
301
|
+
};
|
|
302
|
+
} else if (ownership.kind === "unknown") {
|
|
303
|
+
// The claim cannot be read, so nothing can be approved against it. This reason is a
|
|
304
|
+
// wire answer, not a new member of the compatibility union.
|
|
305
|
+
takeover = {
|
|
306
|
+
kind: "blocked",
|
|
307
|
+
reason: "ownership-unknown",
|
|
308
|
+
detail: ownership.reason,
|
|
309
|
+
minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
310
|
+
};
|
|
311
|
+
} else {
|
|
312
|
+
try {
|
|
313
|
+
const resolved = resolveState();
|
|
314
|
+
if (resolved.kind === "unknown") {
|
|
315
|
+
takeover = {
|
|
316
|
+
kind: "blocked",
|
|
317
|
+
reason: "ownership-unknown",
|
|
318
|
+
detail: resolved.reason,
|
|
319
|
+
minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
320
|
+
};
|
|
321
|
+
} else {
|
|
322
|
+
const resolvedState = resolved.kind === "state" ? resolved.state : null;
|
|
323
|
+
takeover = assessServiceTakeoverCompatibility({
|
|
324
|
+
state: resolvedState,
|
|
325
|
+
subject: ownership,
|
|
326
|
+
managers: observeManagers(resolvedState),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
takeover = {
|
|
331
|
+
kind: "blocked",
|
|
332
|
+
reason: "managing-cli-unknown",
|
|
333
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
334
|
+
minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const json = buildResolveJson(diagnostics.config, live, configHome, cliVersion(), ownership, takeover);
|
|
227
339
|
if (args.json) stdout.log(JSON.stringify(json));
|
|
228
340
|
else reportHuman(json, stdout);
|
|
229
341
|
return 0;
|
package/src/cli/root.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./help";
|
|
12
12
|
import { parseReadyArgs, type ReadyArgs } from "./ready";
|
|
13
13
|
import { parseResolveArgs, type ResolveArgs } from "./resolve";
|
|
14
|
+
import { parseStopApproval } from "./stop-approval";
|
|
14
15
|
import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore";
|
|
15
16
|
|
|
16
17
|
export interface CliHead {
|
|
@@ -103,6 +104,10 @@ export async function runCli(argv: string[]): Promise<CliHead> {
|
|
|
103
104
|
return head;
|
|
104
105
|
}
|
|
105
106
|
case "command":
|
|
107
|
+
if (head.command === "stop" && !parseStopApproval(head.args.slice(1)).ok) {
|
|
108
|
+
console.error("Usage: ocx stop [--json [--expect-pid <pid> --expect-port <port> --expect-hostname <host> --expect-config-home <home> --expect-cli-version <version> --expect-compatibility-token <hex>]]");
|
|
109
|
+
process.exit(64);
|
|
110
|
+
}
|
|
106
111
|
maybeAutoRestoreCodexShim(head.command, head.args);
|
|
107
112
|
return head;
|
|
108
113
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/** Opt-in consistency guard for a desktop-approved stop. Not a consent proof. */
|
|
2
|
+
import { waitForExit } from "../lib/process-control";
|
|
3
|
+
import { waitForPortAvailable } from "../server/ports";
|
|
4
|
+
import { probeEndpointLiveness, probeHostname } from "../server/proxy-liveness";
|
|
5
|
+
import type { GuardedManagerTarget } from "../service/guarded-manager-target";
|
|
6
|
+
import type { ResolveJson } from "./resolve";
|
|
7
|
+
import { STOP_SUMMARY_SCHEMA, type StopOutcome, type StopRunRecord, type StopServiceOutcome } from "./stop-report";
|
|
8
|
+
|
|
9
|
+
export interface StopApproval {
|
|
10
|
+
pid: number;
|
|
11
|
+
port: number;
|
|
12
|
+
hostname: string;
|
|
13
|
+
configHome: string;
|
|
14
|
+
cliVersion: string;
|
|
15
|
+
compatibilityToken: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GuardedStopSnapshot {
|
|
19
|
+
approval: StopApproval;
|
|
20
|
+
manager: Exclude<GuardedManagerTarget, { kind: "unknown" }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sameGuardedManager(
|
|
24
|
+
expected: GuardedStopSnapshot["manager"],
|
|
25
|
+
current: GuardedManagerTarget,
|
|
26
|
+
): boolean {
|
|
27
|
+
if (expected.kind === "absent") return current.kind === "absent";
|
|
28
|
+
return current.kind === "bound" && current.pid === expected.pid
|
|
29
|
+
&& current.managerPid === expected.managerPid && current.backend === expected.backend;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type StopApprovalParse = { ok: true; json: boolean; approval: StopApproval | null } | { ok: false };
|
|
33
|
+
|
|
34
|
+
export function parseStopApproval(argv: string[]): StopApprovalParse {
|
|
35
|
+
if (argv.length === 0) return { ok: true, json: false, approval: null };
|
|
36
|
+
if (argv.every(arg => arg === "--json")) return { ok: true, json: true, approval: null };
|
|
37
|
+
const names = ["--expect-pid", "--expect-port", "--expect-hostname",
|
|
38
|
+
"--expect-config-home", "--expect-cli-version", "--expect-compatibility-token"];
|
|
39
|
+
if (argv.filter(arg => arg === "--json").length !== 1) return { ok: false };
|
|
40
|
+
const values = new Map<string, string>();
|
|
41
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
42
|
+
const flag = argv[index]!;
|
|
43
|
+
if (flag === "--json") continue;
|
|
44
|
+
if (!names.includes(flag) || values.has(flag) || index + 1 >= argv.length) return { ok: false };
|
|
45
|
+
values.set(flag, argv[++index]!);
|
|
46
|
+
}
|
|
47
|
+
if (values.size !== names.length) return { ok: false };
|
|
48
|
+
const integer = (value: string | undefined, max: number): number | null => {
|
|
49
|
+
if (!value || !/^\d+$/.test(value)) return null;
|
|
50
|
+
const parsed = Number(value);
|
|
51
|
+
return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= max ? parsed : null;
|
|
52
|
+
};
|
|
53
|
+
const pid = integer(values.get("--expect-pid"), Number.MAX_SAFE_INTEGER);
|
|
54
|
+
const port = integer(values.get("--expect-port"), 65535);
|
|
55
|
+
const compatibilityToken = values.get("--expect-compatibility-token") ?? "";
|
|
56
|
+
const configHome = values.get("--expect-config-home") ?? "";
|
|
57
|
+
const cliVersion = values.get("--expect-cli-version") ?? "";
|
|
58
|
+
if (pid === null || port === null || !/^[a-f0-9]{64}$/.test(compatibilityToken)
|
|
59
|
+
|| !configHome || !cliVersion) return { ok: false };
|
|
60
|
+
return { ok: true, json: true, approval: {
|
|
61
|
+
pid, port, hostname: values.get("--expect-hostname") ?? "",
|
|
62
|
+
configHome, cliVersion, compatibilityToken,
|
|
63
|
+
} };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function matchesStopApproval(expected: StopApproval, now: ResolveJson): boolean {
|
|
67
|
+
return now.schema === "ocx-resolve/1" && now.liveness.status === "live"
|
|
68
|
+
&& now.liveness.pid === expected.pid && now.liveness.port === expected.port
|
|
69
|
+
&& now.port.effective === expected.port && (now.liveness.hostname ?? "") === expected.hostname
|
|
70
|
+
&& now.liveness.role !== "client" && now.configHome === expected.configHome
|
|
71
|
+
&& now.cliVersion === expected.cliVersion && now.ownership.kind !== "unknown"
|
|
72
|
+
&& now.takeover.kind === "supported" && now.takeover.token === expected.compatibilityToken;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function approvalChanged(): StopOutcome {
|
|
76
|
+
return { ok: false, summary: {
|
|
77
|
+
schema: STOP_SUMMARY_SCHEMA, ok: false, outcome: "approval-changed", exitCode: 1,
|
|
78
|
+
runtimeDown: false, service: "absent", proxy: "unknown", sharedTeardown: "skipped",
|
|
79
|
+
message: "The approved runtime or managing CLI changed; no stop was attempted.",
|
|
80
|
+
} };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function managerStillActive(
|
|
84
|
+
service: StopServiceOutcome,
|
|
85
|
+
record?: Pick<StopRunRecord, "proxy" | "sharedTeardown">,
|
|
86
|
+
): StopOutcome {
|
|
87
|
+
return { ok: false, summary: {
|
|
88
|
+
schema: STOP_SUMMARY_SCHEMA, ok: false, outcome: "manager-still-active", exitCode: 1,
|
|
89
|
+
runtimeDown: false, service,
|
|
90
|
+
proxy: record?.proxy ?? "unknown", sharedTeardown: record?.sharedTeardown ?? "skipped",
|
|
91
|
+
message: "The approved runtime and service manager could not both be confirmed stopped.",
|
|
92
|
+
} };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function runApprovedStop(
|
|
96
|
+
expected: StopApproval,
|
|
97
|
+
read: () => Promise<ResolveJson | null>,
|
|
98
|
+
tracked: () => { pid: number; port: number; hostname: string } | null,
|
|
99
|
+
managerTarget: () => GuardedManagerTarget,
|
|
100
|
+
stop: (snapshot: GuardedStopSnapshot) => Promise<StopOutcome>,
|
|
101
|
+
): Promise<StopOutcome> {
|
|
102
|
+
let snapshot: GuardedStopSnapshot | null = null;
|
|
103
|
+
try {
|
|
104
|
+
const now = await read();
|
|
105
|
+
const target = tracked();
|
|
106
|
+
const manager = managerTarget();
|
|
107
|
+
if (!now || !matchesStopApproval(expected, now) || !target
|
|
108
|
+
|| target.pid !== expected.pid || target.port !== expected.port
|
|
109
|
+
|| target.hostname !== expected.hostname || manager.kind === "unknown"
|
|
110
|
+
|| (manager.kind === "bound" && manager.pid !== expected.pid)) return approvalChanged();
|
|
111
|
+
snapshot = { approval: expected, manager };
|
|
112
|
+
} catch { return approvalChanged(); }
|
|
113
|
+
return snapshot ? stop(snapshot) : approvalChanged();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const GUARDED_SETTLE_MS = 5_000;
|
|
117
|
+
|
|
118
|
+
/** Compose the existing bounded PID/port waiters, then prove health absence. */
|
|
119
|
+
export async function settleApprovedTarget(
|
|
120
|
+
approved: StopApproval,
|
|
121
|
+
io: {
|
|
122
|
+
now?: () => number;
|
|
123
|
+
waitExit?: (pid: number, timeoutMs: number) => boolean;
|
|
124
|
+
waitPort?: typeof waitForPortAvailable;
|
|
125
|
+
probe?: typeof probeEndpointLiveness;
|
|
126
|
+
} = {},
|
|
127
|
+
): Promise<boolean> {
|
|
128
|
+
const now = io.now ?? Date.now;
|
|
129
|
+
const deadline = now() + GUARDED_SETTLE_MS;
|
|
130
|
+
const remaining = () => Math.max(0, deadline - now());
|
|
131
|
+
if (!(io.waitExit ?? waitForExit)(approved.pid, remaining()) || remaining() <= 0) return false;
|
|
132
|
+
const hostname = probeHostname(approved.hostname || undefined);
|
|
133
|
+
if (!await (io.waitPort ?? waitForPortAvailable)(approved.port, hostname,
|
|
134
|
+
{ timeoutMs: remaining(), intervalMs: 50 }) || remaining() <= 0) return false;
|
|
135
|
+
const verdict = await (io.probe ?? probeEndpointLiveness)(
|
|
136
|
+
{ port: approved.port, hostname: approved.hostname || undefined },
|
|
137
|
+
{ timeoutMs: Math.min(250, remaining()) },
|
|
138
|
+
);
|
|
139
|
+
return verdict === "dead" && now() <= deadline;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function runGuardedManagerStep(
|
|
143
|
+
snapshot: GuardedStopSnapshot,
|
|
144
|
+
io: {
|
|
145
|
+
revalidateManager: () => GuardedManagerTarget;
|
|
146
|
+
stopManager: () => StopServiceOutcome;
|
|
147
|
+
signalApproved: () => Promise<boolean>;
|
|
148
|
+
settle: () => Promise<boolean>;
|
|
149
|
+
managerState: () => Promise<"inactive" | "active" | "unknown">;
|
|
150
|
+
},
|
|
151
|
+
): Promise<{ service: StopServiceOutcome; effect: "stopped" | "approval-changed" | "manager-still-active" | "failed";
|
|
152
|
+
proxy: "stopped" | "unknown"; handledByProxy: boolean }> {
|
|
153
|
+
try {
|
|
154
|
+
if (!sameGuardedManager(snapshot.manager, io.revalidateManager())) {
|
|
155
|
+
return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false };
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false };
|
|
159
|
+
}
|
|
160
|
+
const service = snapshot.manager.kind === "absent" ? "absent" : io.stopManager();
|
|
161
|
+
let handledByProxy = false;
|
|
162
|
+
if (snapshot.manager.kind === "absent") {
|
|
163
|
+
try { handledByProxy = await io.signalApproved(); }
|
|
164
|
+
catch { return { service, effect: "failed", proxy: "unknown", handledByProxy }; }
|
|
165
|
+
}
|
|
166
|
+
if (!await io.settle()) {
|
|
167
|
+
return { service, effect: "manager-still-active", proxy: "unknown", handledByProxy };
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
if (await io.managerState() !== "inactive") {
|
|
171
|
+
return { service, effect: "manager-still-active", proxy: "unknown", handledByProxy };
|
|
172
|
+
}
|
|
173
|
+
} catch { return { service, effect: "manager-still-active", proxy: "unknown", handledByProxy }; }
|
|
174
|
+
return { service, effect: "stopped", proxy: "stopped", handledByProxy };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function guardFinalStopSummary(
|
|
178
|
+
record: StopRunRecord,
|
|
179
|
+
managerState: () => Promise<"inactive" | "active" | "unknown">,
|
|
180
|
+
finish: () => StopOutcome,
|
|
181
|
+
): Promise<StopOutcome> {
|
|
182
|
+
try {
|
|
183
|
+
if (await managerState() !== "inactive") return managerStillActive(record.service, record);
|
|
184
|
+
} catch { return managerStillActive(record.service, record); }
|
|
185
|
+
return finish();
|
|
186
|
+
}
|
package/src/cli/stop-report.ts
CHANGED
|
@@ -67,7 +67,7 @@ export interface StopSummaryJson {
|
|
|
67
67
|
schema: typeof STOP_SUMMARY_SCHEMA;
|
|
68
68
|
/** Strict exit-code view: true only for exit 0. */
|
|
69
69
|
ok: boolean;
|
|
70
|
-
outcome: "stopped" | "not-running" | "history-incomplete" | "history-deferred" | "failed";
|
|
70
|
+
outcome: "stopped" | "not-running" | "history-incomplete" | "history-deferred" | "failed" | "approval-changed" | "manager-still-active";
|
|
71
71
|
exitCode: number;
|
|
72
72
|
/** True when this stop left no proxy of this home running by its own paths. */
|
|
73
73
|
runtimeDown: boolean;
|
|
@@ -16,7 +16,9 @@ import {
|
|
|
16
16
|
} from "../lib/system-restart-contract";
|
|
17
17
|
import {
|
|
18
18
|
findLiveProxy,
|
|
19
|
+
isHealthzVersion,
|
|
19
20
|
isOpencodexHealthz,
|
|
21
|
+
isPackageTreeFencedHealthz,
|
|
20
22
|
probeHostname,
|
|
21
23
|
type HealthzIdentity,
|
|
22
24
|
type LiveProxy,
|
|
@@ -105,9 +107,12 @@ export async function requestBoundSystemRestart(
|
|
|
105
107
|
}
|
|
106
108
|
const body = await proofResponse.json().catch(() => null) as HealthzIdentity | null;
|
|
107
109
|
const proof = proofResponse.headers.get(LOCAL_ATTESTATION_PROOF_HEADER);
|
|
110
|
+
// A package-tree fence answers /healthz with 503 but still proves its identity (#5496).
|
|
111
|
+
// Only that exact body is admitted as a non-OK proof response; the proof check is unchanged.
|
|
112
|
+
const fenced = proofResponse.status === 503 && isPackageTreeFencedHealthz(body);
|
|
108
113
|
if (
|
|
109
|
-
!proofResponse.ok
|
|
110
|
-
|| !isOpencodexHealthz(body)
|
|
114
|
+
!(proofResponse.ok || fenced)
|
|
115
|
+
|| !(isOpencodexHealthz(body) || fenced)
|
|
111
116
|
|| body?.pid !== target.pid
|
|
112
117
|
|| !verifyLocalAttestationProof(runtime.attestationSecret, challenge, target.pid, target.port, proof)
|
|
113
118
|
) {
|
|
@@ -127,14 +132,24 @@ export async function requestBoundSystemRestart(
|
|
|
127
132
|
// diagnosis compares (packageVersion vs the /healthz version), so reuse that
|
|
128
133
|
// comparison and refuse before POST. Placeholder versions (unknown/0.0.0) are
|
|
129
134
|
// "cannot compare", not mismatch, and keep the existing behavior.
|
|
130
|
-
|
|
135
|
+
//
|
|
136
|
+
// A fenced proxy booted from files that have since been replaced at the same path, so its
|
|
137
|
+
// boot version differs from this CLI by construction. What the respawn will run is the
|
|
138
|
+
// manifest now on disk, which the fence reports as installedVersion. Without a readable
|
|
139
|
+
// one the replacement is still in flight and restarting now could load a partial tree.
|
|
140
|
+
if (fenced && !isHealthzVersion(body.installedVersion)) {
|
|
141
|
+
return rejected("restart_package_tree_unsettled");
|
|
142
|
+
}
|
|
143
|
+
const proxyVersion = fenced
|
|
144
|
+
? body.installedVersion as string
|
|
145
|
+
: typeof body.version === "string" ? body.version : undefined;
|
|
131
146
|
if (computeVersionSkew(deps.cliVersion ?? ownCliVersion(), proxyVersion).skewed) {
|
|
132
147
|
return rejected("restart_version_skew");
|
|
133
148
|
}
|
|
134
149
|
|
|
135
150
|
let observed: LiveProxy | null;
|
|
136
151
|
try {
|
|
137
|
-
observed = await (deps.findLive ?? findLiveProxy)({ deadlineAt, nowFn: now });
|
|
152
|
+
observed = await (deps.findLive ?? findLiveProxy)({ deadlineAt, nowFn: now, acceptPackageTreeFenced: true });
|
|
138
153
|
} catch {
|
|
139
154
|
return rejected("restart_target_recheck_failed");
|
|
140
155
|
}
|