Skip to content

Make a new Claude or Codex session ready for a prompt without a human - #110

Open
lvwerra wants to merge 3 commits into
mainfrom
fix/first-run-dialogs
Open

Make a new Claude or Codex session ready for a prompt without a human#110
lvwerra wants to merge 3 commits into
mainfrom
fix/first-run-dialogs

Conversation

@lvwerra

@lvwerra lvwerra commented Aug 21, 2026

Copy link
Copy Markdown
Member

they often ask stupid things like "do you want to update now" or "do you trust this folder"

A new session could stop at a dialog before running the task it was launched with. Nobody watches a pane the manager or another agent just opened, and a freshly booted CLI reports idle, so a coordinator polling it saw "finished" while the work had not started.

Investigated by launching the installed CLIs — Claude Code 2.1.232, Codex 0.149.0 — against copied credentials in brand-new folders. Full write-up in docs/first-run-dialogs.md. Evidence: round one · round two · round three

What a new session actually hits

CLI dialog when recorded in
both do you trust this folder? any new folder hasTrustDialogAccepted / trust_level
Codex Update available! → Update now / Skip / Skip until next version a launch with no prompt, when the cache holds a newer version dismissed_version in version.json
Claude managed settings / telemetry approval when those settings change remote-settings-consent.json
Claude "running in Bypass Permissions mode" until answered skipDangerousModePermissionPrompt

Trust inheritance differs per CLI. Claude inherits from a parent — trusting the workspaces root once covers every session under it. Codex does not; it needs the exact path, and a -c 'projects."<dir>".trust_level="trusted"' override does not reach its check (tried and measured).

The Codex update screen only appears on a promptless launch, which is why a first pass that always passed a task concluded — wrongly — that it did not exist. That is exactly a session created without a task: the operator's first typed prompt then lands in the dialog.

What happens to a prompt while a dialog is up

  • A launch argument survives — queued, and it runs once the dialog is answered.
  • A prompt sent while the dialog is showing is partly eaten: the dialog's key handler takes the leading characters and the trailing Enter dismisses it, releasing the queued launch prompt. reply with exactly SECOND arrived as with exactly SECOND.
  • waitForInputReady cannot help: it waits for a quiet screen, and a dialog is a quiet screen.

That is the reported symptom exactly — the task "only started after I sent the prompt a second time". The second send was answering the dialog.

The fix

what where why there
Claude folder trust boot, one entry on the workspaces root it inherits, so this covers every session; boot runs before anything spawns
Claude bypass warning boot, settings.json the app already writes that file — not CLI-owned state
Codex folder trust per launch, appended to config.toml no inheritance and -c does not work, so it must be in the file — but an append never rewrites another process's bytes
Codex update prompt per launch, dismissed_version the cache refreshes hours after boot, so once at boot is not enough
a path escaping the root refused at launch path is checked only lexically when recorded, so a symlink can leave the tree; Claude applies inherited trust to the resolved directory

No image change was needed.

Why append, and where one rewrite remains

An earlier version read all of .claude.json, edited an entry and renamed the result over the live file on every new session. A rename prevents a torn file; it is not a lock, and review reproduced a silently lost concurrent write. Nothing on the session path rewrites that file any more.

One rewrite remains and is stated rather than hidden: setting a field in Codex's version.json means writing the object back, and that can lose a refresh landing in the same instant. I tried a compare-and-set and measured it failing — our rename lands after theirs, so re-reading afterwards only shows our own snapshot. It is accepted because of what the file is: a cache Codex rewrites on its own schedule, where the cost is a notification delayed to the next check. The guarantee made is the narrow one — the file ends self-consistent, so the modal cannot open.

Trust detection parses TOML rather than matching text, so [projects."/p"] # comment and [projects . "/p"] are recognised instead of duplicated into a file Codex refuses to load.

Suppressing a prompt is not never updating

dismissed_version is the field Codex's own "Skip until next version" writes. No check is disabled, codex update still works, and the version found is logged rather than swallowed:

[first-run] codex 999.0.0 is available (running 0.149.0); its update prompt is
dismissed so it cannot block a session — run `codex update` or rebuild the image

