Skip to content

macOS: Claude and Codex editor keys, rebindable in Settings; agents you launch run without the isolation layer - #146

Open
mrcertis wants to merge 11 commits into
howdeploy:mainfrom
mrcertis:claude/fix-agent-isolation-tmp
Open

mrcertis wants to merge 11 commits into
howdeploy:mainfrom
mrcertis:claude/fix-agent-isolation-tmp

Conversation

@mrcertis

@mrcertis mrcertis commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

This replaces #145. #145 was an early, unfinished state of this work; we found and fixed several bugs in it while using it. This PR is the complete, tested version.

A ready macOS arm64 build of exactly this branch (plus the Codex frontend below) to try it: https://github.com/mrcertis/CanvasTTY/releases/tag/1.8.1-mrcertis.1

Summary

macOS only, except the isolation change (all platforms) and two test skips on Windows.

  1. Agents you launch run without the isolation layer. Every agent outside Manual was wrapped in sandbox-exec, whose writable list lacked /tmp/claude-<uid>. Claude Code keeps its Bash tool and external-editor files there whatever TMPDIR says, so every shell call and Ctrl+G failed with EPERM. The layer now wraps only delegated agents (subagents, plugin-started agents) and a CLI's contained auto. Isolated Claude may write /tmp/claude-<uid>. CanvasTTY no longer turns Claude's own sandbox on in Auto: it follows the person's Claude settings. Base protection is unchanged and on by default.
  2. Claude Code editing keys (everything macOS: editing keys for Claude Code cards (newline, select-all, copy/cut/paste) and UTF-8 locale #145 had, fixed):
    • Enter, Shift+Enter and Command+Enter reach Claude as distinct sequences.
    • Command+A selects the whole draft, then Command+C / Command+X / Delete / typing / Paste copy, cut, delete or replace it. This runs through Claude's own Ctrl+G external-editor round trip with a bundled $VISUAL helper (src/agent-runtime/draft-editor.sh).
    • Works in Claude 2.1 background sessions (/bg, /resume, agent view) through --settings env, and inside agent isolation.
    • A localeless macOS launch gets a UTF-8 LANG, so Cyrillic no longer turns into mojibake in pbcopy/vi.
  3. Settings → Keyboard → Claude editor (macOS):
    • Rebindable keys. Defaults: submit Command+Enter, line break Enter, alternate line break Shift+Enter, select Command+A, copy Command+C, cut Command+X, delete Backspace.
    • Submit and line break act only while Claude's prompt box is on screen, so dialogs keep Enter.
    • A Claude draft keys switch at the top turns it all off. Off, keys reach Claude as they are and new sessions keep the person's own editor.
  4. Settings → Keyboard → Codex editor (macOS):
    • The keys are really rebindable now, and passed to the owned Codex frontend in CANVASTTY_CODEX_KEYBOARD.
    • Defaults match the Claude editor. The separate "alternate" and "Super" submit rows are gone.
    • Settings still at the old defaults migrate; customized submit/newline keys are kept.
    • Command+C/X/Delete act on a selected draft; selected terminal output still copies with the terminal's Copy.
    • Command+X now bypasses the Edit menu in a focused terminal, as Command+A/C/V already did. Without that, the menu's Cut swallowed it.
    • Linux and Windows keep exactly the keys and environment they had.

The Codex frontend

The Codex editor keys are read by the owned Codex frontend (the optional codex-native-tui resource from #103). Its side of this change is in mrcertis/codex-macos-tui@5fce773. A build without that frontend is unaffected: Codex cards keep the stock CLI and its keys.

Test plan

  • CI green on the fork with these commits, including windows-pipe-host: https://github.com/mrcertis/CanvasTTY/actions/runs/38469721818
  • npm test locally: all pass except "real CLIs start inside the layer", which fails the same way on clean main with the locally installed opencode 1.15.13 (JSON Parse error: Unexpected identifier "FAKE")
  • Typecheck (web + node)
  • Codex frontend: cargo test -p codex-tui --lib for canvastty, native_enter, select_all
  • Built as a signed macOS arm64 app (1.8.1 + this branch + the Codex frontend), installed and tested by hand: Claude and Codex Enter/Command+Enter, Command+A/C/X/V and Delete on a draft, Enter in permission dialogs, rebinding and restarting a card, copying selected terminal output, background agents.

🤖 Generated with Claude Code

mrcertis and others added 11 commits October 13, 2026 16:27
- Enter, Shift+Enter and Command+Enter reach Claude Code as distinct
  sequences, as they already do for Codex.
- Command+A selects the whole Claude draft (shown as a layer over the
  prompt rows); Command+C/X copy/cut it, typing, paste or Backspace
  replace or clear it. Actions run through Claude's Ctrl+G external
  editor round trip with a bundled $VISUAL helper; input is held while
  it runs and a Ctrl+G picked up late is cancelled, never opening an
  editor.
- macOS launches without any locale get LC_CTYPE=UTF-8 so pbcopy and
  friends stop turning Cyrillic into mojibake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Requests are files req.<n> claimed or cancelled by an atomic rename, so a
  Ctrl+G Claude never handles no longer blocks later actions and a request
  can't be both acted on and cancelled.
- A session's actions run one after another; input typed meanwhile keeps
  its place, and held input still settles answered prompts.
- Only a Claude launched with the helper (SessionMetadata.claudeDraft) gets
  the whole-draft keys; other Claude cards keep xterm's own Command+A.
- Command+A selects only a prompt visible on screen; a dialog in its place
  or a restarted Claude drops the selection.
- The helper keeps the draft when it cannot save a copy and ignores unknown
  requests; the helper path may not contain shell syntax.
- A localeless macOS launch gets LANG set to the system's UTF-8 locale
  (en_US.UTF-8 when the system lacks it), a name ssh can pass on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Pass the draft helper and the locale through Claude's --settings env as
  well: a background session (/bg, /resume, agent view) runs under Claude's
  supervisor and gets this launch's --settings, not its environment.
- The helper answers a request in another card's folder after its own, so a
  background session shown by another card still works; it acts only on
  Claude's own prompt file (claude-prompt-*.md), never on a file a program
  Claude runs opens (git commit, crontab -e).
- A cancel the helper cannot remove is not used up: that Ctrl+G opens the
  editor.
- Agent isolation: a Claude card's own draft folder is writable and readable
  again; the draft root is private app data, hidden from other launches.
- Command+V over a selected draft reads the clipboard when its turn comes,
  so keys pressed meanwhile stay after the paste.
- Every launch resets the card's draft queue; leftovers of an earlier run are
  removed at startup; Command+A over a dialog drops an earlier selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tor and locale

- Inside agent isolation the shared draft root is hidden, so the helper no
  longer requires it: it looks in its own card's folder, then in whichever
  other card folders it can see. The seatbelt test now runs the real helper.
- A cancel is used up only from the card's own folder: another card's
  timed-out action never swallows an ordinary Ctrl+G here.
- A plugin that sets VISUAL or EDITOR in its own --settings env keeps Ctrl+G
  (no helper), and a locale a plugin sets is not replaced in the --settings
  env that background sessions get.
- A request that cannot be claimed but is still there (also a dangling
  link) stops the helper instead of looping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The helper is a POSIX shell script for macOS Claude cards; Windows CI has no
/bin/sh to run it (spawnSync /bin/sh ENOENT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every agent outside Manual was wrapped in sandbox-exec, whose writable
list lacked /tmp/claude-<uid>. Claude Code keeps its Bash tool and
external-editor (Ctrl+G) files there whatever TMPDIR says, so every shell
call and the draft editor failed with EPERM.

- The layer now applies only to delegated agents (subagents, plugin-started
  agents) and to a CLI's contained auto; an agent the person launched runs
  with their own environment.
- Isolated Claude may write /tmp/claude-<uid>.
- CanvasTTY no longer switches Claude's own sandbox on in Auto: it follows
  the person's Claude settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cope

- Settings -> Agents -> Claude draft keys (macOS, on by default). Off, a
  Claude card no longer intercepts Command+A and its follow-up keys, new
  Claude sessions start without CanvasTTY's VISUAL helper, and draft
  actions do nothing.
- Docs and changelogs: the isolation layer wraps subagents, plugin-started
  agents and a CLI's contained auto only; Claude's own sandbox follows the
  person's Claude settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A "Claude editor" group (macOS) next to the Codex editor, headed by the
Claude draft keys switch (moved there from Agents). Defaults: submit
Cmd+Enter, line break Enter, alternate line break Shift+Enter, select
Cmd+A, copy Cmd+C, cut Cmd+X, delete Backspace (forward Delete too).

- The submit and line-break keys act only while Claude's prompt box is on
  screen; dialogs and other screens keep Enter. Bindings apply at once in
  open cards; the switch off removes all Claude key handling.
- claude* bindings form their own conflict context; submit, both line
  breaks and select are always bound, copy, cut and delete can be off.
- Pasting over a selected draft follows the terminal Paste key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Expose Codex submit, newline, select, copy, cut and clear bindings in keyboard settings while preserving customized bindings and non-macOS behavior. Forward the actual launch keymap to renderer metadata on create, restart and restore so Command+X reaches the native editor. Cover settings migration, menu routing and launch lifecycle with regression tests.
The Claude scratch folder /tmp/claude-<uid> exists only on macOS and
Linux; on Windows the check compared POSIX paths and failed the
windows-pipe-host job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The settings store's defaults follow the host, so on the Linux and
Windows CI runners the migration tests got the old keys and failed.
They now pass the macOS defaults explicitly.

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

Copy link
Copy Markdown
Contributor

Thanks for the follow-up. We ran #146 together with our open PRs (#147, #148, #133) and with real sandbox-exec. A few points:

1. Claude draft folder under isolation (still open from #145). It now only matters for delegated Claude cards, which are the ones still inside the layer, but it crosses the layer boundary there.

  • The isolated agent can rm -rf its draft folder and put a symlink in its place: ownWritable is a subpath rule, re-allowed by the ownHidden allow in seatbelt.ts. environment() (ClaudeDraftEditor.ts L81) removes the folder at launch, and roundTrip() L191 recreates it with mkdirSync(..., { recursive: true }), which follows a link. The main process then writes staged.N/req.N/cancel.N and removes done.N/draft.N wherever the link points.
  • L213-214 lstatSync(draft) → readFileSync(draft) follows a link swapped in between. The helper side sees done.N appear, so it knows when to swap. The file then lands on the clipboard.

Suggested change (with tests; all pass on top of this PR):

// environment(): create the folder before the agent starts
rmSync(this.directory(sessionId), { recursive: true, force: true });
mkdirSync(this.options.root, { recursive: true, mode: 0o700 });
mkdirSync(this.directory(sessionId), { mode: 0o700 });

// roundTrip(): instead of mkdirSync(directory, { recursive: true, mode: 0o700 })
if (!ownFolder(directory)) return false;
// ...and instead of lstatSync + readFileSync(draft)
const text = readDraft(draft);

function ownFolder(directory: string): boolean {
  try {
    const stat = lstatSync(directory, { throwIfNoEntry: false });
    if (!stat) { mkdirSync(directory, { mode: 0o700 }); return ownFolder(directory); }
    return stat.isDirectory() && !stat.isSymbolicLink() && (typeof process.getuid !== "function" || stat.uid === process.getuid());
  } catch { return false; }
}
function readDraft(path: string): string {
  let fd: number;
  try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch { return ""; }
  try {
    const stat = fstatSync(fd);
    if (!stat.isFile() || stat.nlink !== 1 || stat.size > 16 * 1024 * 1024) return "";
    return readFileSync(fd, "utf8");
  } catch { return ""; } finally { closeSync(fd); }
}

In isolationPaths return pinnedFolders: all(ownWritable). In seatbeltProfile, right after the ownHidden allow (before it, that allow re-opens it):

if (paths.pinnedFolders.length > 0) {
  lines.push(`(deny file-write-unlink${paths.pinnedFolders.map((path) => ` (literal ${quote(path)})`).join("")})`);
}

With real seatbelt the agent can still create and delete files inside the folder, but rm -rf of the folder and ln -s in its place are refused. On Linux the writable bind is already a mount point. One existing test expects the folder to be absent after environment(); it becomes "empty".

2. /tmp/claude-<uid>: the EPERM is real, but the layer can stay. Claude Code takes its scratch root from CLAUDE_CODE_TMPDIR, falling back to /tmp, not from TMPDIR; so on main every isolated Claude (subagents too) gets EPERM. Making /tmp/claude-<uid> writable fixes that, but it is shared by all of the person's Claude sessions. With real seatbelt, an isolated agent can then rewrite another session's task output, which that session reads back. A narrower fix that keeps the layer for person-launched Auto:

// AgentIsolation.wrap, next to TMPDIR (L208)
const env = { ...launch.env, TMPDIR: `${temp}/`, TMP: temp, TEMP: temp, CLAUDE_CODE_TMPDIR: temp, GIT_TEMPLATE_DIR: gitTemplate, [ISOLATION_ENV]: ISOLATION_NOTE };

and no /tmp/claude-<uid> grant (isolationPaths L269, plus the uid input). Claude then keeps claude-<uid> inside the launch's own temp folder, which is already writable. A seatbelt test that does what Claude does (${CLAUDE_CODE_TMPDIR:-/tmp}/claude-$(id -u)/…) passes. This is now in #148 as its own commit (3f62b7e1, "give isolated Claude Code its own temp folder"), with a real seatbelt test and a bubblewrap check on Linux; it fixes delegated Claude subagents on main too. I have not run a live Claude session inside the layer with it.

3. Scope of the policy change. If the EPERM was the main reason, would you consider keeping the layer for person-launched non-Manual agents? Without it:

  • Plan loses its OS-level read-only project.
  • YOLO runs the CLIs' dangerous flags with only hooks. That includes canvastty-control create, which defaults to YOLO and is not delegated; the "inside agent isolation YOLO still cannot write outside the project" line in agent-orchestration.md no longer holds there.
  • Grok Auto has neither a sandbox nor hooks.
  • With an accounts plugin, the other accounts' homes under plugin-data are readable by a person-launched agent. Base protection's private list does not include plugin-data; fix(isolation, safety): keep CanvasTTY's private data out of agents' reach #148 now adds it (b4b69198), keeping the card's own selected account home readable.
  • Removing CLAUDE_SANDBOX_SETTINGS also removes Claude's own sandbox where the layer is off or unavailable.

If the narrower scope is intended, a launcher/doc note per profile would help.

4. Merge order with #147 / #148 / #133.

Small: the ru label "Редактор Claude" trips our #149 hint test (we will allow "Claude" there).

@howdeploy

Copy link
Copy Markdown
Owner

Thanks for working on this. We need the keyboard fixes, and I understand that Claude's background sessions make this more complicated than fixing a temporary-directory path. Could we get the shortcuts working while preserving CanvasTTY's isolation guarantees?

After reviewing the current head (3df48bf) and the connected launch/runtime code, these are the concerns:

  1. The isolation change is broader than the keyboard fix. In AgentIsolation.ts:142–148, directly launched Claude sessions in Auto, Plan and YOLO no longer get CanvasTTY's OS sandbox. Removing the injected Claude sandbox settings also leaves its native sandbox dependent on the user's configuration. Plan loses the OS-level read-only boundary; YOLO can execute outside the project unless another sandbox is configured. Hooks do not provide an equivalent OS boundary. This policy change also affects other providers.

  2. Background sessions can retain another card's identity and capabilities. TerminalManager.ts:1825–1832 forwards the editor environment and locale through --settings, but not the per-session browser/runtime/orchestration credentials. Our hooks and MCP helpers still depend on those environment variables. With inherited, still-valid credentials from card A, a session displayed in card B can authenticate as A; revoked credentials instead break the integration. This is a compatibility problem in the existing integration, not something introduced solely by this PR, but removing isolation does not fix it. Closing a card also revokes credentials and removes resources that its background session may still need.

  3. The draft helper can consume another card's request. draft-editor.sh:32 scans sibling card directories. If A has a pending cut and B invokes the external editor, B's helper can claim A's request and clear B's draft. Atomic renaming prevents duplicate claims, but does not establish which Claude session owns the request. This is a code-derived scenario, not a live reproduction.

  4. The draft-file boundary still needs hardening. As noted in the earlier review, ClaudeDraftEditor.ts:191–214 recreates an agent-writable directory and uses lstat followed by a path-based read. Directory replacement and symlink races can redirect the unsandboxed main process's filesystem operations, including reading a file into the clipboard. A separate path check alone would not close the race.

  5. The writable /tmp/claude-<uid> grant crosses session boundaries. It exposes shared scratch files to isolated sessions. A per-launch CLAUDE_CODE_TMPDIR addresses that particular problem, but not the background supervisor.

One important qualification on the supervisor issue: static inspection of Claude 2.1.296's Linux binary shows its socket under /tmp/cc-daemon-<uid>/<config-hash>/control.sock, independently of CLAUDE_CODE_TMPDIR. CanvasTTY's macOS socket allowlist does not include that path, so a connection failure is expected there; we have not reproduced a macOS sandbox escape. Linux's current bubblewrap setup leaves that socket path visible, so connecting to an already-running unsandboxed supervisor needs verification. Passing environment variables cannot transfer a client's sandbox to an existing daemon worker.

Possible ways forward:

  • Separate the keyboard fixes from the isolation-policy change; keep private scratch storage and harden the draft exchange, with explicit session ownership instead of scanning other cards.
  • As an interim compatibility mode, disable agent view for isolated Claude launches using CLAUDE_CODE_DISABLE_AGENT_VIEW=1, with the limitation made clear. This would be a temporary feature tradeoff, not full background-session support.
  • For full support, bind credentials and resource lifetime to the executing Claude session, separately from the card displaying it. Forward the necessary environment through a private 0600 settings file, never capability tokens in inline argv. The existing inline-settings merge needs to be accounted for so hooks are preserved.
  • Investigate a supervisor per isolation boundary. Claude documents separate supervisors for separate CLAUDE_CONFIG_DIR values, but settings/auth/history and sandbox ownership would need careful handling; this is not a drop-in fix.

References: background sessions and supervisor behavior, environment variables.

Which approach do you think is practical? Can we fix the draft routing and filesystem issues and retain isolation in this PR, with full agent-view integration handled separately? If disabling agent view is too restrictive, do you see another way to keep the background worker inside the correct sandbox and bound to the correct CanvasTTY session? Happy to consider a smaller solution if we are missing an existing Claude mechanism.

This follow-up is based on source review, documentation and static binary inspection; no live Claude/sandbox tests were run for it.

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.

3 participants