Skip to content

Concurrent same-version scans replace the shared plugin directory and crash later workers with “os error 2” #824

Description

@msapelov

Summary

Two concurrent codex-security scan --auth chatgpt --mode deep commands against different repositories, using the same global @openai/codex-security@0.1.25 installation and default state directory, can cause the earlier scan to fail when it launches another worker:

Codex Exec exited with code 1: Error: No such file or directory (os error 2)

Starting the second scan reinstalls the same plugin version and replaces the directory used as the first scan's MCP coordinator working directory. A later worker inherits that deleted working directory and fails before starting a model session.

This occurs with the same package and plugin versions in both terminals. It does not require an upgrade or different model selections. An offline reproduction below triggers the exact error without credentials or model requests.

Environment

  • macOS 26.6.2, Apple Silicon (arm64)
  • Node.js 26.8.1; npm 11.19.0
  • Globally installed @openai/codex-security: 0.1.25
  • Bundled Codex CLI and SDK: 0.149.1
  • Bundled plugin: 0.1.94
  • ChatGPT authentication, deep scans, default shared state directory

User-visible reproduction

  1. In terminal A, start a deep scan of repository A:

    codex-security scan /path/to/repo-a \
      --auth chatgpt --mode deep \
      --model gpt-5.6-luna --effort xhigh
  2. Wait until its initial discovery workers have started.

  3. In terminal B, start another deep scan of repository B using the same global installation and default state directory:

    codex-security scan /path/to/repo-b \
      --auth chatgpt --mode deep \
      --model gpt-5.6-luna --effort xhigh
  4. Let a worker from scan A complete so the coordinator launches a replacement worker.

Expected: Both scans continue independently. Bootstrapping scan B does not invalidate paths used by scan A.

Observed: Scan A's next worker exits with the error above. The scan then cancels its other active workers and preserves partial findings.

In the observed run, scan B replaced the shared plugin directory about four minutes after scan A started. Scan A's fifth discovery worker failed before receiving a thread ID, about 27 minutes into the scan. The first four workers had started before the directory replacement. Similar failures were observed with other models; model access is not required to reproduce the startup error.

Cause

The following links are pinned to upstream main commit 746fb84a15c906850d266dbea7c9b9e9751394bc, checked on September 8, 2026. These behaviors are also present in the installed 0.1.25 package.

  1. bootstrapPlugin() unconditionally invokes codex plugin add, even when the staged plugin version already matches. Running it twice replaces the installed directory: its inode changes while the pathname stays the same.
  2. The MCP server uses cwd: ".", relative to that plugin installation. An already-running coordinator remains attached to the old, deleted directory.
  3. The startup lock is released before scan execution, allowing the second scan to reinstall the plugin while the first uses it.
  4. The worker executor supplies workingDirectory to the SDK. In bundled SDK 0.149.1, this becomes a CLI --cd argument, but the underlying spawn() call has no cwd option. The child inherits the coordinator's deleted working directory. Startup fails despite the valid --cd target.

The worker permission preflight uses an explicit process cwd, so it can succeed immediately before the SDK launch fails. Supplying an explicit cwd to the execution process also gets past the reproduced startup failure.

Offline reproduction

Save the script below as reproduce.mjs and run node reproduce.mjs with @openai/codex-security@0.1.25 already installed globally.

It calls the installed package's own bootstrap function twice in a disposable, credential-free Codex home and simulates the first coordinator's working directory. It does not modify the real scan state or global installation. Every execution prompt is empty, so no model requests are made. The temporary home is removed afterward.

import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";

// Uses the existing global package. Does not install or update npm packages.
const globalRoot = execFileSync("npm", ["root", "--global"], {
  encoding: "utf8",
}).trim();
const packageRoot = path.join(globalRoot, "@openai/codex-security");
const { bootstrapPlugin, resolveCodexCommand } = await import(
  pathToFileURL(path.join(packageRoot, "dist/runtime.js")).href
);
const { Codex } = await import(
  pathToFileURL(
    path.join(packageRoot, "node_modules/@openai/codex-sdk/dist/index.js"),
  ).href
);
const manifest = JSON.parse(
  await fs.readFile(path.join(packageRoot, "package.json"), "utf8"),
);
assert.equal(manifest.version, "0.1.25", "Reproducer targets version 0.1.25");

