Skip to content
Merged
14 changes: 8 additions & 6 deletions docs-site/src/content/docs/guides/chatgpt-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,11 @@ restart transaction.

## Failure behavior and known limits

When the platform is not macOS, the OpenCodex runtime is missing, or the filter
self-test fails, the launcher runs the original binary with untouched stdout. A
missing bundled app-server binary is the exception: there is nothing to fall back
to, so the launcher exits with an error (see below).
The launcher verifies the executable before every start, including Desktop respawns.
If stdout filtering is unavailable or its self-test fails, the verified executable
runs with untouched stdout. If executable verification fails or the OpenCodex
validator is unavailable, the launcher exits with an error without starting the
app-server. A missing bundled app-server binary also prevents startup (see below).
A filter that passes the self-test and then dies mid-session closes the pipe.
What the bundled app-server does after that has not been verified; it may get
SIGPIPE or a write error and be respawned by Desktop through the same launcher.
Expand All @@ -117,8 +118,9 @@ remaining stream to raw bytes.

The experiment depends on the bundled binary path, the app honoring
`CODEX_CLI_PATH`, and current RPC field shapes. Updates may change these. A moved
or removed OpenCodex installation fails the launcher preflight and runs the
original binary. Run `ocx chatgpt launch` again after relocating the installation.
or removed OpenCodex installation can leave the launcher unable to verify the
executable, so it refuses startup. Run `ocx chatgpt launch` again after relocating
the installation, or `ocx chatgpt restore` to remove the override.
If an app update moves or removes the bundled app-server binary itself, the
launcher cannot start it: it prints a message naming `ocx chatgpt launch` and
`ocx chatgpt restore` on stderr and exits, and Desktop cannot start its
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ko/reference/cli/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서

일치가 확인된(`matched`) autostart wrapper만 backing Codex를 직접 실행합니다. 검사 예외를 포함한 알 수 없는 상태는 검증된 bypass가 필요하며, 관리되는 Windows CMD wrapper는 현재 생성 템플릿 전체와 일치해야 `OCX_SHIM_BYPASS=1`로 실행합니다. 일반 npm 런처는 지원합니다. 검증할 수 없거나 오래된 wrapper는 종료 코드 1로 거부하며 Codex를 시작하지 않습니다. `ocx codex-shim install`로 갱신하세요. 이 실행은 shim을 수리하지 않으며, 내용 검사는 Windows backing 파일 출처나 동시 경로 교체를 보장하지 않습니다.

macOS에서는 도움말과 버전을 포함한 매 실행 시, 선택한 실행 파일의 경로나 정규 경로가 `.app` 번들 내부에 있으면 검증합니다. 이름을 바꾼 번들과 번들 내부를 가리키는 심볼릭 링크도 번들 및 실행 파일의 안전한 소유권, 권한, OpenAI 서명을 요구합니다. 선택한 번들 구성원은 실행 파일, 중간 디렉터리 또는 번들 전체의 심볼릭 링크를 거쳐도 해당 번들의 정규 `Contents/` 내부에 있어야 합니다. 검증 실패 시 Codex를 시작하지 않고 종료 코드 1을 반환합니다. npm 설치를 포함한 일반 번들 외부 실행 파일의 동작은 유지됩니다.

런처 도움말은 `ocx help codex`, Codex 도움말은 `ocx codex --help`입니다. 이후 plain `codex`의 동작은 기존과 같습니다.

### `ocx opencode [opencode args...]`
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/reference/cli/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,8 @@ A missing `OPENCODEX_API_AUTH_TOKEN` is read from this installation's `service-a

Only a matched autostart wrapper launches its backing Codex directly. Unknown state, including an inspection exception, requires a verified bypass; Windows CMD managed wrappers must match the complete current generated template before the selected absolute command runs with `OCX_SHIM_BYPASS=1`. Ordinary npm launchers remain supported. Unverifiable or obsolete wrappers refuse with exit 1 and start no Codex; run `ocx codex-shim install` to refresh them. This launch does not repair shims. The content check does not establish Windows backing-file provenance or prevent concurrent pathname replacement.

