Skip to content
Closed
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
38 changes: 23 additions & 15 deletions bin/ocx.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
resolvePnpmGlobalOwner,
runPnpmGlobalUpdate,
} from "../src/update/pnpm-global-install.mjs";
import { PNPM_READ_CWD, pnpmCommandCwd, pnpmReadEnvironment } from "../src/update/pnpm-read-policy.mjs";
import { checkRegistryPackageIntegrity } from "../src/update/registry-integrity.mjs";
import { hasPendingTeardownIn } from "../src/config/pending-teardown-names.mjs";
import {
Expand Down Expand Up @@ -208,6 +209,8 @@ function runPackageManagerSelfUpdate(manager) {
encoding: "utf8",
timeout: 20_000,
windowsHide: true,
cwd: PNPM_READ_CWD,
env: pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(process.env)),
...invocation.options,
});
},
Expand All @@ -221,6 +224,20 @@ function runPackageManagerSelfUpdate(manager) {
const managerInvocation = args => manager === "pnpm"
? pnpmOwnerInvocation(owner, args)
: npmInvocation(args);
// Read-only pnpm probes run from the installed package directory with project pnpmfiles
// disabled, so an attacker-controlled cwd cannot execute hooks during the update check.
const readProbeOptions = invocation => ({
encoding: "utf8",
timeout: 12000,
windowsHide: true,
...(manager === "pnpm"
? {
cwd: PNPM_READ_CWD,
env: pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(invocation.env ?? process.env)),
}
: invocation.env ? { env: invocation.env } : {}),
...invocation.options,
});
const latestInvocation = managerInvocation(["view", `${PKG}@${tag}`, "version"]);
const installArgs = manager === "pnpm"
? ["add", "-g", "--allow-build=bun", `${PKG}@${tag}`]
Expand All @@ -230,13 +247,7 @@ function runPackageManagerSelfUpdate(manager) {
console.error(`opencodex: could not resolve ${manager} from a trusted absolute PATH entry; aborting before stopping the proxy.`);
process.exit(1);
}
const latestResult = spawnSync(latestInvocation.file, latestInvocation.args, {
encoding: "utf8",
timeout: 12000,
windowsHide: true,
...(latestInvocation.env ? { env: latestInvocation.env } : {}),
...latestInvocation.options,
});
const latestResult = spawnSync(latestInvocation.file, latestInvocation.args, readProbeOptions(latestInvocation));
const latest = latestResult.status === 0 && typeof latestResult.stdout === "string" ? latestResult.stdout.trim() : "";

console.log(`opencodex v${current} (installed via ${manager}, tag ${tag})`);
Expand All @@ -248,13 +259,7 @@ function runPackageManagerSelfUpdate(manager) {
const integrity = checkRegistryPackageIntegrity(PKG, latest || null, args => {
const invocation = managerInvocation(args);
if (!invocation) return { status: 1 };
return spawnSync(invocation.file, invocation.args, {
encoding: "utf8",
timeout: 12000,
windowsHide: true,
...(invocation.env ? { env: invocation.env } : {}),
...invocation.options,
});
return spawnSync(invocation.file, invocation.args, readProbeOptions(invocation));
});
if (integrity.ok === false) {
console.error(`opencodex: ${integrity.reason}; aborting before stopping the proxy.`);
Expand Down Expand Up @@ -771,7 +776,10 @@ function runPackageManagerSelfUpdate(manager) {
encoding: "utf8",
timeout: 180000,
windowsHide: true,
env: unprivilegedOwnershipMutationEnvironment(invocation.env ?? process.env),
// Reads probe from the package dir; mutations (add -g, rollback) must not
// keep a cwd handle inside the package Windows is replacing.
cwd: pnpmCommandCwd(args),
env: pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(invocation.env ?? process.env)),
});
},
log: line => console.log(line),
Expand Down
6 changes: 5 additions & 1 deletion src/update/async-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { unprivilegedOwnershipMutationEnvironment } from "../service/ownership-mutation-lease.mjs";
import { PKG, registrySpawnTarget, type Channel, type Installer } from "./index";
import type { PnpmGlobalOwner } from "./pnpm-global-install.mjs";
import { PNPM_READ_CWD, pnpmReadEnvironment } from "./pnpm-read-policy.mjs";

