Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1193,7 +1193,7 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) {

if (snapshot) {
if (guardedStep?.effect === "approval-changed") {
return approvalChanged();
return approvalChanged(guardedStep.detail);
}
if (guardedStep?.effect === "manager-still-active") {
return managerStillActive(record.service, record);
Expand Down
71 changes: 55 additions & 16 deletions src/cli/stop-approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,41 @@ export function parseStopApproval(argv: string[]): StopApprovalParse {
}

export function matchesStopApproval(expected: StopApproval, now: ResolveJson): boolean {
return now.schema === "ocx-resolve/1" && now.liveness.status === "live"
&& now.liveness.pid === expected.pid && now.liveness.port === expected.port
&& now.port.effective === expected.port && (now.liveness.hostname ?? "") === expected.hostname
&& now.liveness.role !== "client" && now.configHome === expected.configHome
&& now.cliVersion === expected.cliVersion && now.ownership.kind !== "unknown"
&& now.takeover.kind === "supported" && now.takeover.token === expected.compatibilityToken;
return stopApprovalMismatch(expected, now) === null;
}

export function approvalChanged(): StopOutcome {
/**
* The single guard fact that no longer holds, or null when everything still matches.
* The guarded stop collapses every re-verification into one `approval-changed` outcome;
* without the specific check, a desktop log cannot tell a genuine ownership or token
* drift from a transient probe failure on an unchanged runtime.
*/
export function stopApprovalMismatch(expected: StopApproval, now: ResolveJson): string | null {
if (now.schema !== "ocx-resolve/1") return "the resolve contract changed";
if (now.liveness.status !== "live") return `the approved runtime is no longer live (${now.liveness.status})`;
if (now.liveness.pid !== expected.pid) {
return `the live PID is now ${now.liveness.pid ?? "unverified"} (approved ${expected.pid})`;
}
if (now.liveness.port !== expected.port) return `the live port is now ${now.liveness.port} (approved ${expected.port})`;
if (now.port.effective !== expected.port) return `the effective port is now ${now.port.effective} (approved ${expected.port})`;
if ((now.liveness.hostname ?? "") !== expected.hostname) return "the bind hostname changed";
if (now.liveness.role === "client") return "the runtime now routes as a client";
if (now.configHome !== expected.configHome) return "the config home changed";
if (now.cliVersion !== expected.cliVersion) return `the resolving CLI is now ${now.cliVersion} (approved ${expected.cliVersion})`;
if (now.ownership.kind === "unknown") return "the ownership claim could not be re-verified";
if (now.takeover.kind !== "supported") {
return `takeover compatibility is now blocked (${now.takeover.reason})`;
}
if (now.takeover.token !== expected.compatibilityToken) return "the compatibility fingerprint changed";
return null;
}

export function approvalChanged(detail?: string): StopOutcome {
return { ok: false, summary: {
schema: STOP_SUMMARY_SCHEMA, ok: false, outcome: "approval-changed", exitCode: 1,
runtimeDown: false, service: "absent", proxy: "unknown", sharedTeardown: "skipped",
message: "The approved runtime or managing CLI changed; no stop was attempted.",
...(detail ? { detail } : {}),
message: `The approved runtime or managing CLI changed${detail ? `: ${detail}` : ""}; no stop was attempted.`,
} };
}

Expand Down Expand Up @@ -104,10 +126,20 @@ export async function runApprovedStop(
const now = await read();
const target = tracked();
const manager = managerTarget();
if (!now || !matchesStopApproval(expected, now) || !target
|| target.pid !== expected.pid || target.port !== expected.port
|| target.hostname !== expected.hostname || manager.kind === "unknown"
|| (manager.kind === "bound" && manager.pid !== expected.pid)) return approvalChanged();
if (now === null) return approvalChanged("the runtime could not be re-resolved");
const mismatch = stopApprovalMismatch(expected, now);
if (mismatch !== null) return approvalChanged(mismatch);
if (target === null) return approvalChanged("the tracked runtime record could not be verified");
if (target.pid !== expected.pid || target.port !== expected.port
|| target.hostname !== expected.hostname) {
return approvalChanged("the tracked runtime record changed");
}
if (manager.kind === "unknown") {
return approvalChanged(`the service manager could not be re-verified (${manager.reason})`);
}
if (manager.kind === "bound" && manager.pid !== expected.pid) {
return approvalChanged("a different manager owns the approved runtime");
}
snapshot = { approval: expected, manager };
} catch { return approvalChanged(); }
return snapshot ? stop(snapshot) : approvalChanged();
Expand Down Expand Up @@ -149,13 +181,20 @@ export async function runGuardedManagerStep(
managerState: () => Promise<"inactive" | "active" | "unknown">;
},
): Promise<{ service: StopServiceOutcome; effect: "stopped" | "approval-changed" | "manager-still-active" | "failed";
proxy: "stopped" | "unknown"; handledByProxy: boolean }> {
proxy: "stopped" | "unknown"; handledByProxy: boolean; detail?: string }> {
try {
if (!sameGuardedManager(snapshot.manager, io.revalidateManager())) {
return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false };
const current = io.revalidateManager();
if (!sameGuardedManager(snapshot.manager, current)) {
// An unprovable manager is not a proven change: name the observation failure,
// not a swap, or the stop log loses why the revalidation could not answer.
const detail = current.kind === "unknown"
? `the service manager could not be re-verified (${current.reason})`
: "the service manager changed between approval and stop";
return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false, detail };
}
} catch {
return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false };
return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false,
detail: "the service manager could not be re-validated" };
}
const service = snapshot.manager.kind === "absent" ? "absent" : io.stopManager();
if (snapshot.manager.kind === "bound"
Expand Down
5 changes: 5 additions & 0 deletions src/cli/stop-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ export interface StopSummaryJson {
sharedTeardown: StopSharedTeardownOutcome;
/** One stable human-readable line for a caller's UI. */
message: string;
/**
* The single guard fact that failed on a `approval-changed` refusal, when the
* run can name one. Additive on `ocx-stop/1`; absent on every other outcome.
*/
detail?: string;
}

/** What handleStop returns: the pre-existing boolean plus the structured twin. */
Expand Down
25 changes: 17 additions & 8 deletions src/config/process-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,14 +328,23 @@ export function readProcessCommandLine(pid: number): string | undefined {
} catch {
/* WMIC missing or failed — fall through */
}
const output = processCommandLineExec(resolveTrustedWindowsPowerShellExe(), [
"-NoProfile",
"-NoLogo",
"-NonInteractive",
"-Command",
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
return output.trim() || undefined;
// One bounded retry: a transient CIM timeout answers "unreadable" for an
// unchanged process and turns a guarded stop into a false approval-changed.
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const output = processCommandLineExec(resolveTrustedWindowsPowerShellExe(), [
"-NoProfile",
"-NoLogo",
"-NonInteractive",
"-Command",
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
return output.trim() || undefined;
} catch {
/* timed out or CIM unavailable — try once more, then unreadable */
}
}
return undefined;
}
for (const ps of ["/bin/ps", "/usr/bin/ps"]) {
try {
Expand Down
38 changes: 34 additions & 4 deletions src/service/managing-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ import {
import type { ServiceInstallState } from "./state";

const VERSION_PROBE_TIMEOUT_MS = 5000;
/**
* A `--version` spawn is the only evidence for a PATH shim; a transient timeout or a
* busy cold start turns an unchanged manager into `unknown`, which flips both the
* takeover answer and the compatibility token the next resolve compares. One bounded
* retry keeps the observation stable while remaining fail-closed on a real failure.
*/
const VERSION_PROBE_ATTEMPTS = 2;

export interface ManagingCliDeps {
/** Environment to scan PATH/PATHEXT in. Defaults to `process.env`. */
Expand All @@ -52,23 +59,30 @@ function versionFromOutput(stdout: string): string | null {
return null;
}

function probeVersion(
function probeVersionOnce(
executable: string,
args: readonly string[],
deps: Required<Pick<ManagingCliDeps, "spawn" | "platform" | "env">>,
identity: string,
): ManagingCliObservation {
const identity = [executable, ...args].join(" ");
let result: SpawnSyncReturns<string>;
try {
const windowsShim =
deps.platform === "win32" && /\.(cmd|bat)$/i.test(executable);
if (windowsShim && [executable, ...args].some(part => /[&|<>^%!"()]/.test(part))) {
return { status: "unknown", reason: "the selected Windows command shim invocation cannot be probed safely" };
}
// cmd /c re-parses the remainder of its command line: with more than one quoted
// part it strips the outer quotes and truncates at the first space, so every part
// must be quoted individually inside one wrapping pair, passed verbatim.
const shimCommand = `"${[executable, ...args, "--version"].map(part => `"${part}"`).join(" ")}"`;
result = deps.spawn(
windowsShim ? (deps.env?.ComSpec ?? "cmd.exe") : executable,
windowsShim ? ["/c", executable, ...args, "--version"] : [...args, "--version"],
{ timeout: VERSION_PROBE_TIMEOUT_MS, encoding: "utf8", stdio: "pipe", windowsHide: true },
windowsShim ? ["/c", shimCommand] : [...args, "--version"],
{
timeout: VERSION_PROBE_TIMEOUT_MS, encoding: "utf8", stdio: "pipe",
windowsHide: true, ...(windowsShim ? { windowsVerbatimArguments: true } : {}),
},
) as SpawnSyncReturns<string>;
} catch (error) {
return {
Expand All @@ -91,6 +105,22 @@ function probeVersion(
: { status: "observed", version, identity };
}

function probeVersion(
executable: string,
args: readonly string[],
deps: Required<Pick<ManagingCliDeps, "spawn" | "platform" | "env">>,
): ManagingCliObservation {
const identity = [executable, ...args].join(" ");
let observation = probeVersionOnce(executable, args, deps, identity);
// One bounded retry, Windows only (the platform the flake family was verified on):
// a single shim-timeout answer must not flip the fingerprint of an unchanged CLI.
const attempts = deps.platform === "win32" ? VERSION_PROBE_ATTEMPTS : 1;
for (let attempt = 1; observation.status !== "observed" && attempt < attempts; attempt += 1) {
observation = probeVersionOnce(executable, args, deps, identity);
}
return observation;
}

/**
* Where `ocx` resolves on PATH without spawning `where`/`which`.
*
Expand Down
Loading
Loading