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.
This commit is contained in:
pi@mbp-m1-2020
2026-10-11 23:00:17 +02:00
parent 0717a5476f
commit 40643454f0
4 changed files with 191 additions and 6 deletions
+4 -2
View File
@@ -358,6 +358,8 @@ Each extension is a TypeScript module loaded by [jiti](https://github.com/unjs/j
### `aws-sso.ts` ### `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`: 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 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 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. dropped unread.
**Triggers:** a 20 s timer that checks every 60 s inside the refresh window (every **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 10 min once the token has run out). Unprompted logins back off (retries after 30 min,
per unanswered login, at most 4 h), so one waiting for you does not pile up tabs or 1 h, 2 h, then every 4 h), so one waiting for you does not pile up tabs or
pushes overnight. `before_agent_start` pushes overnight. `before_agent_start`
checks first if the token is in its window and logs in (and waits) if the session 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 is over. `message_end` is the fallback: if a request still fails on an expired
+180
View File
@@ -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-<your random part>
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 |
+5 -2
View File
@@ -183,9 +183,12 @@ export function resolveMode(state: State, env: Record<string, string | undefined
return { mode: "local", source: "default" }; return { mode: "local", source: "default" };
} }
/** Gap before the next unprompted login: 30 min, doubling per unanswered login, max 4 h. */ /**
* Gap since the last unprompted login before the next one: 30 min after a success or
* the first unanswered login, then 1 h, 2 h, and at most 4 h.
*/
export function autoLoginGap(failures: number): number { export function autoLoginGap(failures: number): number {
return Math.min(30 * 60e3 * 2 ** Math.max(0, failures), 4 * 3600e3); return Math.min(30 * 60e3 * 2 ** Math.max(0, failures - 1), 4 * 3600e3);
} }
export interface Push { title: string; message: string; click?: string; priority?: "min" | "low" | "default" | "high" | "urgent"; tags?: string[] } export interface Push { title: string; message: string; click?: string; priority?: "min" | "low" | "default" | "high" | "urgent"; tags?: string[] }
+2 -2
View File
@@ -155,8 +155,8 @@ test("mode: command beats .env beats default local; old names map; junk ignored"
assert.equal(normalizeMode("headless"), null); assert.equal(normalizeMode("headless"), null);
}); });
test("autoLoginGap: 30 min, doubling, capped at 4 h", () => { 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, 60, 120, 240, 240, 240]); 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", () => { test("ntfyRequest: title/click/priority headers, bearer only when given, ASCII-safe; bad URLs refused", () => {