Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ agy gates every fresh worktree too; the spawn pre-registers it in agy's own stor
Cursor suppresses its dialog with launch-time `--trust`, and Muse suppresses its own with `--yolo`.
Grok renders a folder-trust gate in a linked worktree, and `references/harness/grok.md` owns how to verify the worker's real location, answer it, and where the decision persists; the project picker is a separate dialog that stays absent when the spawn starts in a git root.
Pi gates the fresh-worktree case too, but unlike Claude its dialog is answered with Enter, and `references/harness/pi.md` owns that recipe and where the decision persists.
Codex shows a directory-trust dialog on the first run for a repository root.
Codex gates a fresh directory too; the spawn pre-registers it, and `references/harness/codex.md` owns that registration.

Use the tool's exact skill form, or natural language only when no separate command is verified or the form remains uncertain.
A successful send or key return is not proof of submission; require the tool-specific postcondition.
Expand Down
4 changes: 2 additions & 2 deletions .agents/skills/harness-adapters/references/harness/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ Verified on 2026-06-11 with codex-cli 0.139.0 unless a fact gives a newer versio
| Marker | None; identity comes from ancestry, and `../../../bin/fm-harness.sh` is what keeps a retained foreign `CLAUDECODE` from renaming it. Verified on 2026-09-01 with codex-cli 0.152.0: the pane process is the `node` npm shim and the native `codex` binary runs as its foreground child, so a tool subprocess reaches the native name directly while the shim itself is identified from its script path. |

A directory trust dialog appears on the first run for a repository root: "Do you trust the contents of this directory?"
Accept it with Enter and verify the instructions begin processing.
The decision persists for the repository, so later worktrees of the same project skip it.
`bin/fm-spawn.sh` pre-registers the launch directory through `bin/fm-codex-trust.sh`, whose header owns the store, scope, dependency and refusal contract.
Workspace trust does not grant hook trust.

