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
33 changes: 33 additions & 0 deletions docs-site/src/content/docs/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ unreadable command line in the approved proxy's supervision chain (or surviving
stopped manager) leaves wrapper ownership unknown, so the guarded stop cannot report success.
Missing or unreadable evidence blocks the guarded stop.

New runtime records include the bound hostname. For older records that omit it, a guarded
stop accepts the discovered default loopback address only when the remaining approval and
ownership checks agree. An omitted hostname cannot independently prove the listener's bind
scope. When the record contains a hostname, it must match the approved value exactly;
wildcard and loopback binds remain distinct.

### `ocx restart`

When this CLI is newer than an attested standalone POSIX proxy, restart can stop the old
Expand Down Expand Up @@ -318,6 +324,33 @@ restart the proxy using the intended current installation; for a background serv
or resolve `PATH` to the intended installation. These diagnostics do not repair the service or
change whether requests are allowed.

If an update restart reports a blank hostname in the runtime record, it leaves the proxy running.
Use `ocx status` and `ocx service status` as clues, not proof of ownership or the actual bind.
Once ownership is verified, for a service use the owning installation's `ocx service restart`;
for Desktop, use the app; for a foreground proxy, use its terminal. A standalone proxy started by `ocx ensure` may be
detached and have no terminal:

1. Independently verify the process identity, installation, home, supervisor and actual listening
address and port. Use OS process and listener inspection with sufficient permissions; check
IPv4, IPv6 and wildcard listeners as applicable. A PID alone can be reused, and missing or
incomplete inspection results do not prove ownership or absence. For example, on macOS or
Linux where `lsof` is available, `ps -p <pid> -o pid=,ppid=,args=` and
`lsof -nP -iTCP:<port> -sTCP:LISTEN` provide process and listener clues. They do not by
themselves establish the home, supervisor, or complete visibility; use the appropriate OS
tools and permissions to verify those separately.
2. Only if those facts are verified and forced termination is acceptable, the owner can run
`ocx stop` from the owning installation and home. It tries a graceful stop first, but may
fall back to forced termination if that fails; it can also refuse, including when the blank
hostname prevents guarded ownership verification. Do not bypass a refusal.
3. Independently confirm the identified process has exited, no supervisor will restart it,
and the intended address and port are free. A successful stop or a status result alone is
not proof of all three. Once confirmed, run `ocx start` from the intended installation and
home with the intended bind and port settings; a successful start regenerates the record.

If any check is incomplete, the stop fails or is refused, or forced termination is unacceptable,
stop and ask the owner or administrator to investigate. Do not edit the runtime record or kill
unrelated processes. Retry the update restart if needed after the new proxy is running.
Comment thread
lidge-jun marked this conversation as resolved.
Resolved

Identical version strings and the `unknown` / `0.0.0` placeholders suppress the warning, as does
an absent proxy version. Doctor does not report placeholders as a confirmed match. Different
strings still produce a neutral warning when they cannot be strictly parsed as SemVer or differ
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ ocx start --socks5-off

### `ocx restart`

執行 `stop` 後接 `ensure`:停止代理/服務、還原原生 Codex、在背景啟動代理,並將即時連接埠同步回 Codex。
一般情況下,代理正在執行時,指令會向身分已驗證的特定 PID 與連接埠請求原地重新啟動,等待正常排空,並確認同一連接埠上出現不同的執行階段 PID。此路徑會保留受管路由與服務監督;若請求結果不確定,不會將其重試為獨立的停止與啟動。只有代理未執行時,才會回退至一般的 `ensure` 啟動。

