Skip to content
C.W.K.
Stream
Lesson 02 of 05 · published

The Keychain ssh Cannot Read

~14 min · macos, launchd, keychain, ssh, debugging

Level 0Loose Note
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Three Launches, Three Native Gates

The helper from the last lesson worked on its first day. The worker did not. On 2026-10-08 the engine tried to start a Claude Code session on the laptop three times, and each attempt stopped at a different gate that belongs to the native CLI, not to Coop.

  1. An untrusted folder. The first launch exited with "Workspace not trusted. Run claude in <folder> once and accept the trust prompt." Dad had only ever opened that repository on the laptop through the Desktop app, and the CLI keeps its own trust per folder.
  2. A login. The second launch started a session that stopped at the CLI's own "Login expired · Please run /login". The CLI's credentials are separate from the Desktop app's, so Dad logged the CLI in on that Mac.
  3. The same login, again. Two minutes after Dad's /login, the third launch stopped at exactly the same line.

The first two are native gates, and the engine handles each by its shape. A launch the CLI refuses outright, like the untrusted folder, starts nothing, so the assignment shows the native reason and tries the launch again every thirty seconds inside its setup window; Dad accepts the trust prompt once on that Mac and the next attempt goes through. A login gate has a different shape: the session starts, then stops. Discovery reads the background job's state, sees it blocked, and fails the assignment at once with the native reason, because no amount of retrying passes that gate. Dad resolves it on that Mac and assigns again. The engine never answers a trust or login prompt itself. Those gates exist to make a human decide something, and a coordinator that clicks through them has quietly become the human.

The third one was not a gate. It was a measurement waiting to happen.

What the Measurement Said

Dad had just logged in. The CLI said the login had expired. Both can be true only if the process asking is not allowed to see what the login stored, and that is what the numbers showed:

  • In a process born in an ssh session, security exits 36 with "user interaction is not allowed": it cannot read the user's login keychain.
  • claude auth status said loggedIn false over ssh, and loggedIn true from a job in the user's gui/<uid> launchd domain. Same Mac, same user, same minute.

So the credential was fine. The session the process was born into was wrong. Claude Code keeps its login in the keychain, and an ssh-born process lives outside the security context where that keychain is readable.

That also explained a fact nobody had needed to explain: office's own launches always worked. Coop's runtime on office is a launchd agent in the GUI domain, so everything it spawns is born there already. The laptop launches were the first ones born anywhere else. Codex, for contrast, keeps its login in a plain file under its home directory, which ssh reads without complaint. This particular wall is Claude's alone.

The Fix: Borrow the GUI Session for One Job

Commit 875fddf teaches the helper to run the launch where the keychain is readable. Instead of spawning the command directly, launch_in_gui:

  1. writes a one-shot launchd job (a property list with a unique label, the command, its working directory, RunAtLoad and AbandonProcessGroup, and two files for its output);
  2. loads it with launchctl bootstrap gui/<uid>, which starts it at once in the user's GUI session;
  3. polls launchctl print every half second until the job reports it is no longer running and gives its exit code;
  4. unloads it with bootout, reads the tail of its output, and deletes every temporary file.

AbandonProcessGroup is the quiet hero. When a launchd job exits, launchd normally kills whatever is left in the job's process group. The launch command is claude --bg, whose entire point is to leave a background session running after the command returns. Without that key, the session would die with the job that started it.

And if no GUI session takes the job (nobody is logged in at that Mac, say), bootstrap fails and the helper falls back to a direct spawn. The answer says which context ran, gui or direct, with the reason the GUI path was unavailable, and the journal records it. A fallback that leaves no trace would turn the next "Login expired" back into a mystery.

The First Canary Nobody Typed

With that fix running on office (runtime 875fddf) and Claude Code 2.1.293 on the laptop, canary todo #349 was assigned to the laptop from the workspace. The launch ran as a GUI-domain job; discovery found the new session with its Remote Control bridge; the invitation went out; the session accepted; it reported done with the hostname, fleet label and checkout HEAD the todo asked for. Assignment to report took under a minute. The record says it plainly: no one typed on the laptop.