After it, the session shows a one-line banner instead of a modal — visible, not blocking. Nothing changes what an agent may do: permissions.defaultMode and Codex's approval_policy / sandbox_mode are as found.

Durability

Both config trees are already carried by scripts/agent-state.shCLAUDE_CONFIG_DIR$DATA_DIR/state/claude (whole) and CODEX_HOME$DATA_DIR/state/codex (whole bar databases and caches). Nothing new had to be made durable. The live copies are on local disk and wiped every deploy; they are there because the bridge restores them at boot.

Verified

Fresh configs, nothing trusted, an update pending — nothing typed after creation:

claude codex
task at launch ● FIXED • DONE, update shown as a banner
no task, attach reaches its prompt, no modal
ordinary child / symlink inside runs runs
symlink out of the root refused, with the resolved path named refused

server/test/first-run.test.mjs — 17 checks. Teeth: reverting to regex detection fails 4; dropping the unknown-version guard fails 1. Server suite green, 193 checks. Throwaway sessions and their folders removed.

A new Claude Code or Codex session stopped at a dialog before running the task
it was launched with. Nobody watches a pane the manager or another agent just
opened, and a freshly booted CLI reports `idle`, so a coordinator polling it saw
"finished" while the work had not started.

Measured with real spawns against copied credentials in brand-new folders,
rather than read off the CLIs' docs. Full write-up in docs/first-run-dialogs.md.

**What a new session asks.** Both CLIs ask "do you trust this folder?", keyed on
the ABSOLUTE path — `/data/workspaces` being trusted does nothing for
`/data/workspaces/<new-session>`, which is why this is a per-session problem and
not a once-per-Space one. Claude can also ask for managed-settings/telemetry
approval and to accept bypass-permissions mode; both are global and were already
answered here, but only because a human once answered them.

**What happens to the launch prompt.** It survives: it is queued and runs once
the dialog is answered, on both CLIs. But a prompt sent WHILE the dialog is up is
partly eaten — the dialog consumes the leading characters and the trailing Enter
dismisses it, releasing the queued first prompt. `reply with exactly SECOND`
arrived as `with exactly SECOND`. That is exactly the reported symptom: the task
"only started after I sent the prompt a second time" — the second send was
answering the dialog. `waitForInputReady` cannot help, because it waits for a
quiet screen and a dialog is a quiet screen.

**The fix**, all in the spawn path; no image change was needed:

- `first-run.js` writes the one trust key per CLI, idempotently and additively:
  `projects[<path>].hasTrustDialogAccepted` for Claude,
  `[projects."<path>"] trust_level = "trusted"` for Codex. A path that is
  already answered is not rewritten, so a running CLI is never written under.
- `ensureRunning` calls it right after creating the workspace and before
  spawning — not at session creation, because the folder can be chosen,
  changed, or deleted and recreated in between.
- `ensureClaudeDialogDefaults()` makes the app own
  `skipDangerousModePermissionPrompt` instead of inheriting an answer somebody
  typed once. That dialog's default button is "No, exit", so a blind Enter on it
  kills the session.

Both files already live inside the config dirs `scripts/agent-state.sh` carries
between local disk and the bucket, so nothing new had to be made durable.

**Nothing is granted and nothing is frozen.** `permissions.defaultMode` and
Codex's `approval_policy` / `sandbox_mode` are left exactly as found, and no
update setting is touched — no update dialog appeared for either CLI, so
suppressing one was never needed.

Verified end to end: without this, `READY` appears 0 times in either pane and
both sit at a dialog. With it, both reply READY with nothing typed. The written
configs still parse and keep their globals. 9 unit checks, verified to fail if
the trust key is wrong; full server suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@lvwerra lvwerra left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: CHANGES REQUESTED

1. Correctness — the “atomic” whole-file rewrite can discard a running CLI's update

server/src/first-run.js:43-70 reads all of .claude.json, changes one project entry, then renames that stale snapshot over the live file. The rename prevents a torn file; it does not make the read/modify/write transaction safe against Claude writing the same file between our read and rename. The PR body's claim that “a running CLI is never written under” is only true when the new path is already trusted. Starting a different, new path still rewrites the shared file underneath every existing Claude session.