## Hook trust

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Coordinate any workflow rollback with its required-check names so a retired chec
It pins one exact shellcheck version and one exact actionlint version and refuses to run under any other.
Print the shellcheck pin with `bin/fm-lint.sh --required-version` and the actionlint pin with `bin/fm-lint-workflows.sh --required-version`.
Use `bin/fm-install-shellcheck.sh` and `bin/fm-install-actionlint.sh` to install those exact builds locally; each installer's header owns its destination usage and supported platforms.
- Harness-adapter ownership spans detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, spawn-time Claude workspace-trust and external-CLAUDE.md-import pre-approval in `bin/fm-claude-trust.sh`, semantic busy sources and trust gates in `bin/fm-busy-lib.sh`, delivery-only rendered guards in `bin/fm-composer-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in the skill tree rooted at `.agents/skills/harness-adapters/SKILL.md`; the `firstmate-coding-guidelines` skill owns the validation policy for checks that depend on those harnesses.
- Harness-adapter ownership spans detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, spawn-time workspace trust in `bin/fm-codex-trust.sh` and Claude workspace-trust and external-CLAUDE.md-import pre-approval in `bin/fm-claude-trust.sh`, semantic busy sources and trust gates in `bin/fm-busy-lib.sh`, delivery-only rendered guards in `bin/fm-composer-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in the skill tree rooted at `.agents/skills/harness-adapters/SKILL.md`; the `firstmate-coding-guidelines` skill owns the validation policy for checks that depend on those harnesses.
- Changes to runtime session backends (`bin/fm-backend.sh`, `bin/backends/`, and the scripts that dispatch through them) keep current setup and limits in the relevant backend guide and active empirical evidence in [`docs/verification/runtime-backends.md`](docs/verification/runtime-backends.md).
- [`docs/documentation-audiences.md`](docs/documentation-audiences.md) and its machine-consumed inventory own prose classification; run `bin/fm-doc-audience-check.sh` after documentation changes.
- In Markdown, put each full sentence on its own line.
Expand Down
146 changes: 146 additions & 0 deletions bin/fm-codex-trust.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# Pre-register Codex workspace trust before an unattended worker launch.
# Usage: fm-codex-trust.sh <worktree> <project>
# fm-codex-trust.sh --secondmate-home <home> <id>
# Only an isolated linked worktree of the given project or a home seeded for
# the given secondmate is eligible. No ancestor or primary checkout is trusted.
# Uses node and the installed codex app-server config/read + config/value/write
# API, without starting a model turn. Codex owns TOML parsing, preservation,
# atomic replacement and version-conflict detection; a conflicting or lost
# concurrent edit is re-read and retried, up to 3 attempts. The exact launch path alone
# is registered in ${CODEX_HOME:-$HOME/.codex}/config.toml; workspace trust never
# changes hook trust. Relative CODEX_HOME, malformed config, explicit untrusted
# entries, symlinked stores and foreign ownership fail closed. Existing trusted
# entries are a no-op. Read-back verifies persistence before reporting success.
# The private stdio server is always stopped (SIGKILL after a 2-second grace);
# it never uses the shared daemon.
set -u
if [ "${1:-}" = --help ] || [ "${1:-}" = -h ]; then
sed -n '2,16{s/^# \{0,1\}//;p;}' "$0"
exit 0
fi
command -v node >/dev/null 2>&1 || { echo 'error: Codex trust requires node' >&2; exit 1; }
node - "$@" <<'JS'
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const cp = require('node:child_process');
const readline = require('node:readline');
const args = process.argv.slice(2);
const refuse = message => { throw new Error(message); };
const real = value => fs.realpathSync(value);
const gitEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
function git(directory, flag) {
return real(path.resolve(directory, cp.execFileSync('git', ['-C', directory, 'rev-parse', flag],
{env: gitEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']}).trim()));
}
function ownedRegular(file) {
const info = fs.lstatSync(file);
if (!info.isFile() || info.uid !== process.getuid()) refuse(`${file} must be a regular file owned by this user`);
}
function checkStore(store) {
try { ownedRegular(store); } catch (error) { if (error.code !== 'ENOENT') throw error; }
}
let server;
let timer;
let nextId = 0;
const pending = new Map();
function rpc(method, params) {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, {resolve, reject});
server.stdin.write(JSON.stringify({id, method, params}) + '\n');
});
}
async function main() {
const secondmate = args.length === 3 && args[0] === '--secondmate-home';
if (!secondmate && (args.length !== 2 || args[0].startsWith('--')))
refuse('usage: fm-codex-trust.sh <worktree> <project> | --secondmate-home <home> <id>');
const target = real(secondmate ? args[1] : args[0]);
if (!fs.statSync(target).isDirectory() || target === '/' || target === (fs.existsSync(os.homedir()) ? real(os.homedir()) : path.resolve(os.homedir())))
refuse(`${target} is not a task launch directory`);
if (git(target, '--show-toplevel') !== target) refuse(`${target} is not a checkout root`);
if (secondmate) {
const marker = path.join(target, '.fm-secondmate-home');
ownedRegular(marker);
if (!args[2] || fs.readFileSync(marker, 'utf8').trim() !== args[2])
refuse(`${target} is not seeded for secondmate ${args[2]}`);
if (!fs.statSync(path.join(target, 'AGENTS.md')).isFile() || !fs.statSync(path.join(target, 'bin')).isDirectory())
refuse(`${target} is not a Firstmate home`);
for (const name of ['data', 'state', 'config', 'projects']) {
const child = path.join(target, name);
try { fs.lstatSync(child); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
if (!fs.statSync(child).isDirectory() || !real(child).startsWith(target + path.sep))
refuse(`${child} is not a directory inside this home`);
}
} else {
const common = git(target, '--git-common-dir');
if (git(target, '--absolute-git-dir') === common)
refuse(`${target} is a primary checkout, not an isolated worktree`);
if (common !== git(real(args[1]), '--git-common-dir')) refuse(`${target} is not a worktree of ${args[1]}`);
}
const configured = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
if (!path.isAbsolute(configured)) refuse('CODEX_HOME must be absolute so registration and launch read the same store');
fs.mkdirSync(configured, {recursive: true, mode: 0o700});
const directory = real(configured);
if (fs.statSync(directory).uid !== process.getuid() || directory === target)
refuse(`${directory} is not an eligible Codex configuration directory`);
const store = path.join(directory, 'config.toml');
checkStore(store);
server = cp.spawn('codex', ['app-server', '--listen', 'stdio://'], {
cwd: directory, env: {...process.env, CODEX_HOME: directory}, stdio: ['pipe', 'pipe', 'ignore']
});
const failPending = error => { for (const item of pending.values()) item.reject(error); pending.clear(); };
server.on('error', failPending);
server.on('exit', (code, signal) => failPending(new Error(`Codex config server exited (${signal || code})`)));
server.stdin.on('error', failPending);
readline.createInterface({input: server.stdout}).on('line', line => {
let response;
try { response = JSON.parse(line); } catch { failPending(new Error('Codex config server emitted invalid JSON')); return; }
const item = pending.get(response.id);
if (!item) return;
pending.delete(response.id);
if (response.error) item.reject(Object.assign(new Error(response.error.message || JSON.stringify(response.error)), {data: response.error.data}));
else item.resolve(response.result);
});
timer = setTimeout(() => failPending(new Error('Codex config server timed out after 15 seconds')), 15000);
await rpc('initialize', {clientInfo: {name: 'firstmate-workspace-trust', version: '1'}, capabilities: null});
server.stdin.write(JSON.stringify({method: 'initialized'}) + '\n');
const userLayer = result => result.layers?.find(item => item.name.type === 'user' && !item.name.profile && item.name.file === store);
// Each attempt's read verifies the previous write: a version conflict or a
// concurrent replace that dropped an acknowledged write is re-read and retried.
for (let attempt = 1; ; attempt++) {
checkStore(store);
const layer = userLayer(await rpc('config/read', {includeLayers: true}));
if (!layer || !layer.version || !layer.config || typeof layer.config !== 'object')
refuse('Codex did not report a versioned user config layer for the launch store');
const entry = layer.config.projects?.[target];
if (entry?.trust_level === 'untrusted') refuse(`existing entry for ${target} is explicitly untrusted`);
if (entry?.trust_level === 'trusted') break;
if (attempt > 3) refuse(`${store} did not retain workspace trust; gave up after 3 attempts`);
let written;
try {
written = await rpc('config/value/write', {
keyPath: `projects.${JSON.stringify(target)}.trust_level`, value: 'trusted',
mergeStrategy: 'replace', filePath: store, expectedVersion: layer.version
});
} catch (error) {
if (error.data?.config_write_error_code !== 'configVersionConflict') throw error;
continue;
}
if (written.status !== 'ok' || written.filePath !== store) refuse('Codex did not confirm the trust write');
}
console.log(`trusted: ${target}`);
}
main().catch(error => {
console.error(`error: refusing to pre-register Codex workspace trust: ${error.message}`);
process.exitCode = 1;
}).finally(() => {
clearTimeout(timer);
if (!server) return;
server.stdin.destroy();
server.kill('SIGTERM');
// A server that stalls on shutdown must not hold the verdict hostage.
setTimeout(() => { server.kill('SIGKILL'); server.stdout.destroy(); }, 2000).unref();
});
JS
25 changes: 22 additions & 3 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -454,9 +454,11 @@
# resolver because `cursor` is not the CLI name. A cursor SECONDMATE instead runs
# the tracked project-scope .cursor/hooks.json in its own home, whose stop-hook
# park owns that home's supervision (docs/supervision-protocols/cursor.md).
# claude is the one harness whose pre-launch setup can REFUSE the spawn: before
# any per-task state exists, and before its worktree .claude/settings.local.json
# hooks are written, every claude launch pre-registers the directory the pane
# Claude and Codex pre-register launch-directory trust and refuse on failure.
# bin/fm-codex-trust.sh owns Codex registration for workers and secondmates.
# Claude registration runs before any per-task state exists and before its
# worktree .claude/settings.local.json hooks are written. Every claude launch
# pre-registers the directory the pane
# starts in - the task worktree, or the secondmate home for a --secondmate spawn -
# in the launching user's own Claude trust store through bin/fm-claude-trust.sh,
# because Claude's interactive workspace-trust dialog gates a folder it has never
Expand Down Expand Up @@ -4535,6 +4537,20 @@ spawn_assert_agent_worktree
# only the worktree shape applies.
AGY_TRUST_PREREGISTERED=0
case "$HARNESS" in
codex)
# Trust only this launch directory, never the treehouse root. The helper
# uses Codex's config API and preserves existing configuration.
export CODEX_HOME=${CODEX_HOME:-${HOME:?}/.codex}
if [ "$KIND" = secondmate ]; then
spawn_trust_args=(--secondmate-home "$PROJ_ABS" "$ID")
else
spawn_trust_args=("$WT" "$PROJ_ABS")
fi
if ! "$FM_ROOT/bin/fm-codex-trust.sh" "${spawn_trust_args[@]}" >/dev/null; then
echo "error: could not pre-register Codex workspace trust for $WT; refusing to launch a codex worker that would wedge on the trust dialog; inspect window $T" >&2
exit 1
fi
;;
claude*)
if [ "$KIND" = secondmate ]; then
spawn_trust_args=(--secondmate-home "$PROJ_ABS" "$ID")
Expand Down Expand Up @@ -5338,6 +5354,9 @@ if [ -n "$WORKER_ACCOUNT" ]; then
elif [ "$HARNESS" = claude ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
LAUNCH="CLAUDE_CONFIG_DIR=$(shell_quote "$CLAUDE_CONFIG_DIR") $LAUNCH"
fi
if [ "$HARNESS" = codex ]; then
LAUNCH="CODEX_HOME=$(shell_quote "$CODEX_HOME") $LAUNCH"
fi
if [ "$KIND" = secondmate ]; then
sq_home=$(shell_quote "$PROJ_ABS")
sq_primary_home=$(shell_quote "$FM_HOME")
Expand Down
11 changes: 9 additions & 2 deletions bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ family_for_basename() {
fm-cmux-claude-composer-live-e2e.test.sh|\
fm-composer-matrix-live-e2e.test.sh|\
fm-composer-codex-idle-live-e2e.test.sh|\
fm-codex-continuity-live-e2e.test.sh|fm-codex-hook-layer-live-e2e.test.sh|\
fm-codex-continuity-live-e2e.test.sh|fm-codex-hook-layer-live-e2e.test.sh|fm-codex-workspace-trust-live-e2e.test.sh|\
fm-grok-continuity-live-e2e.test.sh|\
fm-cursor-primary-live-e2e.test.sh|\
fm-grok-stop-live-e2e.test.sh|fm-harness-adapter-instructions-live-e2e.test.sh|\
Expand Down Expand Up @@ -387,7 +387,7 @@ family_for_basename() {
fm-control.test.sh|fm-control-relaunch.test.sh|\
fm-herdr-session-cleanup.test.sh|fm-send-resolve-key.test.sh|fm-send-strict.test.sh|\
fm-send-inbox.test.sh|fm-spawn-batch.test.sh|\
fm-spawn-dispatch-profile.test.sh|fm-claude-trust.test.sh|\
fm-spawn-dispatch-profile.test.sh|fm-claude-trust.test.sh|fm-codex-trust.test.sh|\
fm-worker-account.test.sh|\
fm-git-strip-ai-trailers.test.sh|\
fm-trace-context-spawn.test.sh|fm-spawn-worktree-settle.test.sh|\
Expand Down Expand Up @@ -1627,6 +1627,13 @@ families_for_changed_path() {
printf '%s\n' pure-contract-unit
printf '%s\n' live-harness-optin
;;
bin/fm-codex-trust.sh)
printf '%s\n' backend-dispatch
printf '%s\n' live-harness-optin
;;
tests/codex-config-fixture.js)
printf '%s\n' backend-dispatch
;;
bin/fm-spawn.sh|bin/fm-send.sh|bin/fm-harness.sh|\
bin/fm-peek.sh|bin/fm-composer*)
printf '%s\n' backend-dispatch
Expand Down
2 changes: 1 addition & 1 deletion docs/orca-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Reinstall the CLI and rerun; [`verification/runtime-backends.md`](verification/r
- Escape is unsupported.
- Orca exposes no stable CLI version or protocol marker, so readiness is the compatibility gate rather than a version floor.
- Only the verified terminal-handle and worktree result fields are accepted; speculative response shapes are rejected.
- Orca's worktree shape is unverified against the spawn-time Claude workspace-trust check in `bin/fm-claude-trust.sh`, which refuses any path that is not a linked git worktree sharing the project's git common dir, so a claude spawn on Orca fails loudly at that check rather than launching if Orca clones instead of linking.
- Orca's worktree shape is unverified against the spawn-time workspace-trust checks in `bin/fm-claude-trust.sh` and `bin/fm-codex-trust.sh`, which refuse any path that is not a linked git worktree sharing the project's git common dir, so a claude or codex spawn on Orca fails loudly at that check rather than launching if Orca clones instead of linking.

## Regression entry points

Expand Down
26 changes: 26 additions & 0 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -2494,3 +2494,29 @@ Without Firstmate's hooks, Herdr reported the question panel as `blocked`, which
This live proof covers the watcher and queue boundary; it does not establish live daemon-consumer delivery.
`bin/fm-test-run.sh tests/fm-daemon.test.sh` exercises that consumer routing separately with portable regressions for busy escalation and busy-bookkeeping failures in away and quiet mode.
Repeat the hooked-worker check above before publication if watcher or task-inbox busy code changes; `bin/fm-test-run.sh tests/fm-task-inbox.test.sh` refreshes the portable ladder regressions.

## Codex workspace trust

Verified on 2026-10-06 with codex-cli 0.159.3 on macOS.
`bin/fm-codex-trust.sh` owns launch-directory registration through Codex's private stdio config API, and `tests/fm-codex-trust.test.sh` covers scope, protocol refusal and the spawn integration without an installed harness.
The token-free live guard uses dummy API-key data and submits no prompt.
It verifies Codex's native TOML preservation and idempotence, then opens the same repository's unregistered primary checkout and registered linked worktree without sending any input.
Only the unregistered checkout displays the folder-trust gate; the registered worktree reaches the composer.
Workspace trust does not approve hooks, and the worker hook-layer posture remains independently covered by `tests/fm-codex-hook-layer-live-e2e.test.sh`.

Refresh command:

```sh
bash bin/fm-test-run.sh tests/fm-codex-workspace-trust-live-e2e.test.sh
```

Observed output:

```text
ok - codex codex-cli 0.159.3: unregistered checkout gates; registered worktree reaches composer without input
ok - codex codex-cli 0.159.3: native config API preserves TOML, is idempotent and refuses malformed input
# all fm-codex-workspace-trust-live-e2e checks passed
```

The registration point precedes backend launch and is shared by tmux, herdr, zellij, orca and cmux.
Other harnesses do not enter the Codex registration branch.
Loading
Loading