# Headless second-Mac install

> SSH-only install on a sink with no GUI session: degraded-mode fallback, the one-password Safe Storage open, and the `wizard set-keychain-access` upgrade path.

- Repository: mvanhorn/agentcookie
- GitHub: https://github.com/mvanhorn/agentcookie
- Human docs: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae
- Complete Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/llms-full.txt

## Source Files

- `docs/quickstart-beta.md`
- `docs/runbook-v0.13-one-password-keychain.md`
- `internal/cli/login_password.go`
- `internal/cli/wizard_keychain.go`
- `scripts/install-beta.sh`

---

---
title: "Headless second-Mac install"
description: "SSH-only install on a sink with no GUI session: degraded-mode fallback, the one-password Safe Storage open, and the `wizard set-keychain-access` upgrade path."
---

`agentcookie wizard install --as sink` runs end-to-end over SSH with no GUI session attached. On macOS 14+ it resolves one of three terminal states — a TTY plus a login password, a fully non-interactive shell with `AGENTCOOKIE_LOGIN_PASSWORD` exported, or neither — and lands either **universal** Chrome cookie delivery (any unmodified cookie CLI on the sink reads the synced Default profile) or a working **degraded** sink (sidecar + adapter delivery only, no Chrome Safe Storage access). Either way the install completes, the LaunchAgent is bootstrapped, and the upgrade from degraded to universal is the single command `agentcookie wizard set-keychain-access`.

## Decision matrix

| Install context | What runs at the keychain step | Result in `sink.yaml` | `agentcookie doctor` → Cookie delivery |
|---|---|---|---|
| SSH TTY, operator types password | Inline partition-list set with the entered password | `skip_chrome_sqlite` absent, `delivery: universal` | `universal` |
| No TTY, `AGENTCOOKIE_LOGIN_PASSWORD=…` exported | Inline partition-list set with the env password | Same as above | `universal` |
| No TTY, no env password | Open is skipped, install **downgrades non-fatally** | `skip_chrome_sqlite: true`, `cdp.enabled: true`, `delivery: degraded` | `degraded` (INFO) |
| `--skip-chrome-sqlite` explicit | Keychain step is skipped | `skip_chrome_sqlite: true`, `delivery: degraded` | `degraded` (INFO) |
| `--write-chrome-sqlite` explicit, open fails | WARNING printed, intent honored | Universal config rendered anyway | `partial` (WARN) until `set-keychain-access` runs |

The downgrade path is the default safety net: rendering a universal `sink.yaml` on a box where agentcookie cannot read the Chrome Safe Storage key would leave a daemon that fails to start, so the wizard collapses to degraded and prints the upgrade command instead.

## The one-password Safe Storage open

The keychain open the wizard performs is a single `security` invocation:

```sh
security set-generic-password-partition-list \
  -S "apple-tool:,apple:,teamid:<YOUR_TEAM>" \
  -k "<login-password>" \
  -s "Chrome Safe Storage" -a Chrome
```

The team ID is resolved from the running `agentcookie` binary's own code signature (`chrome.BinaryTeamID`). The partition list is composed by `chrome.TeamPartitionList(team)` and is exactly `apple-tool:,apple:,teamid:<TEAM>`. No `delete-generic-password` and no `add-generic-password` runs — the Safe Storage item's encryption key value is left untouched, so existing Chrome cookies stay decryptable.

<Info>
macOS requires the login keychain password to modify an existing item's access (`SecKeychainItemSetAccessWithPassword`). There is no headless bypass. One terminal password entry (no GUI dialog) is the floor; zero password entries is not achievable on this path.
</Info>

### Password sources

<ParamField body="AGENTCOOKIE_LOGIN_PASSWORD" type="env var">
When set and non-empty, supplies the login password without a prompt. Read by `internal/cli/login_password.go::acquireLoginPassword`. Passed straight to `security -k`, never logged, never persisted to disk. Briefly visible in `ps` for the lifetime of the `security` call; that is a `security -k` limitation, not an agentcookie choice.
</ParamField>

<ParamField body="TTY prompt" type="interactive">
When the env var is absent and stdin is a character device (PTY-allocated SSH session), the wizard prompts once with echo disabled: `macOS login password (grants Chrome Safe Storage access; entered once, never stored):`. Terminal echo is toggled via `/bin/stty -echo`.
</ParamField>

<ParamField body="neither available" type="error">
Stdin is not a TTY and the env var is unset. `acquireLoginPassword` returns `errNoInteractivePassword`. In default install mode this triggers the degraded downgrade described below.
</ParamField>

### What the partition list covers