I reproduced the lost update deterministically against this module: start with a valid 64 MiB .claude.json, call trustWorkspace('claude', '/work/raced'), and have a second process replace the original with a valid { retainedByCli: 'concurrent-write' } snapshot when .claude.json.am-tmp appears. The helper returns success and the final file has the trust key, but retainedByCli has reverted to the old value (before). Nothing is malformed, so no later parser detects the loss.

This needs a design that avoids a runtime whole-file rewrite of CLI-owned state (or genuinely handles concurrent modification). The Claude result below may help: current Claude can be trusted once at the workspace parent instead of writing per child. Codex still needs an exact-path answer, so it needs its own safe path rather than assuming rename is a lock.

2. Does it solve the operator's problem? — Codex has the exact blocking update dialog the PR rules out

docs/first-run-dialogs.md:29-32 and the PR body infer from “no update dialog appeared” that the operator's report must have been one of the three observed dialogs. That inference does not hold for the installed Codex 0.149.0. Its own tagged source checks a persisted update cache and opens an interactive startup screen when it contains a newer version: updates.rs, update_prompt.rs.

I reproduced it with the real installed binary and an already-trusted new workspace: seed $CODEX_HOME/version.json with latest_version: "999.0.0" and a current last_checked_at, then launch codex. It blocks before the session on:

Update available! 0.149.0 -> 999.0.0
> 1. Update now (runs `npm install -g @openai/codex`)
  2. Skip
  3. Skip until next version
Press enter to continue

This is not an artificial code path: 0.149.0 checks in the background at most every 20 hours, writes that cache, and the next launch shows the popup. The image installs @openai/codex@latest at build time, but it can become stale as soon as a new release is published. DISABLE_AUTOUPDATER=1 did not suppress this Codex screen. The trust fix therefore still loses the launch to exactly the condition the operator named. Please add an explicit non-blocking update policy that preserves the intended update lifecycle, rather than treating one no-update run as evidence that the dialog does not exist.

3. Correctness — valid TOML spelling can be turned into a duplicate table

server/src/first-run.js:87-92 detects an existing Codex project by literal substring, not by TOML key. A valid file using a literal-string key is missed:

[projects.'/work/legal']
trust_level = "trusted"

Calling trustWorkspace('codex', '/work/legal') appends [projects."/work/legal"]; Python's tomllib then rejects the result with Cannot declare ('projects', '/work/legal') twice. This can turn a valid user config into one Codex cannot load. Existing trust_level = "untrusted" under the exact CLI spelling is handled safely (the helper leaves it alone, and real Codex starts without the trust dialog but keeps project-local configuration untrusted), but the test needs to cover semantically identical legal TOML syntax rather than only Codex's current serializer spelling.

4. Correctness / evidence — trust inheritance differs between the two CLIs

The blanket statement at server/src/first-run.js:4-6, runner.js:1846-1848, and docs/first-run-dialogs.md:19-21 is false for Claude Code 2.1.232. With only /…/inherit-parent marked hasTrustDialogAccepted: true, a real interactive launch in a brand-new /…/inherit-parent/claude-child ran its launch task without a dialog. The child had not existed in the config; after the run Claude recorded it with hasTrustDialogAccepted: false. Removing the parent trust and repeating in another new child produced the trust dialog. Codex 0.149.0 did not inherit in the equivalent test and did show the dialog. Please report the behavior per adapter rather than saying both are exact-path-only.

What passed

  • Real API spawns from this branch, in never-before-existing paths and with a task in the create request, completed as READY-CLAUDE-110 and READY-CODEX-110 with no input sent after creation.
  • The durability claim is correct: entrypoint.sh puts both live configs under $AM_LOCAL, scripts/agent-state.sh restores/checkpoints Claude whole and Codex excluding only databases/caches, so these files reach /data and survive deploys.
  • Remote sessions are rejected before the helper; shell/files/trace and the other unsupported adapters are no-ops. The patch does not change Claude permission mode or Codex approval/sandbox mode.
  • Missing files are created. Malformed Claude JSON is warned about and left untouched, falling back to the dialog. Malformed Codex TOML remains invalid (the helper appends text but cannot repair it).
  • server/test/first-run.test.mjs: 9/9 pass. The server suite reached 23 passing suites, then hit two timing failures in the existing resize repaint test; rerunning server/resize.test.mjs passed completely.

Both findings reproduced, and both changed the design rather than patching it.

**1. No whole-file rewrite of CLI-owned state on the session path.** The old
code read all of `.claude.json`, edited one entry, and renamed the result over
the live file every time a new path appeared. Rename prevents a torn file; it is
not a lock, and the review reproduced a lost concurrent write deterministically
— silently, because nothing ends up malformed.

Two measurements reshaped it. Claude Code 2.1.232 INHERITS folder trust from a
parent: with only the root trusted, a brand-new child ran its task with no
dialog. So Claude's answer is now ONE boot-time entry on the workspaces root,
before anything is spawned, written only if missing. Codex 0.149.0 does NOT
inherit — the same test showed its dialog — and it reads trust from config.toml
itself, so a `-c 'projects."<dir>".trust_level="trusted"'` override does not
reach the check (tried, measured, dropped). Its answer is therefore still
written per launch, but APPENDED: an append never writes another process's
bytes, so the failure mode is our own few bytes being lost and the dialog
appearing once more, not somebody else's work disappearing.

`codexTrustedPaths()` now decodes every `[projects.KEY]` header — basic strings,
literal strings, bare keys — so an entry written as `[projects.'/p']` is
recognised. The old substring check missed it and appended a duplicate table,
which makes the file one Codex refuses to load.

**2. Codex's update dialog exists, and the write-up had ruled it out.** It only
appears on a launch with NO prompt, which is why the first investigation — every
run carrying a task — never saw it. That is exactly a session created without a
task, where the operator's first typed prompt then lands in the dialog. There is
no flag or env switch; `DISABLE_AUTOUPDATER` does nothing to it.

`dismissCodexUpdatePrompt()` writes only `dismissed_version`, the field Codex's
own "Skip until next version" uses. `latest_version` and `last_checked_at` are
left as Codex wrote them, so the background check keeps working and `codex
update` still works; the version found is logged instead of swallowed. Called
per launch because that cache is refreshed hours after boot.

The doc is corrected on both counts, including the inheritance claim, which was
wrong in the expensive direction: it justified a runtime write Claude never
needed.

Verified: fresh configs with nothing trusted and an update pending — both CLIs
run their task with nothing typed, and a promptless Codex session reaches its
prompt instead of the modal. 13 unit checks; reverting to substring detection
fails 2, clobbering the update cache fails 1. Server suite green (193 checks).
@lvwerra

lvwerra commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Both findings reproduced and both changed the design rather than getting patched. Also fixed findings 3 and 4, which the summary I was given did not include but which were just as real. 999f9cd.

Round-two evidence →

1. Nothing on the session path rewrites CLI-owned state any more

You are right that rename is not a lock, and right that my "a running CLI is never written under" only held when the path was already trusted. Two measurements let me delete the runtime write rather than defend it:

  • Claude Code 2.1.232 inherits folder trust. With only the root marked hasTrustDialogAccepted: true, a brand-new child ran its launch task with no dialog. Its answer is now one boot-time entry on the workspaces root, written only if missing — so in practice once in the life of a config, before the app has spawned anything.
  • Codex 0.149.0 does not inherit (same test, dialog appeared), and it reads trust from config.toml itself: I tried -c 'projects."<dir>".trust_level="trusted"' on the launch line and it does not reach the check. So Codex still needs a write — but it is now an append. An append never writes another process's bytes: the worst case is our own ~60 bytes being lost if Codex rewrites concurrently, and the only consequence is the dialog showing once more. That is a different failure class from silently discarding someone else's write.

2. You were right about the update dialog, and I can now say why we saw different things

It appears only on a launch with no prompt. Every run in my first investigation carried a task, and with a task Codex skips the screen — so I concluded it did not exist. That inference was wrong and the doc said so out loud now.

It matters more than my original write-up implied: a promptless launch is exactly a session created without a task, so the operator's first typed prompt lands in that dialog — and per the eating behaviour already established, arrives truncated.

How suppressing stayed separate from never updating: dismissCodexUpdatePrompt() writes only dismissed_version — the field Codex's own "Skip until next version" sets. latest_version and last_checked_at are left exactly as Codex wrote them, so the background check keeps running, the cache keeps recording what is available, and codex update still works. The version is logged rather than swallowed:

