Make a new Claude or Codex session ready for a prompt without a human - #110
Make a new Claude or Codex session ready for a prompt without a human#110lvwerra wants to merge 3 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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-110andREADY-CODEX-110with no input sent after creation. - The durability claim is correct:
entrypoint.shputs both live configs under$AM_LOCAL,scripts/agent-state.shrestores/checkpoints Claude whole and Codex excluding only databases/caches, so these files reach/dataand 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; rerunningserver/resize.test.mjspassed 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).
|
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. 1. Nothing on the session path rewrites CLI-owned state any moreYou 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:
2. You were right about the update dialog, and I can now say why we saw different thingsIt 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: 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 3. TOML spelling — fixed by decoding keys, not by matching text
4. Inheritance reported per adapterThe blanket claim is gone from VerifiedFresh configs, nothing trusted, an update pending:
13 unit checks. Teeth: reverting to substring detection fails 2; making the update policy clobber One process note: Still unchanged: |
lvwerra
left a comment
There was a problem hiding this comment.
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-onlyconfig.tomlstrategy 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 refreshedlatest_versionfrom0.100.0to0.149.0and advancedlast_checked_atwhile preserving the old dismissal. Thecodex updatesubcommand 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.
|
All four fixed in 1. Symlink escape — refused
Reproduced your case and the neighbours, so the boundary is where you said it is: It fails loudly rather than silently — logged at creation, and the API answers: 2. TOML — parsed, not matchedYou are right that "every legal spelling is decoded" was not true of an end-of-line regex. 3. The race — I tried your suggestion, measured it, and it does not workI 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. 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 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. 4. DescriptionsThe State17 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: |
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 threeWhat a new session actually hits
hasTrustDialogAccepted/trust_leveldismissed_versioninversion.jsonremote-settings-consent.jsonskipDangerousModePermissionPromptTrust 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
reply with exactly SECONDarrived aswith exactly SECOND.waitForInputReadycannot 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
settings.jsonconfig.toml-cdoes not work, so it must be in the file — but an append never rewrites another process's bytesdismissed_versionpathis checked only lexically when recorded, so a symlink can leave the tree; Claude applies inherited trust to the resolved directoryNo 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.jsonmeans 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"] # commentand[projects . "/p"]are recognised instead of duplicated into a file Codex refuses to load.Suppressing a prompt is not never updating
dismissed_versionis the field Codex's own "Skip until next version" writes. No check is disabled,codex updatestill works, and the version found is logged rather than swallowed:After it, the session shows a one-line banner instead of a modal — visible, not blocking. Nothing changes what an agent may do:
permissions.defaultModeand Codex'sapproval_policy/sandbox_modeare as found.Durability
Both config trees are already carried by
scripts/agent-state.sh—CLAUDE_CONFIG_DIR⇄$DATA_DIR/state/claude(whole) andCODEX_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:
● FIXED• DONE, update shown as a bannerserver/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.