- `apple-tool:` — `/usr/bin/security`. Read path for `yt-dlp`, `pycookiecheat` via the `security` CLI, `browser_cookie3`, `gallery-dl`.
- `teamid:<YOUR_TEAM>` — Developer-ID-signed binaries from the same signing team, reading via `SecItemCopyMatching`. Covers the agentcookie daemon's CGO read path and any tool you sign with the same team.
- `apple:` — Apple-signed system binaries.

A truly arbitrary **unsigned** CGO tool (a locally compiled `kooky`/`rookie` binary calling `SecItemCopyMatching` directly, not signed with your team) is not covered by `teamid:` and does not go through the `security` CLI. Sign it with your team, add it to a trust list with `--extra-binary`, or fall back to `--any-app`.

## Three install paths

<Tabs>
<Tab title="SSH with TTY (the normal path)">

```sh
ssh sink 'agentcookie wizard install --as sink \
    --peer <source-host> \
    --code <pair-code> \
    --pair-url http://<source-host>:9998/pair'
```

The wizard prompts for the macOS login password once on the SSH TTY and lands universal delivery. `agentcookie doctor` reports `Cookie delivery: universal: real Default profile written and Chrome Safe Storage readable; any unmodified cookie CLI works here`.

</Tab>
<Tab title="Fully non-interactive (CI / automation)">

```sh
ssh sink "AGENTCOOKIE_LOGIN_PASSWORD='…' agentcookie wizard install --as sink \
    --peer <source-host> \
    --code <pair-code> \
    --pair-url http://<source-host>:9998/pair"
```

The env var supplies the password with no prompt. Same universal result as the TTY path. Use a shell that does not log command arguments; do not commit the password to a shared CI log.

</Tab>
<Tab title="No password available (degraded fallback)">

```sh
ssh sink 'agentcookie wizard install --as sink ...'   # no TTY, no env var
```

The install **does not fail**. The wizard:

1. Prints `WARNING universal keychain open did not complete: no macOS login password available…`.
2. Sets `skip_chrome_sqlite: true`, `cdp.enabled: true`, `cdp.profile_dir: ~/.agentcookie/chrome-profile`, and `delivery: degraded` in `sink.yaml`.
3. Installs the sink LaunchAgent so syncs still land.
4. Prints the upgrade one-liner: `agentcookie wizard set-keychain-access`.

</Tab>
</Tabs>

## Degraded mode behavior

When `skip_chrome_sqlite: true` is rendered, the sink daemon **never** reads Chrome Safe Storage. Synced cookies land in three places that do not need the key:

```text
~/.agentcookie/
├── cookies-plain.db           # v0.8 plaintext sidecar; AGENTCOOKIE_PLAIN_COOKIES path
├── secrets/<cli>/secrets.env  # secrets-bus drop, mode 0600
└── chrome-profile/            # CDP-managed Chrome profile owned by agentcookie
    └── Default/Cookies        # Chrome encrypts this with its OWN Safe Storage key on
                               # first launch; agentcookie never touches that key
```

Per-CLI adapter session files (`v0.11`) are also rewritten after each sync, so the five built-in-adapter PP CLIs (`instacart-pp-cli`, `airbnb-vrbo-pp-cli`, eBay, Pagliacci, table-reservation-goat) keep working without env vars or Keychain prompts.

To launch the agentcookie-owned Chrome profile on the sink and see synced sites logged in:

```sh
open -a "Google Chrome" --args --user-data-dir="$HOME/.agentcookie/chrome-profile"
```

The default Chrome profile at `~/Library/Application Support/Google/Chrome/Default` is left untouched in degraded mode.

<Warning>
Pass `--no-cdp` to the wizard if you do not want any Chrome touched on the sink. Sidecar plus adapter delivery remain the cookie paths. The setting is forwarded by `install-beta.sh --no-cdp` and recognized by `wizard install` as `wizardNoCDP`.
</Warning>

## Upgrade path: `wizard set-keychain-access`

`agentcookie wizard set-keychain-access` performs the same inline one-password partition-list open used by `wizard install`, with no LaunchAgent dispatch and no GUI prompt. Run it once a login password becomes available:

<Steps>
<Step title="Open Chrome Safe Storage to apple-tool + teamid">

```sh
ssh sink 'agentcookie wizard set-keychain-access'
# or non-interactively:
ssh sink "AGENTCOOKIE_LOGIN_PASSWORD='…' agentcookie wizard set-keychain-access"
```

Expected output: `Chrome Safe Storage partition set and verified readable (apple-tool:,apple:,teamid:<TEAM>); universal delivery enabled with no GUI prompt`.

</Step>
<Step title="Flip sink.yaml from degraded to universal">