export const REGISTRY_DEADLINE_MS = 12_000;
export const REGISTRY_OUTPUT_LIMIT = 4_096;
Expand Down Expand Up @@ -64,7 +65,10 @@ export async function latestVersionAsync(
child = deps.spawnFn(target.bin, target.args, {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
env: unprivilegedOwnershipMutationEnvironment(target.env ?? process.env),
cwd: installer === "pnpm" ? PNPM_READ_CWD : undefined,
env: installer === "pnpm"
? pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(target.env ?? process.env))
: unprivilegedOwnershipMutationEnvironment(target.env ?? process.env),
...target.options,
}) as ChildProcessWithoutNullStreams;
} catch {
Expand Down
35 changes: 26 additions & 9 deletions src/update/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-updat
import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
import { packageVersion } from "../lib/package-version";
import { selfLaunchArgv } from "../lib/self-launch-argv";
import { PNPM_READ_CWD, pnpmCommandCwd, pnpmReadEnvironment } from "./pnpm-read-policy.mjs";

/**
* A `codex-history-backup-*.json` surviving a stop means the native-history restore was
Expand Down Expand Up @@ -97,27 +98,33 @@ function runPnpmCandidate(
commandPath: string,
args: readonly string[],
capture = false,
spawn: typeof spawnSync = spawnSync,
): { status: number | null; stdout?: string | null; stderr?: string | null } {
const invocation = pnpmInvocationForPath(commandPath, args);
if (!invocation) return { status: 1 };
return spawnSync(invocation.file, invocation.args, {
return spawn(invocation.file, invocation.args, {
stdio: capture ? "pipe" : "ignore",
encoding: "utf8",
timeout: 20_000,
windowsHide: true,
env: unprivilegedOwnershipMutationEnvironment(process.env),
cwd: PNPM_READ_CWD,
env: pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(process.env)),
...invocation.options,
});
}

/** Resolve the exact pnpm executable/group/bin that own this package. */
export function resolveCurrentPnpmGlobalOwner(invoked = process.argv[1]): PnpmGlobalOwnerResult {
export function resolveCurrentPnpmGlobalOwner(
invoked = process.argv[1],
deps: { commandPaths?: readonly string[]; spawn?: typeof spawnSync } = {},
): PnpmGlobalOwnerResult {
const spawn = deps.spawn ?? spawnSync;
return resolvePnpmGlobalOwner({
packageName: PKG,
packagePath: packageRoot(),
commandPaths: resolvePnpmCommands(),
commandPaths: deps.commandPaths ?? resolvePnpmCommands(),
runningShimPath: runningPnpmShimPath(invoked),
runPnpm: runPnpmCandidate,
runPnpm: (commandPath, args, capture) => runPnpmCandidate(commandPath, args, capture, spawn),
});
}

Expand Down Expand Up @@ -148,7 +155,10 @@ function runOwnedPnpm(
encoding: "utf8",
timeout: 180_000,
windowsHide: true,
env: unprivilegedOwnershipMutationEnvironment(target.env),
// Reads probe from the package dir; `add -g`/rollback children run from a neutral
// directory so a Windows cwd handle never pins open the package pnpm is replacing.
cwd: pnpmCommandCwd(args),
env: pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(target.env)),
...target.options,
});
}
Expand Down Expand Up @@ -275,16 +285,20 @@ export function latestVersion(
tag: string,
installer: Installer = detectInstall(),
owner?: PnpmGlobalOwner,
spawn: typeof spawnSync = spawnSync,
): string | null {
const resolvedOwner = installer === "pnpm" ? selectedPnpmOwner(owner) : undefined;
if (installer === "pnpm" && !resolvedOwner) return null;
const manager = registrySpawnTarget(installer, ["view", `${PKG}@${tag}`, "version"], resolvedOwner);
if (!manager) return null;
const r = spawnSync(manager.bin, manager.args, {
const r = spawn(manager.bin, manager.args, {
encoding: "utf8",
timeout: 12000,
windowsHide: true,
env: unprivilegedOwnershipMutationEnvironment(manager.env ?? process.env),
cwd: installer === "pnpm" ? PNPM_READ_CWD : undefined,
env: installer === "pnpm"
? pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(manager.env ?? process.env))
: unprivilegedOwnershipMutationEnvironment(manager.env ?? process.env),
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
...manager.options,
});
return r.status === 0 && typeof r.stdout === "string" ? (r.stdout.trim() || null) : null;
Expand Down Expand Up @@ -341,7 +355,10 @@ export function checkUpdatePackageIntegrity(
encoding: "utf8",
timeout: 12000,
windowsHide: true,
env: unprivilegedOwnershipMutationEnvironment(target.env ?? process.env),
cwd: installer === "pnpm" ? PNPM_READ_CWD : undefined,
env: installer === "pnpm"
? pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(target.env ?? process.env))
: unprivilegedOwnershipMutationEnvironment(target.env ?? process.env),
...target.options,
});
});
Expand Down
9 changes: 9 additions & 0 deletions src/update/pnpm-read-policy.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export declare const PNPM_READ_CWD: string;

