Skip to content
Draft
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
50 changes: 45 additions & 5 deletions bin/ocx.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,15 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { checkNpmCacheOwnership, formatNpmCacheOwnershipFailure } from "../src/update/npm-cache-preflight.mjs";
import {
INSTALLER_TREE_CLEANUP_FAILED_EXIT_CODE,
runProcessTreeCommand,
} from "../src/update/install-process.mjs";
import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "../src/update/tray-update-plan.mjs";

const PKG = "@bitkyc08/opencodex";
const NPM_INSTALL_TIMEOUT_MS = 180_000;
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
const cliPath = join(here, "..", "src", "cli", "index.ts");
Expand Down Expand Up @@ -112,7 +118,7 @@ function runTrayLifecycle(launcher, action) {
});
}

function runNpmSelfUpdate() {
async function runNpmSelfUpdate() {
const current = currentPackageVersion();
const tag = updateTag(current);
const npm = npmBin();
Expand All @@ -133,6 +139,15 @@ function runNpmSelfUpdate() {
process.exit(0);
}

const cacheOwnership = checkNpmCacheOwnership({ npmBin: npm, shell: winShell });
if (cacheOwnership.ok === false) {
console.error(`opencodex: ${formatNpmCacheOwnershipFailure(cacheOwnership)}`);
process.exit(1);
}
if (cacheOwnership.ok === "skipped") {
console.warn(`opencodex: npm cache ownership pre-flight skipped: ${cacheOwnership.reason}. Proceeding best-effort.`);
}

// Remember whether a background service manages the proxy BEFORE stopping — `ocx stop`
// unloads it permanently, so a successful update must reinstall it afterwards.
const serviceStatePath = join(configDir(), "service-state.json");
Expand Down Expand Up @@ -222,12 +237,32 @@ function runNpmSelfUpdate() {
}

console.log(`Updating${latest ? ` to v${latest}` : ""}...\n$ ${npm} install -g ${PKG}@${tag}`);
const res = spawnSync(npm, ["install", "-g", `${PKG}@${tag}`], {
const res = await runProcessTreeCommand(npm, ["install", "-g", `${PKG}@${tag}`], {
stdio: "inherit",
timeout: 180000,
timeoutMs: NPM_INSTALL_TIMEOUT_MS,
windowsHide: true,
shell: winShell,
});
if (!res.treeExited) {
console.error("\nopencodex: installer process-tree cleanup could not be confirmed; automatic recovery is unsafe until those processes exit.");
console.error(` The proxy is stopped. Once no '${npm}' installer processes remain, run 'ocx start' or re-run 'ocx update'.${trayBeforeUpdate.restoreOnFailure ? " The Windows tray also remains stopped; run 'ocx tray start' after the installer processes exit." : ""}`);
process.exit(INSTALLER_TREE_CLEANUP_FAILED_EXIT_CODE);
}
if (res.interruptedSignal) {
if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start");
console.error(`\nopencodex: update interrupted (${res.interruptedSignal}); the proxy is still stopped. Run 'ocx start' or re-run 'ocx update'.`);
const exitCode = res.interruptedSignal === "SIGINT"
? 130
: res.interruptedSignal === "SIGHUP"
? 129
: 143;
if (process.platform === "win32") {
process.exit(exitCode);
}
process.kill(process.pid, res.interruptedSignal);
// Do not return to the update call site while fatal signal delivery is pending.
process.exit(exitCode);
}
if (res.status === 0) {
console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`);
repairCodexShimIfNeeded();
Expand Down Expand Up @@ -278,7 +313,12 @@ function runNpmSelfUpdate() {
process.exit(0);
}
if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start");
console.error(`\nUpdate failed (${npm} exit ${res.status ?? "?"}). Try manually: ${npm} install -g ${PKG}@${tag}`);
const failure = res.timedOut
? `timed out after ${Math.trunc(NPM_INSTALL_TIMEOUT_MS / 1000)}s`
: res.error
? `could not run: ${res.error.message}`
: `exit ${res.status ?? "?"}`;
console.error(`\nUpdate failed (${npm} ${failure}). Try manually: ${npm} install -g ${PKG}@${tag}`);
process.exit(1);
}

Expand Down Expand Up @@ -351,7 +391,7 @@ if (updateHelpRequested) {
}

if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstall()) {
runNpmSelfUpdate();
await runNpmSelfUpdate();
}

const bun = resolveBun();
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ja/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,8 @@ npm から opencodex を自己更新します。安定版インストールは `
`git pull && bun install` を案内し、該当タグの最新版なら何もしません。ファイルを
差し替える前に実行中のプロキシを停止します。インストール済みのサービスは再ビルドして自動起動し、
フォアグラウンドインストールには次のステップとして `ocx start` を案内します。
Unix では、更新前に設定済み npm キャッシュが現在のユーザー所有か確認します。所有者の異なる
エントリが見つかった場合やキャッシュを検査できない場合は、実行中のプロキシを停止する前に更新を中止します。

```bash
ocx update
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ko/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,8 @@ npm에서 opencodex를 자체 업데이트합니다. 안정판 설치는 `@lates
`git pull && bun install`을 안내하고, 해당 태그의 최신 버전이면 아무 작업도 하지 않습니다. 파일을
교체하기 전에 실행 중인 프록시를 중지합니다. 설치된 서비스는 다시 빌드해 자동으로 시작하고,
포그라운드 설치에는 다음 단계로 `ocx start`를 안내합니다.
Unix에서는 업데이트 전에 설정된 npm 캐시가 현재 사용자 소유인지 확인합니다. 다른 소유자의 항목이
있거나 캐시를 검사할 수 없으면 실행 중인 프록시를 중지하기 전에 업데이트를 중단합니다.

```bash
ocx update
Expand Down
6 changes: 5 additions & 1 deletion docs-site/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,11 @@ Self-update opencodex from npm. Stable installs use `@latest`; preview installs
unless you pass `--tag latest|preview`. It detects a source checkout and tells you to
`git pull && bun install` instead, and is a no-op if you're already on the newest version for that
tag. A running proxy is stopped before files are replaced; an installed service is rebuilt and
started automatically, while a foreground installation prints `ocx start` as the next step.
started automatically, while a foreground installation prints `ocx start` as the next step. On
Unix, the updater first checks that the configured npm cache is owned by the current user. It aborts
before stopping the proxy when it finds a foreign-owned cache entry or cannot inspect the cache, so
you can correct the cache ownership or configure a user-owned cache and retry without losing the
running service.

```bash
ocx update
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ru/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,8 @@ ocx debug usage logs [-f|--follow]
если у вас уже новейшая версия для этого тега. Работающий прокси останавливается перед заменой
файлов; установленный сервис пересобирается и запускается автоматически, а при установке,
работающей на переднем плане, команда печатает `ocx start` как следующий шаг.
В Unix перед обновлением проверяется, что настроенный кеш npm принадлежит текущему пользователю.
Если найден элемент с другим владельцем или кеш невозможно проверить, обновление прерывается до остановки прокси.

```bash
ocx update
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/zh-cn/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,8 @@ provider debug 默认读取 `OCX_DEBUG=1`(旧的 `OCX_DEBUG_FRAMES=1` 仍可
`--tag latest|preview`。在源码 checkout 中,它会改为提示 `git pull && bun install`;如果已经是
相应 tag 的最新版,则不执行任何操作。替换文件前会停止正在运行的代理;已安装的服务会自动重建
并启动,而前台安装会把 `ocx start` 显示为下一步。
在 Unix 上,更新器会先检查配置的 npm 缓存是否全部归当前用户所有。如果发现其他用户拥有的条目,
或无法检查缓存,更新会在停止代理前中止。

```bash
ocx update
Expand Down
59 changes: 57 additions & 2 deletions docs/adr/0001-gui-update-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,59 @@ detached hidden CLI worker, and the worker performs the install command and opti

For npm installs, the worker runs the Node launcher path (`node bin/ocx.mjs update --tag <tag>`) so
the existing npm self-update guard is reused. For Bun global installs, it runs the existing Bun
global update command. Source checkouts remain manual-only and show `git pull && bun install &&
bun run build:gui`.
global update command. The outer GUI installer command also runs through the shared process-tree
runner; a failed npm or Bun update is recoverable only when that runner confirms the installer tree
has exited. Source checkouts remain manual-only and show `git pull && bun install && bun run build:gui`.

Before an npm updater stops the proxy, it resolves the configured npm cache and checks that every
entry is owned by the current Unix user. A foreign-owned entry (commonly left by an older `sudo npm`
invocation) aborts the update with an actionable error while the existing proxy and service remain
running. The scan fails closed when the cache root is absent, an entry cannot be inspected, or its
50,000-entry / 10-second traversal budget is exhausted. Only the configured cache-root symlink is
canonicalized; nested cache symlinks are rejected fail-closed so a foreign-owned target cannot be
hidden behind one. The blocking filesystem walk runs in a child process so the parent can enforce
the deadline even when a filesystem call itself stalls.
Failure logs omit both cache and entry paths; users can locate the cache explicitly with
`npm config get cache`. Platforms without Unix uid ownership skip this gate.

The GUI worker retries its identity-checked pre-update liveness capture before deciding a service
was inactive. If health remains unavailable, a matching runtime-port record plus an identity-checked
live PID still preserves the pre-update activity evidence. If the installer exits nonzero, the worker
probes again immediately before recovery and leaves any old or concurrently replaced process alone
when health or PID identity proves it is OpenCodex. When npm already stopped the proxy and retired
the old package, the worker validates each candidate's trusted ownership, name, version, path
containment, and complete launcher runtime with a side-effect-free `--version` probe. On UID-capable
platforms, the scope and complete package tree must also reject foreign owners, group/world-writable
entries, and symlinks that leave the candidate tree before any code executes. Trusted npm-generated
links whose immediate and final targets remain inside the candidate tree are allowed. The tree walk
is bounded by entry count and elapsed time. Candidate inspection and restart are bounded to at most
two candidates, preferring the current package and then the newest retired copies, and it tries those runnable
candidates in order until one restores health. The recovery-tree walk runs in its own short-lived
worker, and the GUI worker force-kills that child at the wall-clock deadline even if one filesystem
call blocks. A recovery launcher is always started directly: it
may restore availability, but its potentially temporary path is never persisted into launchd,
systemd, Task Scheduler, or the update job log; the log stores only a path-free candidate label.
If a candidate starts but does not become healthy, the worker retains the detached child handle,
then requires both the same process generation and a fresh OpenCodex identity before stopping that
PID and trying the next candidate. An exited child or missing generation proof fails closed so PID
reuse can never terminate a concurrent replacement proxy.
Service and direct restart paths also stop when the pidfile has changed to a different identity-checked
proxy, and every such identity decision re-reads the process command line instead of trusting a
PID-only cache across the update boundary. The update job remains failed either way because restoring
availability is not the same as installing the new version.

The npm launcher and the Bun/source CLI installer paths run in an isolated process tree. On timeout,
the updater terminates and awaits the known POSIX process group or a Windows `taskkill /T /F` tree;
piped CLI output is drained and bounded before it is replayed. A POSIX process group is not an OS
containment boundary because a lifecycle child can leave it with `setsid` or `setpgid`. Therefore a
timeout, interruption, or nonzero POSIX root exit is always reported as unconfirmed even when the
known group was successfully stopped, and automatic recovery stays disabled. Once a POSIX root has
exited, the updater also refuses to signal its leaderless group by a reusable numeric PGID. While the
root is live, cleanup treats zombie-only groups as stopped and revalidates group membership and the
original leader immediately before each signal; uninspectable or replacement-led groups are refused.
Windows has no retained job-object handle after a normally failed installer root exits, so that case
likewise remains explicitly unconfirmed. The outer worker timeout remains longer than the nested deadline, and
recovery is skipped whenever either layer cannot prove installer-tree shutdown.

After an update requests a restart, the worker now waits for an identity-checked `/healthz` to
return and remain healthy for a short stability window before marking the job successful. This
Expand All @@ -45,6 +96,10 @@ restart path because `bun add -g` does not restart the proxy.
- The GUI request handler stays responsive and does not overwrite its own running module graph.
- Update status survives a proxy restart because it is stored in the opencodex config directory.
- Restart handling can branch between service-managed installs and direct detached proxy starts.
- npm cache ownership failures are detected before service or proxy shutdown.
- Automatic recovery starts only when installer shutdown is confirmed; ambiguous POSIX failures and
escaped descendants remain fail-closed with manual remediation.
- A failed install best-effort restores a previously-active proxy without claiming update success.
- A completed install can still finish with `status: "failed"` when the replacement proxy never
becomes healthy or flaps during the stability window; the job log then points the user at
`ocx start` and the Bun `--allow-scripts` reinstall path.
Expand Down
58 changes: 52 additions & 6 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1225,19 +1225,41 @@ export function isOcxStartCommandLine(commandLine: string): boolean {
return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized);
}

function isLikelyOcxStartProcessUncached(pid: number): boolean | undefined {
const commandLine = readProcessCommandLine(pid);
if (commandLine === undefined) return undefined;
return isOcxStartCommandLine(commandLine);
}

/** Per-process memo: waitForProxy/findLiveProxy used to spawn powershell on every 150ms poll. */
const ocxStartProcessCache = new Map<number, boolean>();

function isLikelyOcxStartProcess(pid: number): boolean {
const cached = ocxStartProcessCache.get(pid);
function lookupOcxStartProcess(
pid: number,
readCommandLine: (pid: number) => string | undefined,
cache: Map<number, boolean>,
): boolean | undefined {
const cached = cache.get(pid);
if (cached !== undefined) return cached;
const commandLine = readProcessCommandLine(pid);
if (commandLine === undefined) return false;
const commandLine = readCommandLine(pid);
if (commandLine === undefined) return undefined;
const ok = isOcxStartCommandLine(commandLine);
ocxStartProcessCache.set(pid, ok);
cache.set(pid, ok);
return ok;
}

function isLikelyOcxStartProcess(pid: number): boolean | undefined {
return lookupOcxStartProcess(pid, readProcessCommandLine, ocxStartProcessCache);
}

export function lookupOcxStartProcessForTests(
pid: number,
readCommandLine: (pid: number) => string | undefined,
cache = new Map<number, boolean>(),
): boolean | undefined {
return lookupOcxStartProcess(pid, readCommandLine, cache);
}

/**
* Alive pid from the pid file without the expensive Windows command-line probe.
* Safe for liveness polls: callers still identity-check /healthz before trusting the proxy.
Expand Down Expand Up @@ -1268,7 +1290,31 @@ export function verifyPidIdentity(candidatePid: number): number | null {
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code !== "EPERM") return null;
}
return isLikelyOcxStartProcess(candidatePid) ? candidatePid : null;
return isLikelyOcxStartProcess(candidatePid) === true ? candidatePid : null;
}

/**
* Re-read the process command line for update/recovery decisions that span enough
* time for the OS to reuse a PID. Never trust the per-process polling cache here.
*/
export function verifyPidIdentityFresh(candidatePid: number): number | null {
try {
process.kill(candidatePid, 0);
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code !== "EPERM") {
ocxStartProcessCache.delete(candidatePid);
return null;
}
}
const isOcx = isLikelyOcxStartProcessUncached(candidatePid);
if (isOcx === undefined) {
ocxStartProcessCache.delete(candidatePid);
return null;
}
// This read is authoritative across a PID-reuse boundary. Reconcile the
// polling memo so later short-lived checks cannot serve the old identity.
ocxStartProcessCache.set(candidatePid, isOcx);
return isOcx ? candidatePid : null;
}

function readProcessCommandLine(pid: number): string | undefined {
Expand Down
Loading
Loading