0096: Terminal shells start in the login session
On this page
Status: accepted
Context
leashd is a launchd daemon (0003): launchd opens ports 80 and 443 as root and hands them to it, so it runs in launchd's system domain, outside your login session. Every shell it started for a site terminal (0026) was outside it too. In those shells the login keychain was out of reach ("User interaction is not allowed"), so claude auth status said signed out while Terminal.app said signed in, and git's osxkeychain helper found nothing. pbcopy and pbpaste did nothing, and SSH_AUTH_SOCK wasn't set. Tools that keep their sign-in in a file, such as gh and Codex, worked. Running the shell in your session from leashd (launchctl asuser) needs root, which invariant 1 rules out, and moving leashd itself into your session would need a root helper to open the ports.
Decision
- The app starts the shell. While the app runs (it stays in the menu bar with its windows closed), it keeps a connection to
run/terminal-host.sock, a socket leashd listens on (0600, and leashd refuses a peer that isn't running as you). To open or restart a terminal, leashd sends the shell, its arguments, folder, environment and size. The app opens the PTY with the sameportable-ptycode leashd uses, starts the shell in it, and sends the PTY's controlling descriptor back withSCM_RIGHTS, then closes its own copy. The shell gets the app's environment, which comes from your session and includesSSH_AUTH_SOCK, plus everything leashd adds for the site. - leashd keeps the PTY. Reading, typing, resizing (
TIOCSWINSZ), activity (tcgetpgrp), replay, attention and saving are unchanged. A received descriptor is marked close-on-exec so programs leashd starts don't inherit it. Closing a terminal hangs up the shell's process groups, as before. - Exit codes come from the app. The shell is the app's child, so the app waits for it and reports its exit code. If the app goes away first, the shell keeps running because leashd holds the PTY, and its end is the end of its output, with no exit code.
- leashd starts the shell itself when the app isn't connected, when it can't start the shell, or when it doesn't answer within 10 seconds. That covers Windows (where the app starts leashd as an ordinary process), tests and a leashd run on its own.
- Frames. Each message is a 4-byte big-endian length and JSON (
openfrom leashd;opened,failedandexitedfrom the app), at most 1 MiB. Receiving descriptors usesrustixand sending usesnix. Both are safe APIs, already in the build, so no newunsafemodule is needed.
Alternatives
- A login item running all the time (a LaunchAgent) as the shell starter. It would work with the app closed and could also start Claude Code for agent handovers. It costs another background process, its registration and removal, and a socket that starts commands as you, for terminals that only the app can open anyway.
- Shells kept in the app, with output streamed to leashd. This undoes 0026: terminals would end with the app.
- Unlocking the keychain from the shell, or a long-lived Claude Code token in the environment. The first needs your password. The second fixes one tool, and Short Leash would be keeping someone else's sign-in.
Consequences
- A site terminal behaves like one in any terminal app:
claudeis signed in,pbcopyworks, and so do git's keychain helper and ssh-agent. - New terminals and restarts depend on the app being open, as they already did in practice. Without the app they still start, outside your session.
- A shell that ends while the app is closed shows as ended without an exit code.
- Anything that can reach leashd's API as you can now get a shell in your login session. Before, that shell ran outside your session. Processes running as you, including ones you start from any terminal, already have that session.