If the previous install downgraded to degraded, `sink.yaml` still carries `skip_chrome_sqlite: true`. Re-run the wizard with `--write-chrome-sqlite --force` to rewrite it:

```sh
ssh sink 'agentcookie wizard install --as sink \
    --peer <source-host> --write-chrome-sqlite --force'
```

`writeYAMLIfMissing` honors `--force` to overwrite the existing `sink.yaml`; without it the degraded values stay. The keychain step is now a no-op (the partition is already set) and resolves to universal.

</Step>
<Step title="Verify">

```sh
agentcookie doctor
security find-generic-password -s "Chrome Safe Storage" -a Chrome -w
```

Expect `Cookie delivery: universal …`. The `security` read can still report `User interaction is not allowed` (`-25308`) from a fresh bare-SSH session if the login keychain has re-locked; that is a session-lock artifact, not a partition failure. The sink daemon reads it in the GUI session.

</Step>
</Steps>

## Wizard flags that change headless behavior

<ParamField body="--skip-chrome-sqlite" type="bool" default="false">
Force degraded mode even on a box with a TTY. `sink.yaml` is written with `skip_chrome_sqlite: true`, `cdp.enabled: true` (unless `--no-cdp`), `delivery: degraded`. The keychain step is skipped entirely; there is no 60-second strategy-loop timeout in this mode.
</ParamField>

<ParamField body="--write-chrome-sqlite" type="bool" default="false">
Force universal mode and **honor it even if the keychain open fails**. Unlike the default path, this does NOT silently downgrade. The wizard renders universal config, prints a clear WARNING if the open did not complete, and points at `agentcookie wizard set-keychain-access` for later.
</ParamField>

<ParamField body="--no-cdp" type="bool" default="false">
Sidecar + adapter-only mode. Disables the CDP injection into `~/.agentcookie/chrome-profile`. Useful when the sink must not run Chrome at all.
</ParamField>

<ParamField body="--skip-keychain-access" type="bool" default="false">
Render universal `sink.yaml` but never attempt the keychain open. Operator explicitly defers the grant. Cookie CLIs prompt on first read until `wizard set-keychain-access` runs.
</ParamField>

<ParamField body="--skip-keychain-prompt" type="bool" default="false">
Same effect as `--skip-keychain-access` for the keychain step. Auto-set by `install-beta.sh` when `! [[ -t 0 ]]` on a sink install.
</ParamField>

<ParamField body="--any-app" type="bool" default="false">
Re-run with `wizard set-keychain-access --any-app` to recreate the Chrome Safe Storage item with `-A` (any application). Preserves the existing key value (`safeStoragePasswordFunc` is read first; the strategy refuses to delete if the read fails). Covers arbitrary unsigned CGO tools but is GUI-prompt-prone and security-broad — only on a dedicated sink.
</ParamField>

<ParamField body="--recreate" type="bool" default="false">
Opt back into the legacy v0.12 LaunchAgent-driven `delete-and-recreate-with-T` trust-list strategy chain. Retained for the unsigned-CGO long tail.
</ParamField>

## install-beta.sh auto-detection

`scripts/install-beta.sh --as sink` wraps the wizard for end users. On a no-TTY shell it auto-flips into headless-safe mode:

```sh
if [[ -z "$SKIP_KEYCHAIN_PROMPT" ]] && [[ "$ROLE" == "sink" ]] && ! [[ -t 0 ]]; then
  SKIP_KEYCHAIN_PROMPT="1"
fi
```

Forwarded to the wizard as `--skip-keychain-prompt`, so the install never blocks on a GUI prompt that no one is there to dismiss. The script also forwards `--skip-chrome-sqlite`, `--write-chrome-sqlite`, `--no-cdp`, and repeatable `--extra-binary <path>` from its own flags to the wizard.

## Verification

```sh
agentcookie doctor
```

The fourteenth check, `Cookie delivery`, reports one of these states (see `checkCookieDeliveryWith` in `internal/cli/doctor.go`):

| Severity | Detail | When |
|---|---|---|
| `OK` | `universal: real Default profile written and Chrome Safe Storage readable; any unmodified cookie CLI works here` | `skip_chrome_sqlite=false`, single Safe Storage item, key probe returns >0 bytes |
| `INFO` | `degraded: writes a separate profile; only agentcookie-aware tools work` | `skip_chrome_sqlite=true` (intentional headless config) |
| `INFO` | `universal config; the Chrome Safe Storage key can't be verified from this session because the login keychain is locked (expected over SSH)` | Universal config, probe returns `errSecInteractionNotAllowed` (locked-keychain false negative) |
| `WARN` | `race: N Chrome Safe Storage keychain items exist …` | Duplicate-item race; remediation is `wizard set-keychain-access` |
| `WARN` | `partial: real Default profile written but the Chrome Safe Storage key has not been granted to cookie readers` | Universal config, key not readable, not locked |

`agentcookie doctor --json` emits the same `DoctorReport` envelope for agent consumers; exit code is `1` when any check is `FAIL`.

## Duplicate-item race

If a sink previously ran in degraded mode with CDP injection, the injector relaunched Chrome and Chrome recreated its **own** Chrome Safe Storage keychain item. The keychain then holds more than one item, and the partition set on one while a reader hits another. `agentcookie doctor` flags the race and `wizard set-keychain-access` now **collapses duplicates to one before granting access**.

The converge step (`convergeSafeStorageToOneItem` in `internal/cli/wizard_keychain.go`):

1. Counts Safe Storage items via `chrome.CountSafeStorageItems`.
2. If `n > 1`, unlocks the login keychain with the supplied password.
3. **Reads the existing value first.** If the read fails, refuses to delete anything — recreating with a different value would derive a different PBKDF2 AES key and permanently destroy every existing Chrome cookie.
4. Deletes every matching item, then re-adds exactly one with the **same** value.
5. Sets the partition list on the surviving item.

To converge manually, quiesce racers first:

```sh
launchctl bootout gui/$(id -u)/dev.agentcookie.sink   # stop the CDP injector
pkill -x "Google Chrome"                              # stop the racer
agentcookie wizard set-keychain-access                # converge + grant
```

## Recover / narrow

Nothing is deleted by the default inline path, so there is no destructive rollback. To narrow access back to the security-CLI tools only (drop Developer-ID CGO readers), re-run the partition set without `teamid:`:

```sh
security set-generic-password-partition-list \
  -S "apple-tool:,apple:" -k "<login-password>" \
  -s "Chrome Safe Storage" -a Chrome
```

## Boundaries to be honest about

<AccordionGroup>
<Accordion title="pycookiecheat and other unsigned-CGO callers">
`pycookiecheat` reads via the Python `keyring` library — a direct `SecItemCopyMatching` from an unsigned homebrew Python — not the `security` CLI. It is the documented unsigned-CGO class that `apple-tool:` and `teamid:` do not cover. Its `-25308 User interaction is not allowed` over SSH is expected, not a bug. Sign it with your team or run `wizard set-keychain-access --any-app`.
</Accordion>
<Accordion title="ps visibility of the env password">
`AGENTCOOKIE_LOGIN_PASSWORD` is passed as `-k <password>` to `security`. For the lifetime of that call it is visible in `ps`. This is a `security -k` constraint; agentcookie does not log or persist the value. Use this path only in trusted SSH sessions or CI runners.
</Accordion>
<Accordion title="locked login keychain reads after SSH reconnects">
Even after a successful partition set, a fresh bare-SSH session can report `User interaction is not allowed` when reading via `security` because the login keychain has re-locked. The sink daemon runs inside the GUI session where the keychain is auto-unlocked and reads the key fine. Doctor surfaces this as `INFO`, not failure.
</Accordion>
<Accordion title="--write-chrome-sqlite does not silently downgrade">
When the operator explicitly forces universal with `--write-chrome-sqlite`, a failed open prints a WARNING and renders universal `sink.yaml` anyway. The sink daemon will then fail to read Chrome Safe Storage until `set-keychain-access` runs. This is intentional: the operator asked for universal; the install respects it rather than rewriting their config behind their back.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
<Card title="Universal cookie delivery" href="/universal-delivery">
What the one-login-password Safe Storage open buys: any unmodified cookie tool reads the synced Default profile.
</Card>
<Card title="Cookie delivery surfaces" href="/cookie-delivery-surfaces">
The three sink-side delivery paths — Default profile, plaintext sidecar, per-CLI adapter session files.
</Card>
<Card title="Configure source and sink" href="/configure-source-sink">
`sink.yaml` keys including `skip_chrome_sqlite`, `cdp.enabled`, and `delivery`.
</Card>
<Card title="doctor and adapter verification" href="/doctor-health-checks">
The `Cookie delivery` check states, the `DoctorReport` JSON envelope, and exit-code semantics.
</Card>
<Card title="LaunchAgent management" href="/launchagent-management">
`dev.agentcookie.sink` plist, `launchctl` bootstrap, and how to quiesce the CDP injector during a re-grant.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Stale Chrome `SingletonLock`, missing `~/go/bin`, pairing `connection refused`, and recovering from a duplicate-item race.
</Card>
</CardGroup>