[first-run] codex 999.0.0 is available (running 0.149.0); its update prompt is
dismissed so it cannot block a session — run `codex update` or rebuild the image

After it, the session shows a one-line banner instead of a modal — visible, not blocking. Called per launch, because that cache is refreshed in the background hours after boot, so once at boot would not hold.

There is no flag or env switch for this: no --no-update-style option in codex --help, and no CODEX_*UPDATE* env string in the binary. DISABLE_AUTOUPDATER does nothing to it, as you found.

3. TOML spelling — fixed by decoding keys, not by matching text

codexTrustedPaths() now parses every [projects.KEY] header and decodes basic strings, literal strings ([projects.'/work/legal']) and bare keys before comparing. Your case is a test: with a literal-string entry present, trustCodexWorkspace('/work/legal') returns false and the file keeps exactly one table for that path.

4. Inheritance reported per adapter

The blanket claim is gone from first-run.js, runner.js and the doc. The doc now states it per CLI, and says plainly that the old claim was wrong in the expensive direction — it justified a runtime write Claude never needed.

Verified

Fresh configs, nothing trusted, an update pending:

claude codex
task at launch ● FIXED • FIXED (update shown as a banner)
no task, attach reaches its prompt, no modal

13 unit checks. Teeth: reverting to substring detection fails 2; making the update policy clobber latest_version fails 1. Server suite green — 193 checks, exit 0.

One process note: archive.test.mjs failed twice mid-investigation with state working, which was load from the probe servers I had left running — it passes alone and the full suite is green once they are stopped. Not related to this change.

Still unchanged: permissions.defaultMode, Codex's approval_policy / sandbox_mode, and every update-checking mechanism.

@lvwerra lvwerra left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: CHANGES REQUESTED

The redesign fixes the two original failures on the ordinary path, and the promptless-Codex case now works end to end. I still found three correctness holes that make the current claims too broad, two of which can put a real launch back behind a dialog.

1. Correctness / does it solve the problem — a workspace symlink can escape Claude's one trusted root

server/src/runner.js:1843-1855 treats the recorded path lexically: it joins it below WORKSPACES_DIR, creates it, and relies on the root entry written by trustWorkspacesRoot(). Claude resolves the working directory before applying inherited trust. I created workspaces/link-out -> /home/node/local/.../outside-target, then created a real API session with path: "link-out" and a launch task. The pane stopped on:

Accessing workspace:
/home/node/local/.../outside-target
...
❯ 1. Yes, I trust this folder

The launch task never ran. This is not an impossible path: an agent can create a symlink under its workspace, and the session API accepts that relative name. A normal child and deep/a/b both ran their launch tasks; a symlink whose real target stayed under the trusted root also ran. A direct sibling outside the root showed the dialog, as expected. The failing boundary is specifically a path that is lexically under the root but canonically outside it.

Please reject an escaping symlink (which also preserves the workspace boundary) or otherwise handle the canonical target without bringing back the per-launch .claude.json rewrite.

2. Correctness — codexTrustedPaths() still misses legal TOML and can corrupt config.toml

server/src/first-run.js:169 requires ] to be the last non-whitespace character on the line. A comment after a table header is legal TOML:

[projects."/work/legal"] # retained comment
trust_level = "trusted"

Against that valid file, trustCodexWorkspace('/work/legal') returned true and appended a second table. Python tomllib then failed with Cannot declare ('projects', '/work/legal') twice. Whitespace around the dotted-key separator ([projects . "/work/legal"]) is another legal spelling the regex misses. The literal/basic-key case from round one is fixed, but the new statement that every legal spelling is decoded is not yet true. This needs either real TOML parsing or detection that accounts for the grammar rather than an end-of-line header regex.

3. Correctness — the update-cache path reintroduces the whole-file race, and its unknown-version guard is ineffective

server/src/first-run.js:142-150 reads all of Codex's version.json and renames a modified stale snapshot over it on every undismissed update. A live Codex background check writes this same file. I repeated the original race against this path: start with a 64 MiB valid cache at latest_version=0.200.0, last_checked_at=10:00; call dismissCodexUpdatePrompt('0.149.0'); when .am-tmp appears, have the Codex-side writer put 0.201.0, 11:00 in the live file. The helper returned true, but the final cache had reverted to 0.200.0, 10:00 and the concurrent marker was gone. The file remained valid, so the loss is silent.

That is a less damaging file than .claude.json, but it directly contradicts first-run.js:22-23, the doc's “latest_version and last_checked_at are left exactly as Codex wrote them”, and the round-two claim that nothing on the session path rewrites CLI-owned state. In the real binary, the background refresh is a whole-file tokio::fs::write, so the same ordering exists. Please make this merge/retry-safe or narrow the claim and establish that losing a refresh is an accepted consequence.

There is a second boundary in the same function: isNewer('0.200.0', null) is true. With a fresh undismissed cache, dismissCodexUpdatePrompt(null) returns true and logs running null. first-run.test.mjs appears to test the opposite, but it calls the null case only after setting dismissed_version equal to latest_version, so it passes for the wrong reason. refreshVersions() is asynchronous and the server accepts requests before it completes, so cliVersion('codex') can genuinely be null. Please give this case its own fresh fixture and define the intended startup behavior.

4. Correctness / documentation — two live descriptions still say the old implementation

server/src/runner.js:1846-1849 says Codex trust comes from a -c override and that neither adapter writes CLI state on the launch path; the code immediately appends config.toml and rewrites version.json. The PR body is also still the round-one body: it says trust never inherits, no update dialog exists, and describes the removed trustWorkspace() design. docs/first-run-dialogs.md itself is corrected, including the update dialog; please bring the runner comment and PR body into agreement before merge.

What passed

  • Correctness: Claude 2.1.232 inherited root trust for a real child, a deeper descendant, and a symlink resolving inside the root. Codex 0.149.0 did not inherit parent trust, and -c 'projects."<dir>".trust_level="trusted"' still showed the real trust dialog. The append-only config.toml strategy has the claimed failure class: a concurrent Codex replacement can lose our append, but the append does not replace the CLI's snapshot.
  • Does it solve the operator's problem: A real API Codex session created without a task reached the normal prompt with the pending update reduced to a banner; the first subsequently delivered prompt produced the exact requested answer rather than being truncated. A launch carrying a prompt skipped the modal, while the same cache on a promptless direct launch showed it, confirming the boundary.
  • Updates: With a stale version.json, real Codex refreshed latest_version from 0.100.0 to 0.149.0 and advanced last_checked_at while preserving the old dismissal. The codex update subcommand remains available. The change does not disable checking or alter approval/sandbox policy.
  • Verification: the 13 focused first-run tests and the full server suite (25 suites) pass. The branch stayed untouched at 999f9cd.

…claim

All four findings reproduced. One of them I first tried to fix and measured the
fix failing, so it is narrowed and stated rather than claimed.

**1. A path that escapes the workspaces root is refused.** `path` is only
checked lexically when recorded, so a symlink under a workspace can point out of
the tree — and an agent can make one. Claude applies inherited trust to the
RESOLVED directory, so an escaping link landed outside the single trusted root
and stopped the pane on the trust dialog with its task queued behind it.
`ensureRunning` now resolves both the root and the workdir and refuses, naming
the resolved path and what to do instead. That also keeps the boundary the
recorded path already implies, and needs no per-launch write. Verified across
the four cases: an ordinary child and a symlink that stays inside both run; a
link out is refused, in the log and in the API response.

**2. Trust detection parses TOML instead of matching text.** The end-of-line
regex missed `[projects."/p"] # comment` and `[projects . "/p"]`, appended a
duplicate table, and produced a file Codex refuses to load. `codexTrustedPaths()`
now parses (smol-toml) and returns null for a file it cannot parse, in which
case nothing is appended — appending to a file we do not understand is how a
broken config becomes a broken config with our text in it.

**3. The version-cache race is narrowed, not papered over.** I first added a
compare-and-set after the rename. Measured with the review's own repro, it does
not work: the interceptor fires, our rename lands after it, and re-reading
afterwards shows only our own snapshot. So the code does a single honest write
and the comment, the doc and the test now say what that costs — one refresh can
be lost, which delays a notification until Codex's next background check and may
show the modal once. What is still guaranteed is the narrow thing: the file ends
self-consistent, so the modal cannot open. `.claude.json` holds state the
operator cannot reconstruct, which is why nothing rewrites that one.