const originalCwd = process.cwd();
const isolatedHome = await fs.mkdtemp(path.join(os.tmpdir(), "cs-cwd-repro-"));
const workerOutput = path.join(isolatedHome, "worker-output");
// Do not inherit API keys, access tokens, or the user's Codex configuration.
const env = {
  PATH: process.env.PATH,
  HOME: os.homedir(),
  CODEX_HOME: isolatedHome,
  TMPDIR: os.tmpdir(),
  SHELL: "/bin/zsh",
};
const codexCommand = resolveCodexCommand(env);
const pluginSource = path.join(packageRoot, "_bundled_plugin");
const bootstrapOptions = { environment: env, codexCommand };
const args = ["exec", "--skip-git-repo-check", "--cd", workerOutput];

function runEmptyPrompt(cwd) {
  const result = spawnSync(codexCommand.command, args, {
    env,
    input: "",
    encoding: "utf8",
    timeout: 10_000,
    ...(cwd === undefined ? {} : { cwd }),
  });
  assert.ifError(result.error);
  assert.equal(result.status, 1);
  return result.stderr.trim();
}

try {
  await fs.mkdir(workerOutput);
  await fs.writeFile(path.join(isolatedHome, "config.toml"), "");
  const first = await bootstrapPlugin(isolatedHome, pluginSource, {
    ...bootstrapOptions,
    signal: AbortSignal.timeout(30_000),
  });
  // Simulate the already-running MCP coordinator's cwd.
  process.chdir(first.installedRoot);
  const originalInode = (await fs.stat(".")).ino;
  assert.match(runEmptyPrompt(), /No prompt provided via stdin/);
  console.log("PASS: startup works before another scan bootstraps");

  await bootstrapPlugin(isolatedHome, pluginSource, {
    ...bootstrapOptions,
    signal: AbortSignal.timeout(30_000),
  });
  const replacementInode = (await fs.stat(first.installedRoot)).ino;
  assert.notEqual(originalInode, replacementInode);
  console.log("PASS: same-version bootstrap replaced the plugin directory");

  const codex = new Codex({ codexPathOverride: codexCommand.command, env });
  const thread = codex.startThread({
    workingDirectory: workerOutput,
    skipGitRepoCheck: true,
  });
  await assert.rejects(
    thread.run("", { signal: AbortSignal.timeout(10_000) }),
    /Codex Exec exited with code 1: Error: No such file or directory \(os error 2\)/,
  );
  console.log("PASS: SDK reproduces the exact os error 2 startup failure");

  assert.match(runEmptyPrompt(workerOutput), /No prompt provided via stdin/);
  console.log("PASS: explicit spawn cwd gets past the startup failure");
  console.log("No model requests: all execution prompts were empty.");
} finally {
  process.chdir(originalCwd);
  await fs.rm(isolatedHome, { recursive: true, force: true });
}

Verified output:

PASS: startup works before another scan bootstraps
PASS: same-version bootstrap replaced the plugin directory
PASS: SDK reproduces the exact os error 2 startup failure
PASS: explicit spawn cwd gets past the startup failure
No model requests: all execution prompts were empty.

Related review and introduction

PR #440 enabled parallel ChatGPT scans by shortening the shared lock's lifetime. A P1 review comment already warned that a second scan could replace shared plugin files used by an active scan, focusing on different plugin versions. This reproduction demonstrates that the same-version case is affected too.

Source history places the concurrency regression for ChatGPT-authenticated scans in 0.1.12, released August 15, 2026: that release includes #440. Version 0.1.11 held the shared lock for the duration of a scan using stored credentials. The exact runtime failure was reproduced on 0.1.25; the historical 0.1.12 binary was not tested.

Workaround and suggested fix

Use a different CODEX_SECURITY_STATE_DIR for each concurrent scan, or run scans sequentially. Different repositories or --output-dir values alone do not isolate the shared plugin installation.

Preserve plugin installations for the lifetime of active scans, for example through isolated installations or safe reuse of an unchanged installation. Keep parallel scanning supported. An explicit worker process cwd is useful additional protection, but it does not by itself protect other plugin files that active scans still use.

A regression test should bootstrap the same plugin twice while retaining the first coordinator's working directory, then verify that a subsequent worker can start. The offline reproduction above provides a model-free starting point.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions