From 40643454f0ba680a5b7b6f440ae6567f451673e6 Mon Sep 17 00:00:00 2001 From: "pi@mbp-m1-2020" Date: Sun, 11 Oct 2026 23:00:17 +0200 Subject: [PATCH] docs/aws-sso.md: setup guide, modes per device type, log reference, troubleshooting Also: unprompted-login back-off now matches the guide (30 min, 1 h, 2 h, then 4 h; the first retry was 1 h); README links the guide. --- README.md | 6 +- docs/aws-sso.md | 180 ++++++++++++++++++++++++++++++++++++++++++ extensions/aws-sso.ts | 7 +- test/aws-sso.test.mjs | 4 +- 4 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 docs/aws-sso.md diff --git a/README.md b/README.md index f26cc12..44bde21 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,8 @@ Each extension is a TypeScript module loaded by [jiti](https://github.com/unjs/j ### `aws-sso.ts` +Setup guide, modes per device type, log reference and troubleshooting: [docs/aws-sso.md](docs/aws-sso.md). + Keeps a Bedrock **AWS SSO** session alive, like Claude Code's `awsAuthRefresh`: when the IAM Identity Center session ends it runs `aws sso login` for you, before a request fails. **You still approve the login** (or your browser does it silently @@ -406,8 +408,8 @@ fingerprints (`~/.pi/agent/aws-sso/events.jsonl`); the token the SDK returns is dropped unread. **Triggers:** a 20 s timer that checks every 60 s inside the refresh window (every -10 min once the token has run out). Unprompted logins back off (30 min, doubling -per unanswered login, at most 4 h), so one waiting for you does not pile up tabs or +10 min once the token has run out). Unprompted logins back off (retries after 30 min, +1 h, 2 h, then every 4 h), so one waiting for you does not pile up tabs or pushes overnight. `before_agent_start` checks first if the token is in its window and logs in (and waits) if the session is over. `message_end` is the fallback: if a request still fails on an expired diff --git a/docs/aws-sso.md b/docs/aws-sso.md new file mode 100644 index 0000000..5c70462 --- /dev/null +++ b/docs/aws-sso.md @@ -0,0 +1,180 @@ +# AWS SSO keeper (`extensions/aws-sso.ts`) — setup and operation + +The keeper keeps pi's Bedrock access alive across AWS IAM Identity Center (IdC) +session ends, so a long or overnight run does not stop at "Token is expired". It +works like Claude Code's `awsAuthRefresh`: when the session ends it runs +`aws sso login` for you. **It never approves anything itself.** Either your browser +completes the login silently because it is still signed in, or you approve it, on +the machine or on your phone. + +Reference (commands, variables, mechanism): the `aws-sso.ts` section of the +[README](../README.md). This page covers setup, daily use and troubleshooting. + +## Why sessions end, and why a timer cannot fix it + +- The **1-hour access token** is renewed by the AWS SDK by itself. You never see that. +- The **session** behind it is your browser's AWS sign-in. Its maximum length is set + by your IdC admin, and it is not predictable from the client: we measured 8 h on + one day and at most 6 h on the next. When it ends, AWS refuses further renewals. +- A login while the browser sign-in is still valid completes silently but **inherits + the same end**, so renewing early gains nothing. Only a login after the end starts + a new session. + +So the keeper **detects** the end instead of guessing it. In the last 5 minutes of +each 1-hour token it makes the renewal attempt itself, through the same SDK pi uses. +If AWS refuses, it starts the login at once, while the old token still covers +requests for a few minutes. If a request fails anyway (pi was closed, the laptop +slept), it logs in and then sends `continue` so the interrupted run resumes. + +The permanent fix is administrative. Your IdC admin can raise the session length +(up to 90 days) in the IAM Identity Center console. With that, the keeper has almost +nothing to do. + +## Pick a mode for each device + +| Situation | Mode | What a renewal looks like | +|---|---|---| +| Mac you sit at; pi-devbox runs on it | `local` (default) | A browser tab opens on the Mac and normally completes by itself ("Your credentials have been shared successfully…"). If it needs you (MFA), you get a push. | +| Linux/Windows desktop with a browser | `local` | Same idea, but **not verified yet**: starting the desktop browser from an SSH session may need a host-specific `PI_AWS_SSO_LOCAL_CMD`. | +| Headless server; you `ssh` in and run pi in tmux | `remote` | Push to your phone with a link and code. Tap it, check the code, press **Allow**. AWS always asks for that click in this flow. | +| Always-on Mac used as a server (you are not at it) | `remote` | As above. `local` would open a tab on a screen nobody looks at. | + +Set the mode with `/aws-sso local` or `/aws-sso remote` (stored per device), or +with `PI_AWS_SSO_MODE` in `.env`. A mode set by command wins over `.env`. + +## Setup + +### 1. Prerequisites + +- `AWS_PROFILE` in pi-devbox `.env` names your Bedrock profile. Its + `source_profile` chain must end in a profile with `sso_session = …`, with a + matching `[sso-session …]` block in `~/.aws/config`. Old-style profiles with + `sso_start_url` directly in the profile cannot be renewed; convert them. +- `~/.aws` is available in the container (pi-devbox mounts it). +- **`local` only:** the Docker host has the AWS CLI, and the container can reach + the host over SSH. Check from the container: + + ```sh + ssh -F ~/.ssh-local/config host 'exec "${SHELL:-/bin/sh}" -lc "aws --version"' + ``` + +### 2. Push notifications (recommended; essential for `remote`) + +1. Install the **ntfy** app (iOS/Android) on your phone. +2. Make a topic name that nobody can guess. **Anyone who knows it can read your + pushes.** Generate it in your own terminal, not in a pi session (transcripts + are archived): + + ```sh + echo "pi-aws-$(openssl rand -hex 12)" + ``` + +3. In the app, subscribe to that topic on `ntfy.sh` (or on your own ntfy server). + +### 3. `.env` (pi-devbox) + +```sh +PI_AWS_SSO_NTFY=https://ntfy.sh/pi-aws- +PI_AWS_SSO_MODE=remote # only on headless/remote devices +# PI_AWS_SSO_NTFY_TOKEN= # only for a protected topic on your own server +# PI_AWS_SSO_REFRESH=1 # force the keeper on without /aws-sso on +``` + +`.env` reaches the container through `env_file`. Recreate the container afterwards +(`docker compose up -d`); a running pi does not see new variables. + +### 4. In pi + +```text +/aws-sso on # once per device; stored in ~/.pi/agent/aws-sso/state.json +/aws-sso remote # if this device should use the device-code flow +/aws-sso push-test # your phone should buzz +/aws-sso status +/aws-sso now # optional: a first login, to see the flow once +``` + +`/aws-sso status` shows, for example: + +```text +keeper: ON · local (default) · push: ntfy.sh (topic 3fb657b1) · auto-continue: on +sso-session: MySession (profile chain Bedrock → Base) +session: alive · started ≈ 22:19 · 1-h token until 23:19 (next check from 23:14) +last check: 23:14 · refreshed +last login: 2026-10-11 22:19:24 · ok (local) +``` + +The topic is shown only as a fingerprint, never in clear. + +## What happens when a session ends + +1. **Detected:** in the 5 minutes before the hourly token runs out, AWS refuses the + renewal. Log: `probe … result: refused`. +2. **Login starts:** + - `local`: a tab opens on the host. If it has not finished after 60 s, you get + the push "AWS SSO login waiting". + - `remote`: the link and code appear in pi, and you get the push "AWS SSO + approval needed" (tap to open). +3. **Done:** the log shows `login_end … ok: true`. If you were pushed about the + login, a low-priority push "AWS SSO renewed" follows. A running task never + noticed. +4. **If nobody approves:** the old token runs out, the task's next request fails + and the run pauses. The keeper tries again after 30 min, then 1 h, 2 h, and then + every 4 h, so an unattended night produces only a few pushes. Each attempt brings + a fresh code, and the keeper waits up to 10 minutes for each. When you approve, + even hours later, the run resumes with `continue`. + +Typing a prompt while the session is over, or `/aws-sso now`, starts a login at +once without waiting for the back-off. + +The status line shows `AWS ✓ since 22:19`, `AWS ⏳ approve ABCD-EFGH`, +`AWS ⏳ waiting for you in the browser` or `AWS ✗ session ended`. + +## Reading the log + +`~/.pi/agent/aws-sso/events.jsonl`: one JSON line per event. Tokens appear only as +`sha256[:8]` fingerprints. + +| Event | Meaning | +|---|---| +| `anchor` | A new refresh token was seen. `inherited: true` = silent renewal inside the same session; `false` = new session | +| `probe` | Renewal attempt inside the 5-min window: `refreshed` (fine), `refused` (session over), `unavailable` (pi's AWS SDK could not be loaded; says nothing about the session) | +| `login_trigger`, `login_start` | A login started, with the reason and mode | +| `device_code_shown` | `remote`: the code shown to you | +| `login_end` | `ok: true` = a new refresh token arrived; otherwise `detail` has the CLI's last line | +| `push`, `push_failed` | Push sent (HTTP status) or not (error) | +| `request_failed_expired` | A request failed on an expired session (fallback path) | +| `auto_continue` | The keeper sent `continue` to resume the run | + +## Troubleshooting + +| Symptom | Check | +|---|---| +| Status `AWS ✗ AWS_PROFILE is not set` / `profile … not found` | `AWS_PROFILE` in `.env`, then the profile chain in `~/.aws/config` (step 1). | +| `local` login ends with `rc: 255` | SSH to the host fails; run the check in step 1. | +| `local` login hangs, no browser appears | Linux/Windows host: the SSH session cannot reach the desktop browser. Set `PI_AWS_SSO_LOCAL_CMD`, or use `remote`. | +| No push arrives | `push`/`push_failed` lines in the log; the phone is subscribed to exactly that topic and server; the ntfy app is allowed to notify (iOS Focus / Android battery settings). `/aws-sso push-test`. | +| "another pi process is already running the login" | Several pi windows share one login; approve the one shown in the window that started it. A stale lock from a crashed pi is cleared automatically. | +| Nothing happens in a `task`/`fork` child or `pi -p` | By design: only interactive pi sessions act. | +| A run stopped and did not resume | `auto_continue` in the log? It fires at most once per 10 min, and `/aws-sso continue off` disables it. Type `continue` yourself. | + +## Security notes + +- The keeper never handles passwords or MFA. It runs the official AWS CLI for + logins, lets pi's own AWS SDK make its normal renewal attempt, and reads the token + cache only for fingerprints and expiry times. Token values are never logged. +- The ntfy topic is a shared secret: keep it long and random, or use a protected + topic on your own server (`PI_AWS_SSO_NTFY_TOKEN`). +- A pushed device link does not give anyone your token. Whoever approves it signs + the waiting CLI in with **their own** account. Still, approve only codes you + expect, and check that the code in the browser matches the push. + +## Verified so far (2026-10-11, mbp-m1-2020) + +| Item | Status | +|---|---| +| `local` on a macOS host, browser still signed in: silent, ~3–7 s | verified | +| `local` after the session ended: browser re-signed in through the IdP silently, run auto-continued | verified once (request-failed path) | +| Detection by refresh attempt before any request fails | unit-tested; first live run pending | +| ntfy push: delivery, title, priority, tap link, UTF-8 body | verified against ntfy.sh | +| `remote` end to end (code → push → approve on phone) | not yet | +| `local` on Linux/Windows hosts | not yet | diff --git a/extensions/aws-sso.ts b/extensions/aws-sso.ts index 5acf474..856cc73 100644 --- a/extensions/aws-sso.ts +++ b/extensions/aws-sso.ts @@ -183,9 +183,12 @@ export function resolveMode(state: State, env: Record { - assert.deepEqual([0, 1, 2, 3, 4, 9].map((n) => autoLoginGap(n) / 60e3), [30, 60, 120, 240, 240, 240]); +test("autoLoginGap: retries 30 min, 1 h, 2 h, then every 4 h; never closer than 30 min", () => { + assert.deepEqual([0, 1, 2, 3, 4, 9].map((n) => autoLoginGap(n) / 60e3), [30, 30, 60, 120, 240, 240]); }); test("ntfyRequest: title/click/priority headers, bearer only when given, ASCII-safe; bad URLs refused", () => {