Skip to content

macOS keychain write hangs when the CLI has a controlling terminal #1

Open

Description

@aheckler

Summary

On macOS, the CLI cannot save its credential when the process has a controlling terminal. The keychain write hangs for 30 seconds, then fails, and nothing is stored. Every subsequent run repeats it, because the credential never lands.

This makes spacefast mcp unusable as a stdio MCP server started from a terminal. While it hangs it also writes a password prompt into the host application's UI and steals its terminal input.

Cause

The macOS keychain write runs:

/usr/bin/security add-generic-password -s "Spacefast CLI" -a default -U -w

and writes <value>\n<value>\n to the child's stdin (credential-store.ts, macOsKeyring().write(), spacefast 0.2.2).

Passing the secret on stdin rather than in argv is the right instinct, since argv is readable from ps. But security only reads the password from stdin when the process has no controlling terminal. When one exists it opens /dev/tty directly, ignores stdin, prints password data for new item: to the terminal, and blocks. The piped value is discarded. After KEYRING_COMMAND_TIMEOUT_MS (30s) the CLI kills the child and throws "Credential keyring command timed out."

The read path is unaffected. In find-generic-password -w, -w means "print the password" and takes no value.

Reproduction

Works, no controlling terminal:

printf 'hunter2\nhunter2\n' | /usr/bin/security add-generic-password -s sf-test -a default -U -w
security find-generic-password -s sf-test -w   # hunter2
security delete-generic-password -s sf-test

Hangs, controlling terminal present, stdin still a pipe:

script -q /dev/null /bin/sh -c 'printf "hunter2\nhunter2\n" | /usr/bin/security add-generic-password -s sf-test -a default -U -w'
security find-generic-password -s sf-test -w   # not found

The second command prints password data for new item: and blocks until killed.

Impact in a terminal UI host

Seen with npx -y spacefast mcp configured as a stdio MCP server in Claude Code:

  1. The server inherits the host's controlling terminal.
  2. The keychain write blocks on /dev/tty.
  3. password data for new item: is printed into the host's input area, bypassing the pipes the host set up for the child, because security writes it to /dev/tty directly.
  4. The blocked security process now competes with the host for terminal input. It consumes bytes from the terminal's mouse tracking and focus reporting sequences, including the leading ESC, so the host receives orphaned fragments such as [<35;82;37M and [I and renders them as literal text in its input box.
  5. After 30 seconds the MCP connection times out.

From the host's side this looks like a random hang plus corrupted UI, with no indication that the CLI is waiting for a password.

Suggested fix

Feed the command to security -i on stdin instead. That keeps the secret out of argv, which is the goal of the current approach, and never involves /dev/tty:

printf 'add-generic-password -U -a "%s" -s "%s" -X "%s"\n' "$account" "$service" "$hex" | /usr/bin/security -i

-X takes the value as hex, which sidesteps quoting and binary-safety concerns. One caveat worth guarding: security -i truncates input lines at 4096 bytes, so check the assembled line length and fall back to passing the value in argv above that threshold.

Environment

  • spacefast 0.2.2 (npm)
  • macOS 26.6.2 (Darwin 25.6.0), arm64
  • Node v24.20.0, npm 12.0.2
  • Host: Claude Code 2.1.258 in Ghostty

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions