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
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1368,6 +1368,7 @@
"server-key-failover-e2e.test.ts": "server",
"server-kiro-completion-e2e.test.ts": "server",
"server-kiro-oauth-401-replay.test.ts": "server",
"server-live-frame-log.test.ts": "server",
"server-live-realtime-fixtures.test.ts": "server",
"server-live.test.ts": "server",
"server-loopback-host-gate.test.ts": "server",
Expand Down
4 changes: 2 additions & 2 deletions src/cli/codex-cli-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface CodexCliUpdateCommandDeps {
readonly inspectIdentity?: (input: CodexCliInstallationIdentityInput) => Promise<CodexCliInstallationIdentityReport>;
readonly deriveInstallationInput?: (
snapshot: CodexCliInstallationSnapshot,
) => CodexCliInstallationTargetDerivation;
) => Promise<CodexCliInstallationTargetDerivation>;
}

function identitySummary(report: CodexCliInstallationIdentityReport): string[] {
Expand Down Expand Up @@ -148,7 +148,7 @@ export async function handleCodexCliUpdateCommand(
const snapshot = trustedNodeLauncherContext()?.codexCliInspectionEnv;
const derive = deps.deriveInstallationInput
?? (await import("../codex/cli-installation-targets")).deriveCodexCliInstallationInput;
const derived = derive({
const derived = await derive({
codexCliPath: snapshot?.codexCliPath ?? null,
path: snapshot?.path ?? null,
pathExt: snapshot?.pathExt ?? null,
Expand Down
22 changes: 11 additions & 11 deletions src/client/hub-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,8 @@ import { clearableDeadline } from "../lib/abort";
import type { Desktop3pModelEntry } from "../claude/desktop-3p";
import { assertDesktop3pModelsValid } from "../claude/desktop-3p-guard";

/**
* A pairing grant may cross loopback or authenticated HTTPS, and nothing else.
*
* Mirrors the hub-side rule in src/server/gui-session.ts. Checking here too is not
* redundant: it keeps the client from spending a single-use code on a request the hub is
* certain to refuse.
*/
function isPairingTransportPermitted(origin: string): boolean {
/** Hub traffic may cross loopback or authenticated HTTPS, and nothing else. */
function isHubTransportPermitted(origin: string): boolean {
let url: URL;
try {
url = new URL(origin);
Expand Down Expand Up @@ -193,6 +187,12 @@ export function normalizeHubOrigin(input: string): string {
"Hub URL must be an HTTP(S) origin without credentials, query, fragment, or non-/v1 path",
);
}
if (!isHubTransportPermitted(parsed.origin)) {
throw new HubClientError(
"insecure_http_refused",
"Hub URLs require loopback or HTTPS; plaintext remote HTTP is not permitted",
);
}
return parsed.origin;
}

Expand Down Expand Up @@ -252,7 +252,7 @@ export async function exchangeConnectPairingGrant(
// Deliberateness is not the control that matters: the grant is readable by anything on the
// path and the session it mints is reusable. The hub refuses this exchange outright now, so
// sending it would only burn a single-use code against a certain rejection.
if (!isPairingTransportPermitted(origin)) {
if (!isHubTransportPermitted(origin)) {
throw new HubClientError("insecure_http_refused", "Pairing requires loopback or HTTPS; plaintext HTTP cannot carry a grant");
}
const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/opencodex-session`, {
Expand Down Expand Up @@ -481,7 +481,7 @@ export async function fetchHubUsage(
options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
): Promise<HubUsageReport> {
const origin = normalizeHubOrigin(serverUrl);
if (!isPairingTransportPermitted(origin)) {
if (!isHubTransportPermitted(origin)) {
throw new HubClientError("insecure_http_refused", "Client usage requires HTTPS or loopback HTTP");
}
const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/usage?${query}`, {
Expand Down Expand Up @@ -593,7 +593,7 @@ export async function downloadDesktop3pModels(
options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
): Promise<{ version: 1; models: Desktop3pModelEntry[] }> {
const origin = normalizeHubOrigin(serverUrl);
if (!isPairingTransportPermitted(origin)) {
if (!isHubTransportPermitted(origin)) {
throw new HubClientError("insecure_http_refused", "Desktop model snapshots require HTTPS or loopback HTTP");
}
try {
Expand Down
82 changes: 42 additions & 40 deletions src/codex/cli-installation-targets.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { closeSync, existsSync, openSync, readSync } from "node:fs";
import { win32 } from "node:path";
import { SHIM_MARKER } from "./shim-templates";
import type { CodexCliInstallationIdentityInput } from "./cli-installation-identity";
Expand Down Expand Up @@ -27,53 +26,40 @@ export type CodexCliInstallationTargetDerivation =

export interface CodexCliInstallationTargetDeps {
readonly platform?: NodeJS.Platform;
readonly exists?: (path: string) => boolean;
/** `refused` means the probe could not decide; a PATH scan must stop rather than
* attest a later candidate that the real launcher would never reach. */
readonly exists?: (path: string) => boolean | "refused" | Promise<boolean | "refused">;
/** Bounded prefix read used only to recognize an OpenCodex-owned wrapper. */
readonly fileContains?: (path: string, marker: string) => boolean;
readonly fileContains?: (path: string, marker: string) =>
boolean | "unavailable" | Promise<boolean | "unavailable">;
}

const DEFAULT_PATH_EXT = ".COM;.EXE;.BAT;.CMD;.PS1";
const SHIM_PROBE_BYTES = 8 * 1024;
const CODEX_PACKAGE_SUFFIX = "\\node_modules\\@openai\\codex\\bin\\codex.js";

function defaultFileContains(path: string, marker: string): boolean {
let descriptor: number | undefined;
let contains = false;
try {
descriptor = openSync(path, "r");
const bytes = Buffer.allocUnsafe(SHIM_PROBE_BYTES);
const count = readSync(descriptor, bytes, 0, bytes.length, 0);
contains = bytes.subarray(0, count).toString("utf8").includes(marker);
} catch {
contains = false;
} finally {
if (descriptor !== undefined) {
try { closeSync(descriptor); } catch { contains = false; }
}
}
return contains;
}

/**
* First match wins, mirroring PATH resolution: directories in order, and within
* each directory every PATHEXT suffix in order (or the exact name when it
* already carries an extension). Skipping a hit to keep scanning would attest
* something other than the launcher that actually resolves.
*/
function scanPath(
async function scanPath(
name: string,
pathValue: string | null | undefined,
pathExt: string | null | undefined,
exists: (path: string) => boolean,
): string | null {
exists: (path: string) => boolean | "refused" | Promise<boolean | "refused">,
): Promise<string | null> {
const extensions = (pathExt ?? DEFAULT_PATH_EXT).split(";").map(value => value.trim()).filter(Boolean);
const names = /\.[a-z0-9]+$/i.test(name) ? [name] : extensions.map(ext => name + ext.toLowerCase());
for (const entry of (pathValue ?? "").split(";")) {
const dir = entry.trim().replace(/^"+|"+$/g, "");
if (!dir) continue;
for (const candidateName of names) {
const candidate = win32.join(dir, candidateName);
if (exists(candidate)) return candidate;
const found = await exists(candidate);
if (found === "refused") return null;
if (found) return candidate;
}
}
return null;
Expand All @@ -86,40 +72,56 @@ function scanPath(
* false identity. No ambient environment is read: without a snapshot the result
* is unavailable rather than silently trusting the child's environment.
*/
export function deriveCodexCliInstallationInput(
export async function deriveCodexCliInstallationInput(
snapshot: CodexCliInstallationSnapshot,
deps: CodexCliInstallationTargetDeps = {},
): CodexCliInstallationTargetDerivation {
): Promise<CodexCliInstallationTargetDerivation> {
if ((deps.platform ?? process.platform) !== "win32") {
return { kind: "unavailable", reason: "unsupported_platform" };
}
const exists = deps.exists ?? existsSync;
const fileContains = deps.fileContains ?? defaultFileContains;
const safeRead = async (path: string, maxBytes: number, prefixOnly = false) => {
const { inspectWindowsInstallationFiles } = await import("./windows-installation-files");
return inspectWindowsInstallationFiles([{ path, maxBytes, metadataOnly: maxBytes === 0, prefixOnly }]);
};
const exists = deps.exists ?? (async (path: string) => {
const result = await safeRead(path, 0);
if (result.kind === "observed") return true;
return result.kind === "refused" ? "refused" : false;
});
const fileContains = deps.fileContains ?? (async (path: string, marker: string) => {
const result = await safeRead(path, SHIM_PROBE_BYTES, true);
if (result.kind !== "observed") return "unavailable";
return Buffer.from(result.files[0]!.bytes).toString("utf8").includes(marker);
});
const configured = snapshot.codexCliPath;

let candidate: string | null;
if (configured) {
if (/^[a-z]:[\\/]/i.test(configured)) {
if (!exists(configured)) return { kind: "unavailable", reason: "candidate_unavailable" };
if (await exists(configured) !== true) return { kind: "unavailable", reason: "candidate_unavailable" };
candidate = win32.normalize(configured);
} else {
if (configured.includes("/") || configured.includes("\\")) {
return { kind: "unavailable", reason: "candidate_unavailable" };
}
candidate = scanPath(configured, snapshot.path, snapshot.pathExt, exists);
candidate = await scanPath(configured, snapshot.path, snapshot.pathExt, exists);
if (!candidate) return { kind: "unavailable", reason: "candidate_unavailable" };
}
} else {
candidate = scanPath("codex", snapshot.path, snapshot.pathExt, exists);
candidate = await scanPath("codex", snapshot.path, snapshot.pathExt, exists);
if (!candidate) return { kind: "unavailable", reason: "candidate_unavailable" };
}

// An OpenCodex wrapper at the npm prefix is our own launcher, not the npm
// artifact. The renamed original beside it is the file npm wrote.
if (/\.cmd$/i.test(candidate) && fileContains(candidate, SHIM_MARKER)) {
const backing = candidate.slice(0, -".cmd".length) + ".opencodex-real.cmd";
if (!exists(backing)) return { kind: "unavailable", reason: "unsupported_layout" };
candidate = backing;
if (/\.cmd$/i.test(candidate)) {
const marker = await fileContains(candidate, SHIM_MARKER);
if (marker === "unavailable") return { kind: "unavailable", reason: "candidate_unavailable" };
if (marker) {
const backing = candidate.slice(0, -".cmd".length) + ".opencodex-real.cmd";
if (await exists(backing) !== true) return { kind: "unavailable", reason: "unsupported_layout" };
candidate = backing;
}
}

const base = win32.basename(candidate).toLowerCase();
Expand All @@ -131,19 +133,19 @@ export function deriveCodexCliInstallationInput(
} else {
return { kind: "unavailable", reason: "unsupported_layout" };
}
if (!exists(win32.join(prefix, "node_modules", "@openai", "codex", "package.json"))) {
if (await exists(win32.join(prefix, "node_modules", "@openai", "codex", "package.json")) !== true) {
return { kind: "unavailable", reason: "unsupported_layout" };
}

// The npm cmd-shim itself prefers %dp0%\node.exe before falling back to PATH.
let node = win32.join(prefix, "node.exe");
if (!exists(node)) {
const resolved = scanPath("node.exe", snapshot.path, null, exists);
if (await exists(node) !== true) {
const resolved = await scanPath("node.exe", snapshot.path, null, exists);
if (!resolved) return { kind: "unavailable", reason: "toolchain_unresolved" };
node = resolved;
}
const npmCli = win32.join(win32.dirname(node), "node_modules", "npm", "bin", "npm-cli.js");
if (!exists(npmCli)) return { kind: "unavailable", reason: "toolchain_unresolved" };
if (await exists(npmCli) !== true) return { kind: "unavailable", reason: "toolchain_unresolved" };

return {
kind: "derived",
Expand Down
30 changes: 21 additions & 9 deletions src/codex/windows-installation-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export interface WindowsInstallationFileRequest {
readonly path: string;
readonly maxBytes: number;
readonly hashOnly?: boolean;
/** Validate and hold the path without reading its contents. */
readonly metadataOnly?: boolean;
/** Read at most maxBytes from the start instead of refusing an oversized file. */
readonly prefixOnly?: boolean;
}
export interface WindowsInstallationFileIdentity {
readonly volumeSerial: string;
Expand Down Expand Up @@ -76,6 +80,8 @@ export async function inspectWindowsInstallationFiles(
const parsedPath = request && components(request.path);
if (!parsedPath || !Number.isSafeInteger(request.maxBytes) || request.maxBytes < 0
|| (request.hashOnly !== undefined && typeof request.hashOnly !== "boolean")
|| (request.metadataOnly !== undefined && typeof request.metadataOnly !== "boolean")
|| (request.prefixOnly !== undefined && typeof request.prefixOnly !== "boolean")
|| request.maxBytes > (request.hashOnly ? 256 * MIB : MIB)) return null;
ceiling += request.maxBytes;
return parsedPath;
Expand Down Expand Up @@ -184,29 +190,35 @@ export async function inspectWindowsInstallationFiles(
}
const handle = relativeOpen(parent, names[names.length - 1]!, false);
const identity = inspect(handle, false);
if (identity.size > request.maxBytes) throw new InspectionRefusal("size-limit");
if (!request.metadataOnly && !(request.prefixOnly && !request.hashOnly)
&& identity.size > request.maxBytes) throw new InspectionRefusal("size-limit");
return { request, handle, identity };
});
openedForTests?.();
const observed = files.map(({ request, handle, identity }) => {
const hash = createHash("sha256");
const bytes = request.hashOnly ? new Uint8Array() : new Uint8Array(identity.size);
const chunk = Buffer.alloc(Math.min(MIB, Math.max(1, identity.size)));
if (request.metadataOnly) return { path: request.path, identity, bytes: new Uint8Array(), digest: "" };
const truncated = Boolean(request.prefixOnly) && !request.hashOnly && identity.size > request.maxBytes;
const readLimit = truncated ? request.maxBytes : identity.size;
const bytes = request.hashOnly ? new Uint8Array() : new Uint8Array(readLimit);
const chunk = Buffer.alloc(Math.min(MIB, Math.max(1, readLimit)));
const read = Buffer.alloc(4);
let offset = 0;
while (offset < identity.size) {
const length = Math.min(chunk.length, identity.size - offset);
while (offset < readLimit) {
const length = Math.min(chunk.length, readLimit - offset);
if (!k.ReadFile!(handle, ffi.ptr(chunk), length, ffi.ptr(read), null)) throw new InspectionRefusal("read-failed");
const count = read.readUInt32LE(0);
if (!count || count > length) throw new InspectionRefusal("read-failed");
hash.update(chunk.subarray(0, count));
if (!request.hashOnly) bytes.set(chunk.subarray(0, count), offset);
if (!truncated) hash.update(chunk.subarray(0, count));
offset += count;
}
if (!k.ReadFile!(handle, ffi.ptr(chunk), 1, ffi.ptr(read), null) || read.readUInt32LE(0) !== 0) {
throw new InspectionRefusal("identity-changed");
if (!truncated) {
if (!k.ReadFile!(handle, ffi.ptr(chunk), 1, ffi.ptr(read), null) || read.readUInt32LE(0) !== 0) {
throw new InspectionRefusal("identity-changed");
}
}
return { path: request.path, identity, bytes, digest: hash.digest("hex") };
return { path: request.path, identity, bytes, digest: truncated ? "" : hash.digest("hex") };
});
for (const file of files) {
if (JSON.stringify(inspect(file.handle, false)) !== JSON.stringify(file.identity)) {
Expand Down
17 changes: 16 additions & 1 deletion src/providers/alibaba-region-backup.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { copyFileSync, existsSync, linkSync, readFileSync, rmSync } from "node:fs";
import { chmodSync, copyFileSync, existsSync, linkSync, readFileSync, rmSync } from "node:fs";
import { getConfigPath } from "../config";
import { hardenSecretPath } from "../lib/windows-secret-acl";

export interface AlibabaBackupIO {
exists: (path: string) => boolean;
read: (path: string) => Buffer;
copy: (source: string, destination: string) => void;
harden: (path: string) => void;
/** Publish with no-replace semantics: fails with EEXIST if the destination exists. */
publishNoReplace: (temp: string, destination: string) => void;
remove: (path: string) => void;
Expand All @@ -14,6 +16,16 @@ const DEFAULT_IO: AlibabaBackupIO = {
exists: existsSync,
read: path => readFileSync(path),
copy: (source, destination) => copyFileSync(source, destination),
harden: path => {
// POSIX: a failed chmod must not publish a credential-bearing backup with weak permissions.
// Windows keeps its own control via hardenSecretPath below, which is the required check.
if (process.platform === "win32") {
try { chmodSync(path, 0o600); } catch { /* Windows may not support POSIX chmod */ }
} else {
chmodSync(path, 0o600);
}
if (process.platform === "win32") hardenSecretPath(path, { required: true });
},
publishNoReplace: linkSync,
remove: path => rmSync(path, { force: true }),
};
Expand Down Expand Up @@ -57,6 +69,9 @@ export function backupConfigBeforeAlibabaRegionMigration(
const temp = `${backup}.${process.pid}.tmp`;
try {
io.copy(configPath, temp);
// The snapshot contains credentials. Harden it before publication so the
// stable backup path is never exposed with inherited permissions or ACLs.
io.harden(temp);
// Verify before publishing: a short copy must never become the snapshot.
if (!io.read(temp).equals(source)) {
throw new AlibabaBackupIntegrityError(`failed to write a complete backup to ${temp}`);
Expand Down
Loading
Loading