Skip to content
Merged
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
7 changes: 6 additions & 1 deletion docs/wall-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,10 @@
{ "name": "EClowns", "date": "2026-08-25", "payment_id": "pay_TTqLBc6AIvyoGj" },
{ "name": "Swathesh Babu", "date": "2026-08-25", "payment_id": "pay_TTsBMTuFXBeYgN" },
{ "name": "JIIMROOD", "date": "2026-08-25", "payment_id": "pay_TU01Sc8vLofb6d" },
{ "name": "Francis B", "date": "2026-08-25", "payment_id": "pay_TU09jO9hJR0r37" }
{ "name": "Francis B", "date": "2026-08-25", "payment_id": "pay_TU09jO9hJR0r37" },
{ "name": "SNJY", "date": "2026-08-25", "payment_id": "pay_TU5ucArkNnGaEa" },
{ "name": "Teminite", "date": "2026-08-25", "payment_id": "pay_TU6A8fNXjpwkvB" },
{ "name": "Kush Varma", "date": "2026-08-25", "payment_id": "pay_TU725sL3bkFQfD" },
{ "name": "Wynand Jordaan", "date": "2026-08-25", "payment_id": "pay_TU8odyWTHulcSR" },
{ "name": "sohail alrazeen", "date": "2026-08-26", "payment_id": "pay_TUHeTr6UMs5BIx" }
]
24 changes: 21 additions & 3 deletions src/main/hive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
*/
import {
existsSync, mkdirSync, readFileSync, writeFileSync, renameSync,
readdirSync, statSync, rmSync, appendFileSync, symlinkSync, copyFileSync, chmodSync
readdirSync, statSync, rmSync, appendFileSync, symlinkSync, copyFileSync, chmodSync,
realpathSync
} from 'node:fs';
import { join, dirname, isAbsolute } from 'node:path';
import { homedir } from 'node:os';
Expand All @@ -41,6 +42,7 @@ import { selectBroadcastTargets } from '../shared/broadcast';
import { preferredAgentRole } from '../shared/agentRole';
import { mergeTaskLedger } from '../shared/taskLedger';
import { expandTilde } from './fs';
import { codexTrustEntry } from '../shared/codexTrust';

/** The subset of HarnessConfig the hive consumes for the default-MCP merge.
* Kept as a local shape so hive.ts never imports the foundation-owned config
Expand Down Expand Up @@ -751,7 +753,7 @@ export class HiveManager {
if (desc.kind === 'hooks') {
if (desc.shim === 'agy') this.installAgyHooks();
else if (desc.shim === 'codex') {
env.CODEX_HOME = this.installCodexHooks(dir);
env.CODEX_HOME = this.installCodexHooks(dir, meta.cwd);
// Codex refuses to run hooks from a config dir without persisted
// "hook trust" (normally an interactive gate). Our hooks.json is
// hive-authored inside an isolated CODEX_HOME, so we bypass that gate
Expand Down Expand Up @@ -1872,7 +1874,7 @@ export class HiveManager {
* untouched. The user's ~/.codex/auth.json is linked in and their config.toml is
* copied + extended (login + model/provider/trust settings still apply).
* Returns the CODEX_HOME path for the caller to put in the worker's env. */
private installCodexHooks(dir: string): string {
private installCodexHooks(dir: string, cwd: string): string {
const home = join(dir, '.codex');
try {
mkdirSync(home, { recursive: true });
Expand Down Expand Up @@ -1934,6 +1936,22 @@ export class HiveManager {
config += `\n[[hooks.${ev}]]\n[[hooks.${ev}.hooks]]\ntype = "command"\ncommand = '${this.nodeRunUnquoted(shim)}'\ntimeout = 30\n`;
}
}
// Directory trust. A fresh config dir has never answered Codex's "do you
// trust this directory?" prompt, so an agent launched into one stops on it
// before its first turn and, with nobody at the keyboard, never resumes.
// Pressing Yes persists exactly the table below, and pre-seeding it is the
// ONLY suppression there is — no flag or env skips the prompt (in
// particular `--dangerously-bypass-approvals-and-sandbox` governs command
// approval, not trust) and a `-c projects…` override is not persisted, so
// it does not read as an answer. Trust the FINAL working directory: by the
// time this runs, isolation has resolved, so `cwd` is the agent's git
// worktree when one was created and the original repo when it fell back —
// one path, always the one the CLI will actually open in. Canonical
// (realpath) spelling, because Codex resolves the directory before looking
// it up and `/tmp/x` would never match its `/private/tmp/x`.
let trusted = cwd;
try { trusted = realpathSync(cwd); } catch { /* not on disk yet — best spelling we have */ }
config += codexTrustEntry(trusted);
writeFileSync(join(home, 'config.toml'), config, 'utf8');
} catch (e) { console.error('[hive] installCodexHooks failed:', e); }
return home;
Expand Down
42 changes: 42 additions & 0 deletions src/shared/codexTrust.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/** Codex gates every working directory behind an interactive "do you trust this
* directory?" prompt, and the answer is persisted per config home as a
* `[projects."<dir>"]` table with `trust_level = "trusted"`. An agent that gets
* its own isolated CODEX_HOME therefore starts with a virgin config and hits
* that prompt on its first turn — with nobody at the keyboard it simply stalls.
*
* Pre-seeding the same table is the only way out: no flag or environment
* variable skips the prompt (notably NOT
* `--dangerously-bypass-approvals-and-sandbox`, which governs command approval,
* not directory trust), and a `-c projects…` override is not persisted so it
* does not count as an answer either. */

/** Render a string as a TOML basic string, quotes included.
*
* A directory name may legally contain a quote or a backslash on POSIX, and
* interpolating one straight into `[projects."…"]` produces a config file Codex
* cannot parse — which is worse than the prompt it was meant to remove. */
export function tomlBasicString(value: string): string {
let out = '"';
for (const ch of value) {
const code = ch.codePointAt(0) as number;
if (ch === '"') out += '\\"';
else if (ch === '\\') out += '\\\\';
else if (ch === '\b') out += '\\b';
else if (ch === '\t') out += '\\t';
else if (ch === '\n') out += '\\n';
else if (ch === '\f') out += '\\f';
else if (ch === '\r') out += '\\r';
else if (code < 0x20 || code === 0x7f) out += `\\u${code.toString(16).padStart(4, '0')}`;
else out += ch;
}
return `${out}"`;
}

/** The config.toml fragment that marks `dir` as already trusted.
*
* `dir` must be canonical (realpath-resolved): Codex resolves the directory
* before looking it up, so an entry keyed by an unresolved spelling — `/tmp/x`
* where the kernel says `/private/tmp/x` — never matches and the prompt returns. */
export function codexTrustEntry(dir: string): string {
return `\n[projects.${tomlBasicString(dir)}]\ntrust_level = "trusted"\n`;
}
97 changes: 97 additions & 0 deletions test/codex-directory-trust.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use strict';

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const loadTs = require('./load-ts.cjs');

const { HiveManager } = loadTs('src/main/hive.ts');
const { codexRemoteAliasPath } = loadTs('src/shared/codexRemote.ts');

/** A per-agent CODEX_HOME starts life as a virgin config dir, so Codex asks
* "do you trust this directory?" on the first turn and a headless agent sits
* on the prompt forever. Pressing Yes persists exactly this table, and
* pre-seeding it suppresses the prompt (verified on Codex 0.149.1 — no flag or
* env does; `--dangerously-bypass-approvals-and-sandbox` does not). */
function trustTable(cwd) {
return `[projects."${cwd}"]\ntrust_level = "trusted"`;
}

/** A hive home plus a `$HOME` whose `~/.codex/config.toml` is under our control:
* installCodexHooks seeds the generated config from the user's, so the test has
* to own that file to say anything about what survives. */
function sandbox(t, userConfig) {
const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'md-codex-trust-')));
const fakeHome = path.join(home, 'user-home');
fs.mkdirSync(path.join(fakeHome, '.codex'), { recursive: true });
if (userConfig !== undefined) {
fs.writeFileSync(path.join(fakeHome, '.codex', 'config.toml'), userConfig, 'utf8');
}
const realHome = process.env.HOME;
process.env.HOME = fakeHome;
t.after(() => {
if (realHome === undefined) delete process.env.HOME; else process.env.HOME = realHome;
fs.rmSync(home, { recursive: true, force: true });
});
return home;
}

