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:
@@ -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
@@ -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 |
|
||||||
@@ -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[] }
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user