Notice what that canary proves and what it does not. It proves a fresh session on another Mac can be started, found, admitted and heard from. It does not retroactively prove the first two gates are gone: trust stays per folder and login stays per Mac, and both still stop the next new repository or the next new laptop. Invariant 10 again: each claim gets its own proof.

A credential belongs to the session that runs you, not to the user you are. When a command works in your terminal and fails as a service, stop debugging the command. Compare the two birthplaces.

Code

From the engine's remote_helper.py: the launch, as a one-shot job in the GUI domain (trimmed excerpt)·python
def launch_in_gui(argv, cwd, timeout):
    """Run the launch as a one-shot launchd job in this user's GUI session.

    A process born in an ssh session cannot read the user's login keychain
    (`security` exits 36, "user interaction is not allowed"), so Claude Code
    started there is logged out even right after Dad's /login: on 2026-10-08
    `claude auth status` said loggedIn false over ssh and loggedIn true from a
    job in `gui/<uid>`. Office's own launches already run in that domain,
    because Coop's runtime is a GUI launchd agent. The job runs the command and
    exits; AbandonProcessGroup keeps the background session alive after it.
    """
    ...
    target = "gui/%d/%s" % (uid, label)
    try:
        bootstrap = subprocess.run(["/bin/launchctl", "bootstrap", "gui/%d" % uid, plist], capture_output=True, text=True, timeout=15)
        if bootstrap.returncode:
            return None, (bootstrap.stderr or bootstrap.stdout).strip()[:300]
        deadline = time.time() + float(timeout)
        code = None
        while time.time() < deadline:
            printed = subprocess.run(["/bin/launchctl", "print", target], capture_output=True, text=True, timeout=10)
            finished, code = job_state(printed.stdout)
            if finished:
                break
            time.sleep(0.5)
        else:
            subprocess.run(["/bin/launchctl", "bootout", target], capture_output=True, timeout=15)
            raise subprocess.TimeoutExpired(argv[0], timeout)
        subprocess.run(["/bin/launchctl", "bootout", target], capture_output=True, timeout=15)
        return {"returncode": 0 if code is None else code, "stdout": _read_tail(out), "stderr": _read_tail(err),
                "context": "gui"}, None
    finally:
        ...  # remove the plist, the output files and the temporary directory
From the engine's remote_helper.py: the fallback that says which context ran (trimmed excerpt)·python
def launch(cwd, argv, timeout, context="gui"):
    ...
    command = [executable] + argv[1:]
    fallback = None
    if context == "gui" and os.path.exists("/bin/launchctl"):
        result, fallback = launch_in_gui(command, cwd, timeout)
        if result is not None:
            return result
    completed = subprocess.run(command, cwd=cwd, stdin=subprocess.DEVNULL, capture_output=True,
                               text=True, timeout=float(timeout), start_new_session=True)
    return {"returncode": completed.returncode, "stdout": completed.stdout[-OUTPUT_LIMIT:],
            "stderr": completed.stderr[-OUTPUT_LIMIT:], "context": "direct",
            **({"gui_unavailable": fallback} if fallback else {})}

External links

Exercise

Pick a CLI on your Mac that keeps its login in the keychain (Claude Code's claude auth status is one, if you use it). Run its status command three ways: in your terminal, over ssh localhost, and as a one-shot launchd job you bootstrap into gui/$(id -u) with its output sent to a file. Write down the three answers. If ssh disagrees with the other two, you have reproduced this lesson's measurement on your own machine.
Hint
A minimal job needs Label, ProgramArguments, RunAtLoad and StandardOutPath. Bootstrap it with launchctl bootstrap gui/$(id -u) job.plist, read the output file once launchctl print gui/$(id -u)/<label> no longer shows it running, then launchctl bootout gui/$(id -u)/<label>. Remote Login must be enabled in System Settings for the ssh leg.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.