export declare const PNPM_MUTATION_CWD: string;

export declare function pnpmCommandCwd(args?: readonly string[]): string;

export declare function pnpmReadEnvironment(
env?: Record<string, string | undefined>,
): Record<string, string | undefined>;
27 changes: 27 additions & 0 deletions src/update/pnpm-read-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { tmpdir } from "node:os";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";

/** Keep read-only pnpm probes away from the caller's project and its executable hooks. */
export const PNPM_READ_CWD = dirname(fileURLToPath(import.meta.url));

const PNPM_MUTATION_COMMANDS = new Set(["add", "install", "update", "remove", "uninstall"]);

/**
* Mutations (`add -g`, rollback) cannot run from inside the installed package: on Windows
* a child whose working directory sits in the tree being replaced pins it open and blocks
* removal. A stable directory outside the package keeps that handle neutral.
*/
export const PNPM_MUTATION_CWD = tmpdir();

/** Working directory for a pnpm child: read probes isolate, mutations stay outside the package. */
export function pnpmCommandCwd(args) {
return PNPM_MUTATION_COMMANDS.has(args?.[0]) ? PNPM_MUTATION_CWD : PNPM_READ_CWD;
}

export function pnpmReadEnvironment(env = process.env) {
const isolated = Object.fromEntries(
Object.entries(env).filter(([key]) => key.toLowerCase() !== "npm_config_ignore_pnpmfile"),
);
return { ...isolated, npm_config_ignore_pnpmfile: "true" };
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion structure/ops/service-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,6 @@ constants in child processes.

src/update/refresh-scheduler.ts owns the package cache timer and per-channel singleflight for the running proxy. Eligible npm, pnpm and Bun installs refresh missing or 20-hour-stale `version.json` after bind, check staleness hourly and retry failures with bounded backoff. Each server start owns one scheduler reference; the last matching stop disarms the timer. A stopped automatic lookup cannot write a late result, but an explicit check joining that lookup marks explicit interest and writes its successful result even if the last listener stops before it resolves. Source/mise installs and `OCX_DISABLE_UPDATE_CHECK=1` do not start automatic lookup; explicit requests remain available.

src/update/async-check.ts uses the existing owner-bound registry target with a bounded asynchronous child; pnpm owner discovery runs in src/update/pnpm-owner-worker.ts off the request loop. `src/update/notify.ts` writes successful results atomically and preserves a dismissal only for the same channel and version. The interactive pre-bind prompt reads the cache and does not launch a second detached refresh. `src/update/badge.ts` only reads the cache and reports unknown at 40 hours.
src/update/async-check.ts uses the existing owner-bound registry target with a bounded asynchronous child; pnpm owner discovery runs in src/update/pnpm-owner-worker.ts off the request loop. Read-only pnpm owner and registry probes — in the scheduler, the synchronous updater, and the `bin/ocx.mjs` package-manager self-update — run from the installed update module directory via `src/update/pnpm-read-policy.mjs` with project pnpmfiles disabled, never from the caller's workspace. pnpm mutations (`add -g`, rollback) instead run from a neutral directory outside the package — on Windows a cwd inside the tree being replaced pins it open and blocks removal. `src/update/notify.ts` writes successful results atomically and preserves a dismissal only for the same channel and version. The interactive pre-bind prompt reads the cache and does not launch a second detached refresh. `src/update/badge.ts` only reads the cache and reports unknown at 40 hours.

The desktop badge snapshot in src/update/desktop-badge.ts is process-local display state keyed by a Tauri session id. A 60-second shell heartbeat renews receipt time; entries expire after 180 seconds and the store retains at most 32 sessions. It is separate from the package version cache and from the updater job/ownership transaction. A proxy restart reports unknown until a bound desktop shell republishes; no update installation can be authorized by this snapshot.
Loading
Loading