On macOS, every launch, including help/version, verifies the selected executable when its selected path or canonical location is inside any `.app` bundle. Renamed bundles and symlinks into bundles require the same safe ownership, permissions and OpenAI signatures for the bundle and executable. A selected bundle member must stay inside that bundle's canonical `Contents/`, including through executable, directory or whole-bundle symlinks. Failed verification exits 1 without starting Codex. Ordinary non-bundle executables, including npm installations, retain their behavior.

Use `ocx help codex` for launcher help and `ocx codex --help` for Codex help. Launching plain `codex` later retains its existing behavior.

### `ocx opencode [opencode args...]`
Expand Down
15 changes: 9 additions & 6 deletions docs-site/src/content/docs/tr/guides/chatgpt-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,12 @@ etkisiz bir yeniden başlatma işlemi garanti etmez.

## Hata davranışı ve bilinen sınırlar

Platform macOS değilse, OpenCodex çalışma zamanı eksikse veya filtre öz sınaması
başarısızsa başlatıcı özgün dosyayı stdout'u değiştirmeden çalıştırır. Paketlenmiş
app-server dosyasının eksikliği istisnadır: geri dönülecek dosya olmadığı için
başlatıcı hatayla çıkar.
Başlatıcı, Desktop'ın yeniden başlattığı işlemler dahil her çalıştırmadan önce
yürütülebilir dosyayı doğrular. Stdout filtresi kullanılamıyorsa veya öz sınaması
başarısızsa doğrulanmış dosya stdout değiştirilmeden çalışır. Dosya doğrulaması
başarısızsa ya da OpenCodex doğrulayıcısı kullanılamıyorsa başlatıcı app-server'ı
çalıştırmadan hatayla çıkar. Paketlenmiş app-server dosyasının eksikliği de
başlatmayı engeller.

Öz sınamayı geçen filtre oturum ortasında çökerse boru kapanır. Paketlenmiş
app-server'ın bundan sonra ne yaptığı doğrulanmamıştır; SIGPIPE/yazma hatası
Expand All @@ -115,8 +117,9 @@ ham baytlara geçirir.

Deney; paketlenmiş dosya yoluna, uygulamanın `CODEX_CLI_PATH` değerini kullanmasına
ve güncel RPC alan biçimlerine bağlıdır. Güncellemeler bunları değiştirebilir.
OpenCodex taşınır veya kaldırılırsa başlatıcının ön kontrolü başarısız olur ve
özgün dosya çalışır. Kurulumu taşıdıktan sonra `ocx chatgpt launch` komutunu yeniden çalıştırın.
OpenCodex taşınır veya kaldırılırsa başlatıcı dosyayı doğrulayamayabilir ve
başlatmayı reddeder. Kurulumu taşıdıktan sonra `ocx chatgpt launch` komutunu yeniden
çalıştırın veya geçersiz kılmayı kaldırmak için `ocx chatgpt restore` kullanın.

