Repository navigation
Inbox: Trust folder button for Claude Code launches - #2451
Open
backnotprop wants to merge 147 commits into
Open
backnotprop wants to merge 147 commits into
backnotprop wants to merge 147 commits into
Conversation
PR 1 of the launch plan. Compose's To offers "New Claude Code session" under the running sessions; Send writes the thread first, then starts the agent with `claude --bg` and a per-launch settings file (login-shell CLAUDE_* env, 0600). The mod's existing bridge poll delivers the first turn. A launched thread shows one run line (starting, working, waiting on a permission prompt, stopped, failed, missing), Stop / Open in Terminal / Delete in the thread menu, and a reply to a stopped session respawns it. Full permissions is a Settings → Agents switch. Config-only flag `inboxLaunch.enabled`, default off: off, the launch routes answer 404 and the window is unchanged. Tests: tests/e2e/inbox-launch-claude.spec.ts against the real endpoint with a fake `claude` (tests/helpers/fake-claude); unit tests for the run-line mapper, the env filter and the CLI parsers. scripts/launch-smoke/ is the manual real-CLI smoke.
- A permission-prompt line goes once the session is live and idle (answered in Terminal); the host is re-read while it last said waiting. - The login shell is read per start only, from a minimal seed env; every other claude call runs with the Inbox's own env, scrubbed of PLANNOTATOR_*, CLAUDE* and Claude's per-session names (CLAUDE_PID, CLAUDE_CODE_SESSION_*, CLAUDE_CODE_MESSAGING_*, CLAUDE_CODE_CHILD_SESSION, CLAUDE_CODE_EXECPATH, CLAUDE_CODE_ENTRYPOINT, CLAUDECODE). - inboxLaunch.extraSettings is gone; one validated inboxLaunch.model. - A reply respawns once; a failed or unconnected respawn reads failed with Try again, never retried by itself. - Config read once per list (cached by config.json stat); launched roots indexed in the store. - No claude call is killed with its process group: the shared supervisor is never in a group we kill. - Delete or Try again during a start leaves no orphan session. - Starting holds until the session polls busy or answers (no blink). - Phone: no Delete; re-address offered only when the message can move. - Tests: wait-fors instead of sleeps, no wording snapshot, e2e for the prompt answered in Terminal, Delete during --bg, re-address in 15, phone 01/08; the smoke checks the supervisor's group and one login read.
- N1: every claude call keeps the person's CLAUDE_CONFIG_DIR and other CLAUDE_* settings; only Plannotator's names and Claude's per-session ones are dropped. - N2: the login shell starts from the Inbox's env through the same filter, so SSH_AUTH_SOCK, launchctl proxies and LC_* survive. - N3: Stop while "Starting again" reads "You stopped", never a failure, and the same reply does not respawn it again. - N4: the shell's own skip-permissions export never rides the --bg env; it is set only when the full-permissions switch is on. - N5: availability says what it checked (the Inbox's PATH, the last start's login PATH, Claude's install folders). - Try again on a start that never connected respawns it (or starts anew when no session was made) instead of answering 422. - Test comment and smoke notes corrected.
A per-launch PermissionRequest hook (plannotator inbox permission-hook) asks the person in the launch thread under the perm:<session> identity, long-polls /api/inbox/launch/<id>/permission, and prints allow once, allow for this session, or deny with a reason. ExitPlanMode passes straight through. A 60 s lease withdraws the question when the prompt is answered in Terminal or no longer waited on. The run line gains waiting_answer. Behind inboxLaunch.enabled. Co-Authored-By: Claude <noreply@anthropic.com>
…eat/inbox-launch-permissions
… smoke Co-Authored-By: Claude <noreply@anthropic.com>
M1: a command's list lines can never become choices. The command and
folder now sit above the :::question block, which holds only the prompt
and three "- [ ]" choices; lines starting ":::" are escaped. A pick is
read by exact label. The route checks the stored question has exactly
this prompt's labels, and withdraws it otherwise (fail closed).
S1: the Inbox sends the hook only { choice: once|session|deny, reason }.
The hook builds the decision from its own event, so whatever answers on
the port cannot change tool_input or grant more than Claude suggested.
S2: picks/reply on a perm: question need the page's own Origin and this
run's serverSession (403 window_only otherwise). The perm: author prefix
is reserved to the permission route in the store. Docs say what this
does and does not protect against.
S3: "Answered in Terminal" only when no hook call is held.
S4: each hook call is cut off at 35 s; inbox.json is read again on each
retry; 15 refusals of one token end the wait with a stderr line.
Low: a command over 8,000 chars is never asked (Claude's dialog
decides); permission styling only in a launched thread with launch on;
turning launch off withdraws open questions; reuse the Inbox's
selfCommand; AskUserQuestion passes through like ExitPlanMode; refusals
log to stderr; dead code and the unused lease field are removed.
Co-Authored-By: Claude <noreply@anthropic.com>
N1: a path (Write, Edit, Read, NotebookEdit), a link (WebFetch) or a
query (WebSearch) is one line of inline code; one holding a line break,
a control or an invisible character is never asked (withdrawn, Claude's
own dialog decides). Every detail counts toward the 8,000 cap. The
route now checks the stored body, prompt and labels against the
question it would write now, so a reshaped card fails closed.
N2: the session label names every rule or folder the suggestion grants
("Allow `git add` and `git commit` for this session"); a rule it cannot
show exactly, or a label over 200 characters, offers no session choice.
N3.2: the store refuses a Re-ask on a perm: message (422), and the
window no longer offers one.
Phone: a request the device door hands in (its token checked) may
answer a permission question; a plain local call without the page's
Origin is still refused.
Low: bidi and invisible characters are shown as <U+202E> markers in
every detail and label, never hidden; asked() returns { prompt, detail }
and inboxPermissionQuestion decides askable; the stale "(after one more
host read)" is gone; the docs state the accepted risks (a local program
forging headers, browser automation clicking the window).
Co-Authored-By: Claude <noreply@anthropic.com>
…missions # Conflicts: # packages/inbox/api.ts
Builds approval "inbox-permission-prompt" (card frame and width, the dock's header) into the launch thread: - PermissionCard: header "Claude Code is waiting for your permission · N min" with Stop, in place of the separate run line; the action title and the exact request (command with purpose and folder, Write content as numbered lines, Edit as removed/added lines, WebFetch link with host and "Looking for"); Allow once (accent), Allow <rule> for this session, Deny, Deny with a reason; "Or allow this and switch the session to Auto mode · Full permissions". Answered, it shrinks to one framed line; the answer reply and the withdrawal follow-up are hidden. - Mode switches are real: two new exact-label choices, enum "auto" | "full"; the hook answers allow with setMode auto / bypassPermissions built from its own event. Full permissions only for a launch with full permissions (inboxLaunchFullPermissions, the one seam for the open question) and only when the hook's own env has PLANNOTATOR_LAUNCH_FULL_PERMISSIONS=1; otherwise drawn off with its reason. Confirms the first time per session. - Allow once takes focus when the card is newest and waiting, and the footer is scrolled into view, so Enter answers it. No new shortcuts. - Body additions (Write content, Edit diff, Bash description, WebFetch purpose) go through the same fencing and escaping; the stored body is still compared with the rebuilt one (fail closed). - The questionChrome seam in packages/ui is removed again (packages/ui equals main). Tests: escaping of a Write holding fences and "- [ ]" lines; mode-switch choice, route and hook tests; e2e of the approved card through the real hook (Write by Allow once with Enter, Bash by the session rule, WebFetch denied with a reason, Edit by Auto mode with setMode auto, Full permissions off and confirmed). Co-Authored-By: Claude <noreply@anthropic.com>
- The session button wraps, so every rule it grants shows on desktop and phone. - A WebFetch link is shown exactly as sent (scheme, host, path, query), wrapped, with "Host: ..." on its own line when the text does not start with the real host. - Allow once takes focus only from the page body, without a focus ring; the answers ignore activation for 600 ms after the card appears or takes focus, and a repeated keydown never clicks. - Full permissions has one source, the launch record, for the start flags, the hook's env and the card. - Picks on a permission question are unsent-only in the store; the first Full permissions pick of a session needs confirm_full on the server too. - The request in full: Write says create or overwrite, Edit shows replace_all, MultiEdit and NotebookEdit show their edits, Bash shows "Runs outside the sandbox", and undrawn input keys show as JSON. - Auto and Full records say what was asked, never that it switched. - Session rule labels keep their backticks (code spans parsed properly). - More invisible ranges shown (tag block, soft hyphen, Hangul fillers, variation selectors); doc comments fixed; disabled Full permissions note in flow, never over Deny. Co-Authored-By: Claude <noreply@anthropic.com>
…sync file check
- A reply carrying its own answer for an answered permission question posts its
words as text and leaves the answer record alone (question_already_sent when
nothing else is left to send).
- The Write overwrite check is an async lstat given 300 ms, so an unreachable
network path never freezes the Inbox; a timeout or error says "Write a file".
- A file tool's path that is a symbolic link shows where it points
("Links to ..."), kept the same when the question is checked again.
Co-Authored-By: Claude <noreply@anthropic.com>
…us keep one line The answered record's path takes only the room left and truncates first; the waiting header reads "waiting · N min" on a phone. The e2e checks both at 390 wide. Co-Authored-By: Claude <noreply@anthropic.com>
…uto mode - Never set, Settings -> Agents' "Start new sessions with full permissions" follows the login shell: CLAUDE_CODE_EVEN_MORE_DANGEROUSLY_SKIP_PERMISSIONS set to a true value makes the launch full (launch.full_permissions, still the one source, fixed when the launch is made; the first start reuses that read). Once the person sets the switch, their choice wins either way. Settings shows it on with "Follows your shell's setting until you change it." - Every launch starts with --allow-dangerously-skip-permissions and --permission-mode bypassPermissions (full) or auto. Auto mode refused: started again with no --permission-mode, and why goes to the start log. - The card always offers Full permissions; the greyed-out state is gone. - Fake CLI: asks nothing in bypass mode, refuses auto on refuseAuto, and its login shell takes extra exports. E2E for the shell default and the fallback. Co-Authored-By: Claude <noreply@anthropic.com>
… no hook in a full launch From the security review of c445a71: - M1/S1: at launch, read the person's own bypass consent (read-only) into launch.bypass_available. The allow flag, a bypass start and the card's Full permissions need it; older launches read as without it. The Inbox never writes consent. The fake CLI models Claude's consent rule. - S2: GET /api/inbox/launch/settings refuses another site's page; concurrent reads share one login-shell read. - S3: the auto fallback pins permissions.defaultMode "default" in that launch's settings file; only commander's refusal of the mode flag counts. - S4: a full launch has no PermissionRequest hook (Claude's skip export waives bypass's safety checks only without one); the Inbox tool stays allowed. - The order: the Settings switch, the person's own defaultMode (no --permission-mode, never forced to auto), the shell's export, then auto. - Nits: stale comments, the login env kept at most a minute; a card already there when the thread opens leaves Reply its focus. Co-Authored-By: Claude <noreply@anthropic.com>
… Claude reads it; Send off the shell From the re-check of 499db45: - M1: the Inbox PermissionRequest hook is in every launch, full included. Plannotator's plugin has its own PermissionRequest hook, and Claude then ignores the skip export, so bypass's safety checks still ask; they now reach a card. The export is no longer set anywhere. The fake CLI counts the enabled plugin's hook; e2e proves a full launch's safety check reaches the card. Docs no longer say a misjudged bypass fails to start. - S1: consent only from managed or user settings.json and ~/.claude.json; disableBypassPermissionsMode from managed, user settings.json and settings.local.json, and the launch folder's settings. - S2: every auto launch pins permissions.defaultMode "default". - S3: Send uses a shell read under a minute old and waits only to finish one under way, when the switch is unset and none is fresh, or when none ever finished; read once at Inbox start and ahead when the composer picks Start Claude Code; the start reads its own env after the response. - S4: a person's own mode applies whenever the launch is not full. - Nits: a Full pick a session cannot honour is Allow once; a full launch's card offers no Full; Claude files read once per settings request. Co-Authored-By: Claude <noreply@anthropic.com>
…read as Claude reads it From the review of 3e6006e: - S1: prepare decides from the last finished login-shell read, however old (each start reads the shell again); it waits only for a read under way or when none ever finished. - S2: disableBypassPermissionsMode counts only as "disable", from managed, the user's settings.json and the launch folder's settings (never ~/.claude/settings.local.json). Settings says plainly when bypass is turned off in Claude Code's settings (bypass_off), never the consent notice. - Nits: a full launch's card offers no mode switch; the Settings copy allows that a safety check still comes to the card; the stale env header and the docs on the ahead read are fixed. Co-Authored-By: Claude <noreply@anthropic.com>
New scratch folder in compose's folder menu makes <root>/<thread-name slug> (-2, -3 when taken, never through a link) and starts Claude Code there. The root is inboxLaunch.scratchDir (default <dataDir>/scratch), set in Settings > Agents. While Claude Code does not trust the root the row reads 'trust it once', and the failed start's Open in Terminal runs claude in the root. Delete purges and removes a scratch folder, only while it is still the folder the Inbox made; never a project folder. Co-Authored-By: Claude <noreply@anthropic.com>
Claude keys workspace trust on a git repository's root, and a parent's trust never covers a nested repository (real-CLI smoke, code.claude.com docs). The Inbox makes scratch folders as plain folders, so the root's trust covers them. A scratch folder the agent made a repository needs its own trust: a reply's restart refused as untrusted now offers Open in Terminal, which runs claude in that folder. claude purge runs with --yes, as its confirmation would wait on a terminal no one sees. The fake CLI models both. Co-Authored-By: Claude <noreply@anthropic.com>
claudeTrustsFolder and where a launch's trust is keyed move to inbox-launch-claude.ts; inbox-agents.ts only exposes the config path. Scratch-folder creation and removal stay agent-neutral.
…aunch's safety check is Deny or Terminal
Claude Code 2.1.314 ignores a PermissionRequest hook's allow for its own
safety checks (person-only) and keeps its Terminal dialog; a deny counts.
The hook input marks nothing, so the launch's mode decides.
- A full launch's card offers Deny and Deny with a reason, the host's note
("Claude Code only accepts this approval in Terminal."), Open in Terminal
(computer) and Copy attach command (everywhere).
- Every allow is checked: the first host read 3 s after the answer decides.
Still waiting on a permission prompt: a follow-up says Claude Code didn't
take it, and the record shows that with the Terminal actions; until then
"Sent, checking that it went through". Never "Allowed" before it took.
- Host reads from before an answer settled never show "waiting on a
permission prompt" for the prompt just answered.
- The thread read carries attach_command, so the resting menu has Copy
attach command (phones).
- Claude-only terms, note, follow-up text and the attach command live in
inbox-launch-claude.ts; core and the card take generic allowHere/note.
- Nits: Try again of a start that never ran is a first start (no "again");
no "· N min" under a minute.
- The fake CLI models person-only safety checks (allow ignored, own dialog,
auto-block after safetyBlockMs) and lists waitingFor while it waits.
Co-Authored-By: Claude <noreply@anthropic.com>
…nto feat/inbox-launch-host # Conflicts: # docs/agent-guides/inbox-launch.md # docs/agent-guides/server-and-storage.md # packages/server/inbox-launch-claude.ts
…safety checks to Claude's own dialog
Simplify after review: no allow-verification state machine.
- Removed the allow checks (settle/max timers, "Sent, checking", the
"didn't take" follow-up, run.approval_check). The record states the
person's own answer ("You allowed this once", "You denied this", "You
chose to approve this in Terminal"); the run line says what Claude does.
Host reads made before an answer settled still count for nothing, and one
read is due once it settled.
- M1: the hook input's permission_mode decides bypass mode
(claudePermissionTerms in inbox-launch-claude.ts); the launch record only
when the input has none. A full launch whose session says default keeps
Allow once.
- M2: a bypass card offers Deny, Deny with a reason and Approve in Terminal.
Approve in Terminal answers `terminal`: the hook prints nothing, so Claude
shows its own dialog (2.1.314 awaits the hooks first); on a computer the
card opens Terminal attached, the record says "Approve it in Claude Code
within 2 minutes", and on a phone Copy attach command until the run line
shows it. One set of Terminal actions.
- The bypass card's header says "is waiting on a safety check"; its question,
withdrawn with the hook gone, says "No longer waiting." (never "Answered in
Terminal.").
- The fake shows a safety check's dialog only after the hook ends, with its
own 2-minute deadline from then.
Co-Authored-By: Claude <noreply@anthropic.com>
…nto feat/inbox-launch-host # Conflicts: # docs/agent-guides/inbox-launch.md # docs/agent-guides/server-and-storage.md # packages/server/inbox-launch.ts
…lete, purge smoke) M1: a deleted launch's Delete does nothing; a path any launch was given is never given again; Delete checks the folder's inode. S1-S7: re-check before removal, cached trust read, no orphan project on a refused Send, no scratch root inside a git repository, trust through the host, the trust-once row names the root with a hint, a kept folder is said in the toast. Purge skipped below a repository's root (the real purge on 2.1.314 drops the repository's entry there).
…nto feat/inbox-launch-scratch # Conflicts: # docs/agent-guides/inbox-launch.md # docs/agent-guides/server-and-storage.md # packages/server/inbox-launch-claude.ts # packages/server/inbox-launch.ts
# Conflicts: # docs/agent-guides/inbox-launch.md
# Conflicts: # packages/inbox/components/Thread.tsx
# Conflicts: # apps/hook/server/inbox-command.ts
…, not crypto.randomUUID)
…en's first fresh read, never a stale copy App records the ids of the first thread read numbered after the open and passes them to ThreadPane (openedWith); until that read lands no card takes focus. Adds a DOM test for the focus rule (permissionFocus.test.tsx) that also covers the insecure-context answer.
# Conflicts: # docs/agent-guides/inbox-launch.md
# Conflicts: # packages/inbox/components/Thread.tsx
backnotprop
added a commit
that referenced
this pull request
Nov 3, 2026
…by Trust and start on #2451)
backnotprop
added a commit
that referenced
this pull request
Nov 3, 2026
…ne only (⋯ has no Open in Terminal since #2451)
The agent is blocked until the person answers, so a new permission question ends the thread's snooze at once (owner answer q-21cc8c10 A). A replayed hook call does not.
…older In the home folder: "Your home folder can't be trusted. Start in a folder inside it." Anywhere else: "To trust it, run <agent> here in a terminal on the Inbox's computer." The run carries home_folder when the launch folder is the Inbox's home (owner answer q-aa43fc24 A).
# Conflicts: # packages/server/inbox-launch-permission.ts
… subpaths; ui pins it Core gains ./inbox-run-line, ./inbox-permission and ./inbox-scratch across the launch-agents stack; published core 0.25.17 has none of them, so smoke:package failed. 0.25.18 is published from main after the stack merges.
…aunch-permissions
…box-launch-scratch
runOf compared resolve() paths, which misses a symlinked home or another case on macOS while the Claude host decides home by placeKey. Use samePlace; host tests for a linked home and a trailing slash (review P2-B1).
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #2402 (
feat/inbox-launch-host).What changes
Trust and start in New message is now the main path (approval
.product/approved/inbox-launch-trust-compose/). On a folder Claude Code doesn't trust, New message's button reads Trust and start. One press trusts the folder and starts, with no confirm step./, a folder above home and guests get no offer.The thread's Trust folder button stays as the fallback for a launch that still stops on trust.
The press is the consent
GET /api/inbox/sessionscarrieslaunch.agents[i].untrusted, from the cachedtrusts(), so the 5 s refresh reads no folder's files.?trust_agent=&trust_project=addsfactsandwarnsfor the folder To names. Facts and the run line's warning come from one reading (claudeTrustReading).to.launch.trust: { folder, facts }. If the folder changed since that reading, the answer is409 trust_warning_changed: nothing is trusted, sent or made. The window reads again, opens the details on what changed, and ignores presses for 600 ms.409 trust_failedsends nothing, keeps the words and removes a scratch folder made for that Send.The gate
Compose's Send can now write the person's Claude config, so
to.launch.trustbelongs to the owner's window alone.serverSession403 window_onlysend_messagehas no such field403 owner_only, and gets no offer403 cross_origin409, stale tabA
trustkey that holds nothing still counts as a press, sonullcan't pass as a plain launch. Behavior change:POST /api/inbox/launch/:id/trustnow has the same window check. Before, a bare local call counted as the owner. A local program that forgesOriginand readsserverSessionstays "Accepted, not defended" ininbox-launch.md.The thread's Trust folder button
When Claude Code refuses a start or a restart because the folder isn't trusted, the run line gets a Trust folder button. One click saves the trust, then starts the launch again, so the kept message goes out with no second click.
403 owner_only.enableAllProjectMcpServers: true: Claude's "New MCP server found" prompt would otherwise hold the hidden session, and the folder's hooks behind it.disabledMcpjsonServersstill wins. Found by a realclaudecheck of ffc4d22.The write
It sets
projects["<key>"].hasTrustDialogAccepted = truein the file Claude picks: a legacy.config.jsonwhen it applies, else$CLAUDE_CONFIG_DIR/.claude.json, else~/.claude.json, with the custom OAuth name when that is set. Only that value changes: every other byte stays, and so does the file's mode. A symlinked config is written at its target. The write is flushed to disk before the rename, as Claude's own save is.mkdirat<config path>.lockand released withrmdir. It counts as dead once its mtime is 10 s old. The Inbox holds it only for a synchronous read, edit and rename.claudeTrustsFoldernow reads the same key, which fixes the two mismatches: a linked worktree of a trusted repository reads as trusted, and paths compare physically.Open in Terminal
Owner's decision, in its own commit: out of the ⋯ menu. It shows on the thread's status line only when a session is waiting on Terminal or can't start. Copy attach command stays in ⋯.
openTerminal), for the line and for Approve in Terminal on the permission card, with the rule the ⋯ item had: not on a phone or over the tailnet, and only with a session or a refused folder.failedline with a session (never connected, a restart that failed) has the button. ⋯ used to be its only way there. The rule looks at the session, not atattach_command, so a host that offers no attach command while something holds the session still has it.terminal()are unchanged, so a host's own guard on the command stands. That's the guard against a second writer onfeat/inbox-launch-pi.Fixes from the security review
403 owner_only.accepttakes anallowedcallback for this.claudeTrustKeyskips a.gitthat isn't a regular file or folder, and reads its pointer files throughreadAgentFile, so a FIFO can't block the Inbox.Copy and docs
inbox-launch.mdcovers Trust and start, the offer, the press, the gate and the seam (trustOffer(target):{ folder, read(), accept() }), and no longer says the Inbox never edits~/.claude.json.server-and-storage.mdhas the new route and fields.Test sandbox
Under
PLANNOTATOR_TEST_SANDBOX, the server's launch home is now<dataDir>/agents-home, so a test that clicks through the real server can't reach the real~/.claude.json.refuseRealClaudeConfigrefuses a home or its Claude folder as a test's config dir.startInboxServergains alaunchHoststest seam, andfakeLaunchHostmoved totests/helpers/.Tests
claude: the launch specs run in CI's Inbox window e2e on this PR, on Linux. Locally, the five (inbox-launch-claude,-permission,-scratch,-trust,-trust-compose) with the Pi spec, 54 passed on a trial merge of this branch and Inbox launch: Pi host #2412. The compose spec covers desktop and phones 390 and 320 wide.inbox-launch-trust-send.test.ts, 32 tests for the gate, the order, 409 andtrust_failed.composeTrust.test.tsx(23) andopenInTerminal.test.tsx(9), both rendering the real components.ci:full):unit-1tounit-3,dom-1todom-4,heavy-agents,heavy-modand Checks all pass.useComposeSessionscheck, the name re-check under the lock, the reused offer, and both FIFO guards each failed tests when removed or changed.heavy-*lanes, the fullbun test, WebKit, and real CLIs. The real-CLI check is the owner's.