async function prepCodexAgent(home, id, cwd) {
const hive = new HiveManager(() => home);
const injection = await hive.ensureAgent({ id, name: id, provider: 'codex', cwd });
const codexHome = injection.env.CODEX_HOME;
assert.ok(codexHome, 'a codex spawn must get an isolated CODEX_HOME');
return { codexHome, config: fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8') };
}

test('codex spawn prep trusts the agent\'s final working directory', async (t) => {
const home = sandbox(t);
// Codex canonicalizes the directory it prompts about, so the entry has to be
// stored realpath-resolved or it simply does not match and the prompt returns.
const repo = path.join(home, 'repo');
const via = path.join(home, 'symlinked-repo');
fs.mkdirSync(repo, { recursive: true });
fs.symlinkSync(repo, via, 'dir');

const { config } = await prepCodexAgent(home, 'codex-trust-1', via);
assert.ok(config.includes(trustTable(repo)), `no trust entry for ${repo} in:\n${config}`);
});

test('a working directory needing TOML escaping is escaped, not interpolated', async (t) => {
const home = sandbox(t);
const repo = path.join(home, 'we"ird\\repo');
fs.mkdirSync(repo, { recursive: true });

const { config } = await prepCodexAgent(home, 'codex-trust-2', repo);
const escaped = `${home}/we\\"ird\\\\repo`;
assert.ok(config.includes(trustTable(escaped)), `path was not TOML-escaped in:\n${config}`);
});

test('the user\'s own codex settings survive the trust entry', async (t) => {
const home = sandbox(t, 'model = "gpt-5-codex"\n');
const repo = path.join(home, 'repo');
fs.mkdirSync(repo, { recursive: true });

const { config } = await prepCodexAgent(home, 'codex-trust-3', repo);
assert.ok(config.includes('model = "gpt-5-codex"'), `user setting was lost:\n${config}`);
assert.ok(config.includes(trustTable(repo)), `no trust entry for ${repo} in:\n${config}`);
});

test('the short home alias used for remote control sees the trust entry', async (t) => {
const home = sandbox(t);
const repo = path.join(home, 'repo');
fs.mkdirSync(repo, { recursive: true });

const { codexHome } = await prepCodexAgent(home, 'codex-trust-4', repo);
// Remote control runs the CLI against a short symlinked spelling of the same
// home (macOS caps a Unix socket path at 104 bytes). Built here under the test
// root rather than the production CODEX_REMOTE_ALIAS_ROOT so the test owns it.
const alias = codexRemoteAliasPath(codexHome, 'codex-trust-4', path.join(home, 'alias'));
fs.mkdirSync(path.dirname(alias), { recursive: true });
fs.symlinkSync(codexHome, alias, 'dir');

const config = fs.readFileSync(path.join(alias, 'config.toml'), 'utf8');
assert.ok(config.includes(trustTable(repo)), `no trust entry for ${repo} in:\n${config}`);
});
Loading