Same function: `dismissCodexUpdatePrompt(null)` used to dismiss an update,
because `isNewer(x, null)` is true and `refreshVersions()` is async — the server
serves requests before versions resolve. It now does nothing without a known
version. The old test passed for the wrong reason (it pre-set the dismissal); the
new one uses a fresh fixture and asserts nothing was written.

**4. Descriptions match the code again.** The `runner.js` comment still described
the `-c` override and claimed neither adapter writes CLI state on the launch
path. The PR body was still round one's, asserting that trust never inherits and
that no update dialog exists; it is rewritten.

17 unit checks. Teeth: reverting to the regex fails 4, dropping the unknown-
version guard fails 1. Server suite green (193 checks) once my own probe servers
were stopped — under their load `archive.test.mjs` flakes on timing.
@lvwerra lvwerra changed the title Answer the folder-trust dialog before a new session hits it Make a new Claude or Codex session ready for a prompt without a human Aug 21, 2026
@lvwerra

lvwerra commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

All four fixed in 34ffeb6, and one of them I had to fix twice — the first attempt measured as not working. Round-three evidence →

1. Symlink escape — refused

ensureRunning now resolves both the root and the workdir and refuses when the session would run outside the tree. I took the reject option you suggested: it keeps the boundary the recorded path already implies and needs no per-launch write, which is the constraint that mattered.

Reproduced your case and the neighbours, so the boundary is where you said it is:

deep/a/b                      ran its task     (ordinary child)
link-in  -> inside-target     ran its task     (symlink, stays inside)
link-out -> outside-target    refused, loudly

It fails loudly rather than silently — logged at creation, and the API answers:

{"error":"link-out resolves to /home/node/local/outside-target, outside the
 workspaces root — a session has to run inside it. Point the session at a folder
 in the tree, or copy what you need into one."}

2. TOML — parsed, not matched

You are right that "every legal spelling is decoded" was not true of an end-of-line regex. codexTrustedPaths() now parses with smol-toml, so [projects."/work/legal"] # retained comment and [projects . "/p"] are found. Against your exact file, trustCodexWorkspace returns false and not one byte is added. A file that does not parse returns null and is left alone — appending to a file we do not understand is how a broken config becomes a broken config with our text in it.

3. The race — I tried your suggestion, measured it, and it does not work

I added a compare-and-set after the rename first. It fails, for the reason your repro implies: the interceptor fires, our rename lands after theirs, and re-reading afterwards only shows our own snapshot, so the CAS sees nothing wrong.

interceptorFired = true
final file       = latest 0.200.0, dismissed 0.200.0   <- their 0.201.0 gone

So I took your other option and narrowed the claim, in all three places that were overclaiming: the module comment, the doc, and the test. The code does one honest write. What is guaranteed is that the file ends self-consistent, so the modal cannot open; what is accepted is that one refresh can be lost, costing a notification delayed to Codex's next background check. Stated with the reason it is a different bargain from .claude.json, which holds state the operator cannot reconstruct.

The test now asserts that narrow guarantee and names the accepted loss, instead of asserting a repair that does not happen. I would rather have a test that documents the limitation than one that reads like coverage.

The null boundary: fixed, and you were right that my old test passed for the wrong reason. dismissCodexUpdatePrompt now returns false without a known version; the new test uses a fresh fixture and asserts dismissed_version is still null afterwards. I left isNewer('0.200.0', null) === true as it is and asserted that too, so the guard's location is explicit rather than implied.

4. Descriptions

The runner.js comment now describes the append and the boot-time entry. The PR body is rewritten — it was still round one's, claiming trust never inherits and that no update dialog exists.

State

17 checks. Teeth: regex detection fails 4, dropping the unknown-version guard fails 1. Server suite green, 193 checks.

One note in case it shows up again: archive.test.mjs flaked twice on timing mid-investigation. It was load from probe servers I had left running — green once they are stopped, and unrelated to this change.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant