#4488 — Git writable-root configuration

Issue · 2026-09-30 · PARTIALLY REPRODUCED

High confidence in the generated configuration finding; actual Codex sandbox enforcement and Git branch/stash outcomes are unverified. The claim is that linked-worktree Git operations fail because common-directory ref maintenance paths are absent from additional writable roots.

Tested scope and result

Two clean checkouts of trusted main produced identical results using synthetic worktree metadata inside owned temporary directories. The real gitWritableRootsForWorkspace, buildCodexConfig and toCodexPermissionSettings functions ran. Neither generated configuration nor policy was applied. No Codex process, sandboxed command, branch deletion, stash operation or permission change was attempted.

Common-directory pathAttached fixtureDetached fixture
objects/synthetic; refs/heads/topic/syntheticIncluded by generated rootsIncluded by generated roots
packed-refs; packed-refs.lock; refs/stash; logs/refs/stashAbsent from generated rootsAbsent from generated roots
config; hooks/syntheticAbsentAbsent

“Included” means a lexical descendant of an emitted root, not an enforcement result. The JSON configuration and typed policy contain the same roots. Attached HEAD emits only its branch-parent and reflog-parent directories; detached HEAD emits the heads directories. A mismatched reverse worktree pointer returns no Git roots. Every fixture path and write stays within the owned temporary hierarchy.

Expected versus actual

For the reported integration claim, expected behavior is successful supported Git ref maintenance under the intended workspace policy. This fixture cannot establish that integration behavior. Its narrower expected diagnostic is that relevant common-directory paths appear in the emitted configuration if that configuration supplies their write access. Actual output omits the packed-ref and stash paths while preserving object and branch-path controls. The test passes because it asserts this observed omission, not because it proves the issue fixed.

Environment and base

Trusted base: facb6c161d9915d2517ed15f6101e4295cf85da1. Linux container; Node 24.19.0; repository-pinned pnpm 9.15.0; Vitest 4.1.1. Two separate detached worktrees with separate frozen installs, fresh temporary metadata per run, Turbo forced with zero cached tasks. Shared package store only. No additional dependencies. This is not the issue's macOS/Codex CLI 0.157.1 environment.

Exact repeatable steps

git clone https://github.com/get-bb/bb.git first
cd first
git checkout --detach facb6c161d9915d2517ed15f6101e4295cf85da1
corepack pnpm install --frozen-lockfile
# Save the inline test below as plugins/provider-codex/src/issue4488.test.ts
corepack pnpm exec turbo run test --filter=bb-plugin-provider-codex --force -- issue4488.test.ts --silent=false
# Repeat in a separate fresh clone named second at the identical SHA.

The execution environment used its preinstalled pinned pnpm launcher, separate per-checkout npm/build caches and the existing package store. Those local cache paths are omitted. Both installations exited 0; unrelated missing generated CLI-bin warnings did not prevent the focused dependency build/test.

Inline test

import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, it } from "vitest";
import { buildCodexConfig, gitWritableRootsForWorkspace, toCodexPermissionSettings } from "./session-params.js";
import type { CodexSessionOptions } from "./session-params.js";

it("records generated Git roots for synthetic attached and detached worktrees", () => {
  const root = realpathSync(mkdtempSync(path.join(tmpdir(), "synthetic-git-roots-")));
  const workspace = path.join(root, "worktree");
  const common = path.join(root, "repo.git");
  const gitDir = path.join(common, "worktrees", "synthetic");
  const options: CodexSessionOptions = {
    permissionMode: "accept-edits", permissionScope: "workspace",
    approvalReviewer: "user", permissionEscalation: "ask",
  };
  const outcomes = [];
  try {
    for (const p of [workspace, gitDir, path.join(common, "objects"), path.join(common, "refs/heads/topic"), path.join(common, "logs/refs/heads/topic")]) mkdirSync(p, { recursive: true });
    writeFileSync(path.join(workspace, ".git"), `gitdir: ${gitDir}\n`);
    writeFileSync(path.join(gitDir, "gitdir"), `${path.join(workspace, ".git")}\n`);
    writeFileSync(path.join(gitDir, "commondir"), "../..\n");
    for (const [name, head] of [["attached", "ref: refs/heads/topic/synthetic\n"], ["detached", "0123456789abcdef0123456789abcdef01234567\n"]]) {
      writeFileSync(path.join(gitDir, "HEAD"), head);
      const roots = gitWritableRootsForWorkspace(workspace);
      const args = { options, gitWritableRoots: roots, additionalWorkspaceWriteRoots: [] };
      const config = buildCodexConfig({ ...args, threadId: "synthetic-thread" });
      const permission = toCodexPermissionSettings(args);
      expect(config?.["sandbox_workspace_write.writable_roots"]).toEqual(roots);
      expect(permission.sandboxPolicy).toMatchObject({ type: "workspaceWrite", writableRoots: roots });
      expect(permission.sandbox).toBe("workspace-write");
      const includedByRoot = (candidate: string) => roots.some(r => candidate === r || candidate.startsWith(r + path.sep));
      const coverage = Object.fromEntries(["objects/synthetic", "refs/heads/topic/synthetic", "packed-refs", "packed-refs.lock", "refs/stash", "logs/refs/stash", "config", "hooks/synthetic"].map(p => [p, includedByRoot(path.join(common, p))]));
      expect(coverage).toEqual({ "objects/synthetic": true, "refs/heads/topic/synthetic": true, "packed-refs": false, "packed-refs.lock": false, "refs/stash": false, "logs/refs/stash": false, "config": false, "hooks/synthetic": false });
      expect(roots.every(r => r.startsWith(root + path.sep))).toBe(true);
      outcomes.push({ name, roots: roots.map(r => path.relative(root, r)), configurationAndPolicyAgree: true, lexicalRootCoverage: coverage });
    }
    writeFileSync(path.join(gitDir, "gitdir"), `${path.join(root, "other", ".git")}\n`);
    expect(gitWritableRootsForWorkspace(workspace)).toEqual([]);
    console.log("ISSUE4488_EVIDENCE=" + JSON.stringify({ outcomes, mismatchedBackPointerRoots: [], configurationApplied: false, gitOperationsExecuted: false }));
  } finally {
    rmSync(root, { recursive: true, force: true });
  }
});

Actual evidence and second clean reproduction

The same agent personally ran both checkouts at the recorded SHA. This is a same-agent second clean reproduction, not independent verification. Both runs: 1 test passed; 6 Turbo tasks successful; 0 cached. No test correction was needed. Structured output is identical:

{
  "first": {
    "outcomes": [
      {
        "name": "attached",
        "roots": [
          "repo.git/worktrees/synthetic",
          "repo.git/objects",
          "repo.git/refs/heads/topic",
          "repo.git/logs/refs/heads/topic"
        ],
        "configurationAndPolicyAgree": true,
        "lexicalRootCoverage": {
          "objects/synthetic": true,
          "refs/heads/topic/synthetic": true,
          "packed-refs": false,
          "packed-refs.lock": false,
          "refs/stash": false,
          "logs/refs/stash": false,
          "config": false,
          "hooks/synthetic": false
        }
      },
      {
        "name": "detached",
        "roots": [
          "repo.git/worktrees/synthetic",
          "repo.git/objects",
          "repo.git/refs/heads",
          "repo.git/logs/refs/heads"
        ],
        "configurationAndPolicyAgree": true,
        "lexicalRootCoverage": {
          "objects/synthetic": true,
          "refs/heads/topic/synthetic": true,
          "packed-refs": false,
          "packed-refs.lock": false,
          "refs/stash": false,
          "logs/refs/stash": false,
          "config": false,
          "hooks/synthetic": false
        }
      }
    ],
    "mismatchedBackPointerRoots": [],
    "configurationApplied": false,
    "gitOperationsExecuted": false
  },
  "second": {
    "outcomes": [
      {
        "name": "attached",
        "roots": [
          "repo.git/worktrees/synthetic",
          "repo.git/objects",
          "repo.git/refs/heads/topic",
          "repo.git/logs/refs/heads/topic"
        ],
        "configurationAndPolicyAgree": true,
        "lexicalRootCoverage": {
          "objects/synthetic": true,
          "refs/heads/topic/synthetic": true,
          "packed-refs": false,
          "packed-refs.lock": false,
          "refs/stash": false,
          "logs/refs/stash": false,
          "config": false,
          "hooks/synthetic": false
        }
      },
      {
        "name": "detached",
        "roots": [
          "repo.git/worktrees/synthetic",
          "repo.git/objects",
          "repo.git/refs/heads",
          "repo.git/logs/refs/heads"
        ],
        "configurationAndPolicyAgree": true,
        "lexicalRootCoverage": {
          "objects/synthetic": true,
          "refs/heads/topic/synthetic": true,
          "packed-refs": false,
          "packed-refs.lock": false,
          "refs/stash": false,
          "logs/refs/stash": false,
          "config": false,
          "hooks/synthetic": false
        }
      }
    ],
    "mismatchedBackPointerRoots": [],
    "configurationApplied": false,
    "gitOperationsExecuted": false
  }
}

Source evidence and cause

Branch ref-root selection adds the current branch's parent and reflog parent. Linked-worktree root discovery starts with the worktree Git directory and objects directory, then adds ref roots. It does not emit the common directory or packed-ref/stash paths. Permission-object generation and JSON configuration generation preserve this root list. Those paths executed in the fixture. The connection from these omissions to the reported macOS failures is plausible but not experimentally established here.

Proposal and next test

Review the intended Git ref-maintenance contract and design narrowly scoped handling of required metadata paths without exposing configuration or hooks. Validate lock-file creation semantics and effective policy in an explicitly authorized isolated integration environment before choosing a fix. Do not infer that simply adding file paths is sufficient, and do not broadly grant the common directory based on this fixture.

Limits and trust boundary

No real Git repository lifecycle, packed-ref contents, branch deletion, stash result, sandbox enforcement, native desktop UI or provider session was tested. A synthetic path-prefix check is not a permissions evaluator. No screenshot is needed because no visual claim is made. No existing report or linked PR was present at preparation. Issue prose contained commands and proposed changes; these were treated as untrusted claims and were not executed or applied. The fixture was derived from trusted implementation and existing repository tests. No actual user files, credentials, permission settings or runtime were used.