Uygulama güncellemesi paketlenmiş app-server dosyasını taşır veya kaldırırsa
başlatıcı onu çalıştıramaz: stderr üzerinde `ocx chatgpt launch` ve
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout-clients.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"droid-normalized-reasoning-defaults.test.ts": "clients",
"desktop-app-server-shim.test.ts": "clients",
"desktop-app-server-shim-launcher.test.ts": "clients",
"desktop-app-server-exec-boundary.test.ts": "clients",
"desktop-bundle-trust-async.test.ts": "clients",
"desktop-chatgpt-config.test.ts": "clients",
"desktop-supervised-restart.test.ts": "clients",
Expand Down
4 changes: 4 additions & 0 deletions scripts/test-layout/layout-exec-boundary.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
{
"exec-trust-boundary.test.ts": "codex-integration",
"exec-boundary-launch.test.ts": "codex-integration",
"exec-launcher-boundary.test.ts": "codex-integration",
"exec-cli-renamed-app-bundle.test.ts": "codex-integration",
"exec-cli-bundle-containment.test.ts": "codex-integration",
"exec-renamed-app-bundle.test.ts": "codex-integration",
"exec-known-app-bundles.test.ts": "codex-integration"
}
5 changes: 5 additions & 0 deletions src/chatgpt/app-server-shim/bundle-trust-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export function bundleTrustStat(path: string): ReturnType<BundleTrustDeps["stat"

type FilesystemTrustDeps = Omit<BundleTrustDeps, "codesign">;

/** The inline bundle root is code: protect its file and directory entry like the bundle. */
export function untrustedLauncherFilesystemReason(path: string, deps: FilesystemTrustDeps, existing = true): string | null {
return (existing ? ownedSafely(deps, path, "file") : null) ?? untrustedAncestorReason(path, deps);
}

function ownedSafely(deps: FilesystemTrustDeps, path: string, kind: "file" | "directory"): string | null {
const s = deps.stat(path);
if (!s) return "is missing";
Expand Down
46 changes: 40 additions & 6 deletions src/chatgpt/app-server-shim/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { randomBytes } from "node:crypto";
import { join, posix } from "node:path";
import { getConfigDir } from "../../config/paths";
import { selfLaunchArgv } from "../../lib/self-launch-argv";
import { defaultBundleTrustDeps } from "./bundle-trust";
import { untrustedLauncherFilesystemReason, type BundleTrustDeps } from "./bundle-trust-policy";

export const CHATGPT_APP_CODEX_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex";

Expand Down Expand Up @@ -47,24 +49,41 @@ function shellQuote(value: string): string {
}

/**
* A failed precondition or a failed self-test runs the original binary with untouched stdout.
* A failed filter self-test runs the verified binary with untouched stdout.
* Executable verification is required on every invocation, including passthrough and respawns.
* A filter that passes the self-test and then dies mid-session closes the pipe.
* Expected (not yet validated against the bundled app-server): the server gets SIGPIPE or a
* write error and Desktop respawns it through the same launcher.
*/
export function buildChatgptShimLauncher(argv: readonly string[], real = CHATGPT_APP_CODEX_BINARY): string {
export function buildChatgptShimLauncher(
argv: readonly string[],
real = CHATGPT_APP_CODEX_BINARY,
validator: readonly string[] = [process.execPath, ...selfLaunchArgv(["internal", "codex-exec-target"])],
bundleRoot = /^(.*?\.app)\/Contents\//i.exec(real)?.[1],
): string {
return `#!/bin/bash
# opencodex (experimental): ChatGPT app-server stdout passes through the quota-gate filter.
REAL=${shellQuote(real)}
FILTER=(${argv.map(shellQuote).join(" ")})
${bundleRoot === undefined ? "" : `BUNDLE_ROOT=${shellQuote(bundleRoot)}\n`}FILTER=(${argv.map(shellQuote).join(" ")})
VALIDATE=(${validator.map(shellQuote).join(" ")})
if [ ! -x "$REAL" ]; then
echo "opencodex (experimental): the bundled app-server is no longer at $REAL; run 'ocx chatgpt launch' again or 'ocx chatgpt restore'." >&2
exit 127
fi
validate_real() {
local target
if [ ! -x "\${VALIDATE[0]}" ] || ! target=$("\${VALIDATE[@]}" ${bundleRoot === undefined ? "" : '--bundle-root "$BUNDLE_ROOT" '}-- "$REAL") || [ -z "$target" ]; then
echo "opencodex: app-server execution verification failed; run 'ocx chatgpt launch' again or 'ocx chatgpt restore'." >&2
return 126
fi
REAL="$target"
}
if [ "$(uname -s)" = "Darwin" ] && [ -x "\${FILTER[0]}" ] \\
&& "\${FILTER[@]}" --self-test >/dev/null 2>&1; then
validate_real || exit 126
exec "$REAL" "$@" > >(exec "\${FILTER[@]}")
fi
validate_real || exit 126
exec "$REAL" "$@"
`;
}
Expand All @@ -74,17 +93,32 @@ exec "$REAL" "$@"
* launcher, so an existing symbolic link is replaced rather than followed and a respawn during
* the write never runs a half-written script.
*/
export function writeChatgptShimLauncher(configDir = getConfigDir(), real = CHATGPT_APP_CODEX_BINARY): string {
export function writeChatgptShimLauncher(
configDir = getConfigDir(), real = CHATGPT_APP_CODEX_BINARY,
bundleRoot = /^(.*?\.app)\/Contents\//i.exec(real)?.[1],
filesystem: Pick<BundleTrustDeps, "uid" | "stat" | "adminGroupId"> | undefined = process.platform === "darwin" ? defaultBundleTrustDeps() : undefined,
): string {
if (!bundleRoot || !posix.isAbsolute(bundleRoot) || !real.startsWith(bundleRoot + "/Contents/")) {
throw new Error("the app-server launcher requires its discovered bundle root");
}
mkdirSync(configDir, { recursive: true });
const path = chatgptShimLauncherPath(configDir);
const argv = [process.execPath, ...selfLaunchArgv(["internal", "chatgpt-app-server-filter"])];
const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
let created = false;
const assertFilesystem = (file: string, existing: boolean) => {
const reason = filesystem && untrustedLauncherFilesystemReason(file, filesystem, existing);
if (reason) throw new Error(`unsafe app-server launcher: ${reason}`);
};
try {
writeFileSync(temp, buildChatgptShimLauncher(argv, real), { mode: 0o755, flag: "wx" });
assertFilesystem(temp, false);
writeFileSync(temp, buildChatgptShimLauncher(argv, real, undefined, bundleRoot), { mode: 0o755, flag: "wx" });
created = true;
chmodSync(temp, 0o755);
assertFilesystem(temp, true);
renameSync(temp, path);
} catch (error) {
try { unlinkSync(temp); } catch { /* the temp file was never created */ }
if (created) { try { unlinkSync(temp); } catch { /* preserve the publication error */ } }
throw error;
}
return path;
Expand Down
2 changes: 1 addition & 1 deletion src/cli/chatgpt-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ CODEX_CLI_PATH launcher: ${app.shim === null ? "unknown" : app.shim ? "yes" : "n
console.error("ChatGPT process inspection failed; no launcher was changed and no app was opened. Retry after resolving inspection.");
return 1;
}
if (binary) writeChatgptShimLauncher(undefined, binary);
if (binary) writeChatgptShimLauncher(undefined, binary, install.root);
if (!(await quitApp(install, launcher))) {
console.error("ChatGPT exit could not be confirmed; no new app was opened. Check the running app and retry.");
return 1;
Expand Down
28 changes: 20 additions & 8 deletions src/cli/codex-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ import { spawn } from "node:child_process";
import { loadConfig } from "../config";
import { getCodexRoutingKind, type CodexRoutingKind } from "../codex/inject";
import { inspectCodexShimBackingForCommand } from "../codex/shim";
import { validatedCodexExecInvocationAsync, type CodexExecBoundaryAsyncDeps } from "../codex/exec-boundary";
import { CODEX_GLOBAL_OPTIONS_WITH_VALUE, CODEX_INTERNAL_COMMANDS, CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC } from "../codex/shim-templates";
import { withoutSiblingMarker } from "../codex/sibling-start";
import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
import { tokenCollidesWithAdmin } from "../lib/admin-secrets";
import { selfLaunchArgv } from "../lib/self-launch-argv";
import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets";
import { commandInvocation } from "../lib/win-exec";
import { findExecutableOnPath } from "../remote-control/workspace-executable";
import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness";
import { codexCredentialDestinationIsTrusted, resolveCodexLaunchHome } from "./codex-launch-auth";
Expand Down Expand Up @@ -265,14 +265,26 @@ export function resolveCodexLaunchCommand(
}
}

/** Launch the selected Codex command with inherited IO and preserve its exit status. */
function spawnCodex(args: readonly string[], env: CodexLaunchEnv): Promise<number> {
export interface CodexSpawnDeps extends CodexLaunchCommandDeps {
execBoundary?: CodexExecBoundaryAsyncDeps;
spawn?: typeof spawn;
}

/** Validate the final backing command immediately before each launch, including fast paths. */
export async function spawnCodex(args: readonly string[], env: CodexLaunchEnv, deps: CodexSpawnDeps = {}): Promise<number> {
let launch: CodexLaunchCommand, inv: Awaited<ReturnType<typeof validatedCodexExecInvocationAsync>>;
try {
launch = resolveCodexLaunchCommand(env, deps);
inv = await validatedCodexExecInvocationAsync(launch.command, args, { ...deps.execBoundary, verifyAnyAppBundle: true, env: launch.env as NodeJS.ProcessEnv });
} catch (error) {
// A selected executable that vanished before verification is the same missing CLI
// the spawn-time ENOENT branch below reports, not a trust refusal.
const missing = error instanceof CodexNotFoundError || (error as NodeJS.ErrnoException | null)?.code === "ENOENT";
console.error(missing ? CODEX_INSTALL_HINT : `❌ Cannot launch codex: ${error instanceof Error ? error.message : "executable verification failed"}`);
return 1;
}
return new Promise<number>(resolve => {
let launch: CodexLaunchCommand;
try { launch = resolveCodexLaunchCommand(env); }
catch (error) { console.error(error instanceof CodexNotFoundError ? CODEX_INSTALL_HINT : CODEX_UNVERIFIED_SHIM); resolve(1); return; }
const inv = commandInvocation(launch.command, args);
const child = spawn(inv.file, inv.args, { stdio: "inherit", env: launch.env as NodeJS.ProcessEnv, ...inv.options });
const child = (deps.spawn ?? spawn)(inv.file, inv.args, { stdio: "inherit", env: launch.env as NodeJS.ProcessEnv, ...inv.options });
child.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
console.error(CODEX_INSTALL_HINT);
Expand Down
2 changes: 2 additions & 0 deletions src/cli/codex-shim-autorestore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export function skipsCodexShimAutoRestore(command: string | undefined, args: str
if (command === "status" || command === "doctor") return true;
// The launcher owns shim handoff after classifying routing, including native fast paths.
if (command === "codex") return true;
// Exec admission only observes the selected target; it must never repair a shim first.
if (command === "internal" && args[1] === "codex-exec-target") return true;
// `lab` is read-only inspection; it must not trigger shim side effects.
if (command === "lab") return true;
// Local messaging owns its helpers; even malformed usage must not repair/start anything.
Expand Down
31 changes: 29 additions & 2 deletions src/cli/internal-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,42 @@
* that same user, and a same-uid process could call `kill` directly. A token here would
* protect nothing and would imply a boundary that does not exist.
*/
const USAGE = "Usage: ocx internal desktop-restart-handoff --plan <path> | chatgpt-app-server-filter [--self-test]";
import type { CodexExecBoundaryAsyncDeps } from "../codex/exec-boundary";

const USAGE = "Usage: ocx internal desktop-restart-handoff --plan <path> | chatgpt-app-server-filter [--self-test] | codex-exec-target [--bundle-root <path>] -- <path>";

function optionValue(args: readonly string[], name: string): string | undefined {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
}

export async function handleInternalCommand(args: readonly string[]): Promise<number> {
/**
* Dispatch internal helpers with command-specific argument checks and exit codes.
* The executable-target helper writes one trusted path to stdout only after validation.
*/
export async function handleInternalCommand(args: readonly string[], execBoundary: CodexExecBoundaryAsyncDeps = {}): Promise<number> {
const sub = args[0];
if (sub === "codex-exec-target") {
const bundled = args[1] === "--bundle-root";
const root = bundled ? args[2] : undefined;
const separator = bundled ? 3 : 1;
const command = args[separator + 1];
if (args.length !== separator + 2 || args[separator] !== "--" || !command || /[\r\n\0]/.test(command)
|| (bundled && (!root || !root.startsWith("/") || /[\r\n\0]/.test(root)))) {
console.error(USAGE);
return 2;
}
try {
const { assertCodexExecTargetTrustedAsync } = await import("../codex/exec-boundary");
const target = await assertCodexExecTargetTrustedAsync(command, bundled ? { ...execBoundary, expectedBundleRoot: root } : execBoundary);
if (!target || /[\r\n]/.test(target)) throw new Error("invalid executable path");
console.log(target);
return 0;
} catch (error) {
console.error(`OpenCodex refused the app-server executable: ${error instanceof Error ? error.message : "verification failed"}`);
return 1;
}
}
if (sub === "chatgpt-app-server-filter") {
if (args.length > 2 || (args[1] !== undefined && args[1] !== "--self-test")) {
console.error(USAGE);
Expand Down
Loading
Loading