若目前 CLI 比已驗證的獨立 POSIX 代理新,且符合嚴格的擁有權與監督條件,受保護的更新路徑會停止舊代理,確認其已停止後才從目前的安裝啟動新代理;此路徑不同於上述原地重新啟動。若資格或停止狀態無法確認,指令會回報失敗。條件與其他情況請參閱[重新啟動參考資料](/reference/cli/lifecycle/#ocx-restart)(英文)。

如果更新後的重新啟動回報執行階段紀錄中的主機名稱為空白,這次嘗試不會停止代理。請參閱[狀態與復原指引](/reference/cli/lifecycle/#status-and-health)(英文),先確認擁有權與實際綁定位址;若確認不完整,請洽擁有者或管理員。

### `ocx ensure`

Expand Down
6 changes: 3 additions & 3 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { getCodexHome } from "../codex/paths";
import { admitUpdateRestartChild } from "./update-restart-child";
import { HEALTH_SENTINEL_STANDALONE_ARG, startServiceHealthSentinel } from "../service/health-sentinel-launch";
import { UpdateRestartRequired } from "./update-restart-candidate";
import { describeUpdateRestartFailure, restartFromCurrentInstallation } from "./update-restart";
import { reportUpdateRestartFailure, restartFromCurrentInstallation } from "./update-restart";
import { findGuiDist } from "../server/gui-static";
import { inspectGuiBundleFreshness, staleGuiBundleLines } from "../server/gui-freshness";
// Best-effort recovery for runtime execution and spawned children if launched
Expand Down Expand Up @@ -606,7 +606,7 @@ async function handleStart(options: { block?: boolean } = {}) {
writeRuntime: bound => { updateRestartChild?.check(bound.port, bound.config.hostname ?? ""); writeRuntimePort({
pid: process.pid,
port: bound.port,
hostname: bound.config.hostname,
hostname: bound.server.hostname,
attestationSecret: bound.localAttestationSecret,
command: currentServingCommand(),
...siblingRuntimeField(),
Expand Down Expand Up @@ -984,7 +984,7 @@ async function handleProxyRestart(
console.log(`🔄 Running proxy ${candidate.target.version} is older than this CLI (${candidate.cliVersion}); restarting it from the current installation...`);
const update = await restartFromCurrentInstallation(candidate, deadlineAt, detachedStartEnvironment());
if (update.ok) console.log(`✅ Proxy updated to ${update.live.version} (PID ${update.live.pid}).`);
else console.error(`❌ ${describeUpdateRestartFailure(update.code, update.reason)} (${update.code})`);
else reportUpdateRestartFailure(update);
process.exitCode = update.ok ? 0 : 1;
return update.ok;
}
Expand Down
4 changes: 2 additions & 2 deletions src/cli/stop-approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export interface GuardedStopSnapshot {
* inspection probe -- it carries no path, command line, stderr, or token.
*/
export type TrackedRuntimeCheck =
| { pid: number; port: number; hostname: string }
| { pid: number; port: number; hostname: string | undefined }
| { untracked: string }
| null;

Expand Down Expand Up @@ -145,7 +145,7 @@ export async function runApprovedStop(
return approvalChanged(`the tracked runtime record could not be verified (${target.untracked})`);
}
if (target.pid !== expected.pid || target.port !== expected.port
|| target.hostname !== expected.hostname) {
|| (target.hostname === undefined ? expected.hostname !== "127.0.0.1" : target.hostname !== expected.hostname)) {
return approvalChanged("the tracked runtime record changed");
}
if (manager.kind === "unknown") {
Expand Down
5 changes: 5 additions & 0 deletions src/cli/system-restart-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ export async function requestBoundSystemRestart(
if (!sameRestartTarget(target, observed)) return rejected("restart_target_changed");
if (skew.relation === "cli-newer") {
if (!updateHome) return rejected("update_restart_home_unverified");
try {
if (JSON.stringify((deps.readUpdateHome ?? readUpdateRestartHome)()) !== JSON.stringify(updateHome)) {
return rejected("update_restart_home_changed");
}
} catch { return rejected("update_restart_home_changed"); }
Comment thread
lidge-jun marked this conversation as resolved.
Resolved
const current = readRuntime(target.pid);
if (!current || current.pid !== runtime.pid || current.port !== runtime.port
|| current.hostname !== runtime.hostname || current.attestationSecret !== runtime.attestationSecret) {
Expand Down
16 changes: 13 additions & 3 deletions src/cli/update-restart-child.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { existsSync } from "node:fs";
import { isIP } from "node:net";
import { getRuntimePortPath, readRuntimePort } from "../config/process-state";
import { packageVersion } from "../lib/package-version";
import { parseStrictSemver } from "../lib/strict-semver";
Expand All @@ -10,12 +11,21 @@ export const UPDATE_RESTART_CHILD_ENV = "OCX_UPDATE_RESTART_CHILD";
export interface UpdateRestartChildMarker { home: UpdateRestartHome; version: string; port: number; hostname: string; deadlineAt: number }
export interface UpdateRestartChildGuard { check(port?: number, hostname?: string): void; complete(): void }

function parseMarker(raw: string): UpdateRestartChildMarker {
function safeBindHost(host: string): boolean {
if (!host || host.length > 255 || host !== host.trim()) return false;
if (host.startsWith("[") || host.endsWith("]")) {
return host.startsWith("[") && host.endsWith("]") && isIP(host.slice(1, -1)) === 6;
}
return isIP(host) !== 0 || /^(?:\*|0|(0+\.){3}0+)\.?$/.test(host);
}

export function parseUpdateRestartMarker(raw: string): UpdateRestartChildMarker {
if (raw.length > 8192) throw new Error("update_restart_child_marker_invalid");
const value = JSON.parse(raw) as UpdateRestartChildMarker;
if (!value || !Number.isSafeInteger(value.deadlineAt) || !Number.isInteger(value.port)
|| typeof value.hostname !== "string" || value.port < 1 || value.port > 65535 || typeof value.version !== "string"
|| typeof value.hostname !== "string" || !safeBindHost(value.hostname) || value.port < 1 || value.port > 65535 || typeof value.version !== "string"
|| value.version === "0.0.0" || !parseStrictSemver(value.version) || !value.home
|| !(value.home.diagnosedHostname === null || (typeof value.home.diagnosedHostname === "string" && safeBindHost(value.home.diagnosedHostname)))
|| !Number.isSafeInteger(value.home.revision) || value.home.serviceRecord?.schema !== 1
|| typeof value.home.serviceRecord.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.home.serviceRecord.digest)) throw new Error("update_restart_child_marker_invalid");
for (const directory of [value.home.config, value.home.codex]) {
Expand Down Expand Up @@ -49,7 +59,7 @@ export function admitUpdateRestartChild(argv: string[], io: UpdateRestartChildIo
if (raw === undefined) return undefined;
delete env[UPDATE_RESTART_CHILD_ENV];
delete env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV];
const marker = parseMarker(raw);
const marker = parseUpdateRestartMarker(raw);
if (argv.length !== 3 || argv[0] !== "start" || argv[1] !== "--port" || argv[2] !== String(marker.port)) {
throw new Error("update_restart_child_command_changed");
}
Expand Down
9 changes: 6 additions & 3 deletions src/cli/update-restart-home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface UpdateRestartHome {
codex: UpdateRestartDirectory;
revision: number;
serviceRecord: UpdateRestartServiceRecord;
diagnosedHostname: string | null;
}

export type UpdateRestartDirectoryStat = Pick<BigIntStats, "dev" | "ino" | "isDirectory">;
Expand All @@ -44,7 +45,9 @@ export function readUpdateRestartHome(deps: UpdateRestartHomeDeps = {}): UpdateR
throw new Error("update_restart_owner_unverified");
}
const stat = deps.statDirectory ?? (path => statSync(path, { bigint: true }));
return { config: physicalDirectory(getConfigDir(), stat), codex: physicalDirectory(currentCodexHome(), stat), revision: owner.revision, serviceRecord };
const diagnostics = readConfigDiagnostics({ passive: true });
if (diagnostics.error) throw new Error("update_restart_configuration_unverified");
return { config: physicalDirectory(getConfigDir(), stat), codex: physicalDirectory(currentCodexHome(), stat), revision: owner.revision, serviceRecord, diagnosedHostname: diagnostics.config.hostname ?? null };
}

export function assertUpdateRestartHome(expected: UpdateRestartHome, deadlineAt = Date.now() + 2000, deps: UpdateRestartHomeDeps = {}): void {
Expand All @@ -62,7 +65,7 @@ export function assertUpdateRestartHome(expected: UpdateRestartHome, deadlineAt

/** Read-only eligibility shared by parent pre-stop and child admission. */
export function assertUpdateRestartConfiguration(hostname: string): void {
const diagnostics = readConfigDiagnostics();
const diagnostics = readConfigDiagnostics({ passive: true });
if (readClientConnectionState().kind !== "disconnected" || diagnostics.error
|| (diagnostics.config.hostname ?? "") !== hostname) throw new Error("update_restart_configuration_changed");
|| (diagnostics.config.hostname === undefined ? hostname !== "127.0.0.1" : diagnostics.config.hostname !== hostname)) throw new Error("update_restart_configuration_changed");
}
Loading
Loading