From efdaba606e6884893b43838fecef35f4f6204a63 Mon Sep 17 00:00:00 2001 From: "pi@mbp-m1-2020" Date: Sun, 11 Oct 2026 17:06:04 +0200 Subject: [PATCH] aws-sso: keep the Bedrock SSO session alive by running aws sso login before it ends Like Claude Code's awsAuthRefresh: the user still approves each login. mode mac runs it on the Docker host over SSH (own browser); mode device shows the device code in pi for SSH/tmux use. Off by default (/aws-sso on). Session start inferred from refresh-token changes; 8 h default, measured. --- AGENTS.md | 1 + README.md | 32 ++++ extensions/aws-sso.ts | 370 ++++++++++++++++++++++++++++++++++++++++++ test/aws-sso.test.mjs | 127 +++++++++++++++ 4 files changed, 530 insertions(+) create mode 100644 extensions/aws-sso.ts create mode 100644 test/aws-sso.test.mjs diff --git a/AGENTS.md b/AGENTS.md index aa49032..9b5043e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ extensions/ mcp-loader.ts # Generic MCP server loader + /mcp slash command task.ts # `task` tool — pi-task (isolated child, PASS/FAIL envelope, boundary diff) as a tool fork-gate.ts # tool_call hook: block forks whose brief needs `task`; reason = the task(...) to make + aws-sso.ts # /aws-sso: run `aws sso login` (host browser or device code) before the IdC session ends test/ # node --test 'test/**/*.test.mjs' — two-sided tests for the pure parts (classifier, root validation) install.sh # Idempotent installer — symlinks extensions/ into ~/.pi/agent/extensions/ package.json # pi package manifest — enables `pi install /path` as an alternative diff --git a/README.md b/README.md index f73bc9c..4c92243 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,38 @@ Each extension is a TypeScript module loaded by [jiti](https://github.com/unjs/j --- +### `aws-sso.ts` + +Keeps a Bedrock **AWS SSO** session alive, like Claude Code's `awsAuthRefresh`: +shortly before the IAM Identity Center session ends (or when a request fails on +an expired token) it runs `aws sso login` for you. **You still approve the login.** +Off until you enable it. + +| Command | Effect | +|---|---| +| `/aws-sso on` / `off` | Enable/disable (persisted in `~/.pi/agent/aws-sso/state.json`) | +| `/aws-sso status` | Mode, profile chain, time left, last login | +| `/aws-sso now` | Log in now | +| `/aws-sso mode mac` | Run `aws sso login` on the Docker host over SSH (`~/.ssh-local/config`, host alias `host` or `$PI_AWS_SSO_SSH_HOST`): your own browser opens. Default when the sidecar config exists. | +| `/aws-sso mode device` | Run `aws sso login --use-device-code --no-browser` here and show link + code in pi; approve on any device. For SSH/tmux sessions. | +| `/aws-sso hours ` | Session length if your IdC admin changed it (default 8 h, measured) | + +`PI_AWS_SSO_REFRESH=1` / `=0` force it on / off regardless of the saved state. + +**How it knows when the session ends.** The AWS SDK already refreshes the 1-hour +access token inside a session; only the session itself expires (admin-set; 8 h +measured on 2026-10-10). Its start is not stored anywhere, so the extension +treats a new *refresh token* in `~/.aws/sso/cache/.json` as a +new session and remembers when it first saw it. Tokens are only ever logged as +`sha256[:8]` fingerprints (`~/.pi/agent/aws-sso/events.jsonl`). + +**Triggers:** a 60 s timer (20 min before the end; one attempt per session, so an +ignored login does not loop), `before_agent_start` (if the session has already +expired, log in and wait before sending the prompt), and `message_end` (a request +failed with an expired-token error → log in, then tell you to send `continue`). +Only interactive sessions act; print/JSON children (`task`, `fork`) stay inert. +A lock file stops several pi processes from starting several logins. + ## Deploying on a new machine ```bash diff --git a/extensions/aws-sso.ts b/extensions/aws-sso.ts new file mode 100644 index 0000000..0c804ea --- /dev/null +++ b/extensions/aws-sso.ts @@ -0,0 +1,370 @@ +/** + * aws-sso.ts — keep the Bedrock AWS SSO session alive, the way Claude Code's + * `awsAuthRefresh` does: when the session nears its end (or has expired), run + * `aws sso login` for you. You still approve the login yourself. + * + * Modes (`/aws-sso mode …`): + * mac – run `aws sso login` on the Docker host over SSH (pi-devbox), so your + * own browser opens. For when you sit at the laptop. + * device – run `aws sso login --use-device-code --no-browser` here and show the + * link + code in pi. Approve on any device (phone, other computer). + * For SSH/tmux sessions. + * + * Inert until enabled: `/aws-sso on` (persists in ~/.pi/agent/aws-sso/state.json), + * or env PI_AWS_SSO_REFRESH=1. PI_AWS_SSO_REFRESH=0 is a hard off switch. + * + * Why a timer is needed at all: within one IAM Identity Center session the AWS + * SDK already refreshes the 1-hour access token by itself. The session itself has + * a fixed lifetime set by the IdC admin (8 h measured here); after that only a new + * `aws sso login` helps. The session start is not recorded anywhere, so it is + * inferred from the refresh token: a new refresh token = a new session. + * Tokens never leave this process except as sha256[:8] fingerprints. + */ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, openSync, closeSync, unlinkSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// ── pure helpers (tested in test/aws-sso.test.mjs) ───────────────────────────── + +export function fingerprint(value: string | undefined | null): string { + if (!value) return "-"; + return createHash("sha256").update(String(value)).digest("hex").slice(0, 8); +} + +/** botocore's cache file name for an sso-session: sha1(session name). */ +export function cacheKeyFor(sessionName: string): string { + return createHash("sha1").update(sessionName).digest("hex"); +} + +export function parseIni(text: string): Record> { + const out: Record> = {}; + let cur: string | null = null; + for (const raw of String(text).split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith(";")) continue; + const sec = line.match(/^\[\s*(.+?)\s*\]$/); + if (sec) { cur = sec[1].replace(/\s+/g, " "); out[cur] ??= {}; continue; } + const kv = line.match(/^([^=]+?)\s*=\s*(.*)$/); + if (kv && cur) out[cur][kv[1].trim()] = kv[2].trim(); + } + return out; +} + +export interface SsoSession { sessionName: string; startUrl: string; region: string | null; chain: string[] } + +/** Follow source_profile from `profile` to the profile that names an sso_session. */ +export function resolveSsoSession(configText: string, profile: string): SsoSession { + const ini = parseIni(configText); + const chain: string[] = []; + let name = profile; + for (let hop = 0; hop < 10; hop++) { + if (chain.includes(name)) throw new Error(`source_profile loop: ${[...chain, name].join(" -> ")}`); + chain.push(name); + const p = name === "default" ? (ini["default"] ?? ini["profile default"]) : ini[`profile ${name}`]; + if (!p) throw new Error(`profile '${name}' not found in ~/.aws/config`); + if (p.sso_session) { + const s = ini[`sso-session ${p.sso_session}`]; + if (!s) throw new Error(`[sso-session ${p.sso_session}] not found (referenced by profile '${name}')`); + if (!s.sso_start_url) throw new Error(`[sso-session ${p.sso_session}] has no sso_start_url`); + return { sessionName: p.sso_session, startUrl: s.sso_start_url, region: s.sso_region ?? null, chain }; + } + if (p.sso_start_url) throw new Error(`profile '${name}' uses legacy sso_start_url; convert it to an [sso-session] block (legacy sessions cannot refresh)`); + if (!p.source_profile) throw new Error(`profile '${name}' has neither sso_session nor source_profile`); + name = p.source_profile; + } + throw new Error(`source_profile chain too long: ${chain.join(" -> ")}`); +} + +/** Device-code output → { code, url }. The URL must be on the configured start URL's host. */ +export function parseDeviceOutput(text: string, startUrl: string): { code: string; url: string } | null { + const want = new URL(startUrl).host; + const m = String(text).match(/https:\/\/\S*user_code=([A-Z0-9-]+)/); + if (!m) return null; + let url: URL; + try { url = new URL(m[0]); } catch { return null; } + if (url.host !== want || !/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(m[1])) return null; + return { code: m[1], url: url.toString() }; +} + +export function isSsoExpiredError(message: string): boolean { + return /token (is )?expired|session (has )?expired|invalid_grant|ExpiredToken|The SSO session associated with this profile|run 'aws sso login'/i.test(String(message)); +} + +export interface State { + enabled?: boolean; + mode?: "mac" | "device"; + sessionHours?: number; + leadMinutes?: number; + anchor?: { refreshFp: string; since: number }; + lastLogin?: { at: number; ok: boolean; mode: string; detail?: string }; +} + +export const DEFAULTS = { sessionHours: 8, leadMinutes: 20 }; + +/** + * Where the session stands. `since` is when we first saw this refresh token. + * none – no cached token + * expired – the 1-hour access token has expired AND the session is past its end + * due – inside the lead window before the session's end + * ok – healthy + */ +export function assess(state: State, token: { refreshFp: string; accessExpiresAt: number } | null, now: number) { + const hours = state.sessionHours ?? DEFAULTS.sessionHours; + const lead = (state.leadMinutes ?? DEFAULTS.leadMinutes) * 60e3; + if (!token) return { status: "none" as const, endsAt: null as number | null }; + const since = state.anchor && state.anchor.refreshFp === token.refreshFp ? state.anchor.since : null; + const endsAt = since === null ? null : since + hours * 3600e3; + if (endsAt !== null && now >= endsAt && now >= token.accessExpiresAt) return { status: "expired" as const, endsAt }; + if (endsAt !== null && now >= endsAt - lead) return { status: "due" as const, endsAt }; + return { status: "ok" as const, endsAt }; +} + +/** New anchor when the refresh token changed; null when unchanged. */ +export function reanchor(state: State, refreshFp: string, firstSeen: number): State["anchor"] | null { + if (state.anchor && state.anchor.refreshFp === refreshFp) return null; + return { refreshFp, since: firstSeen }; +} + +export function fmtLeft(ms: number): string { + if (ms <= 0) return "0m"; + const m = Math.round(ms / 60e3); + return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m` : `${m}m`; +} + +// ── I/O ─────────────────────────────────────────────────────────────────────── + +const DIR = join(homedir(), ".pi", "agent", "aws-sso"); +const STATE = join(DIR, "state.json"); +const LOCK = join(DIR, "login.lock"); +const LOG = join(DIR, "events.jsonl"); +const SSH_CONFIG = join(homedir(), ".ssh-local", "config"); + +function loadState(): State { + try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } +} +function saveState(s: State) { + mkdirSync(DIR, { recursive: true, mode: 0o700 }); + const tmp = `${STATE}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(s, null, 2)); + renameSync(tmp, STATE); +} +function logEvent(event: string, extra: Record = {}) { + try { + mkdirSync(DIR, { recursive: true, mode: 0o700 }); + writeFileSync(LOG, JSON.stringify({ ts: new Date().toISOString(), pid: process.pid, event, ...extra }) + "\n", { flag: "a" }); + } catch { /* logging must never break pi */ } +} + +function ssoSession(): SsoSession | { error: string } { + const profile = process.env.AWS_PROFILE; + if (!profile) return { error: "AWS_PROFILE is not set" }; + try { return resolveSsoSession(readFileSync(join(homedir(), ".aws", "config"), "utf8"), profile); } + catch (e) { return { error: (e as Error).message }; } +} + +function readToken(sessionName: string): { refreshFp: string; accessExpiresAt: number; mtime: number } | null { + const f = join(homedir(), ".aws", "sso", "cache", `${cacheKeyFor(sessionName)}.json`); + try { + const d = JSON.parse(readFileSync(f, "utf8")); + return { refreshFp: fingerprint(d.refreshToken), accessExpiresAt: Date.parse(d.expiresAt), mtime: statSync(f).mtimeMs }; + } catch { return null; } +} + +/** Cross-process lock so several pi processes never start several logins. */ +function tryLock(): boolean { + mkdirSync(DIR, { recursive: true, mode: 0o700 }); + try { closeSync(openSync(LOCK, "wx")); writeFileSync(LOCK, String(process.pid)); return true; } + catch { + try { + const pid = Number(readFileSync(LOCK, "utf8")); + if (pid && pid !== process.pid) { try { process.kill(pid, 0); return false; } catch { /* stale */ } } + unlinkSync(LOCK); + closeSync(openSync(LOCK, "wx")); writeFileSync(LOCK, String(process.pid)); return true; + } catch { return false; } + } +} +function unlock() { try { if (Number(readFileSync(LOCK, "utf8")) === process.pid) unlinkSync(LOCK); } catch { /* gone */ } } + +function defaultMode(): "mac" | "device" { return existsSync(SSH_CONFIG) ? "mac" : "device"; } + +type Ui = { notify(m: string, t?: "info" | "warning" | "error"): void; setStatus(k: string, t: string | undefined): void }; + +/** Run one login. Resolves true when the cached refresh token changed. */ +function runLogin(mode: "mac" | "device", s: SsoSession, ui: Ui | null, timeoutMs = 10 * 60e3): Promise<{ ok: boolean; detail: string }> { + const before = readToken(s.sessionName)?.refreshFp ?? "-"; + const args = mode === "mac" + // Login shell on the host: a non-interactive ssh PATH may not include aws. + ? ["-F", SSH_CONFIG, "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", process.env.PI_AWS_SSO_SSH_HOST || "host", + `exec "\${SHELL:-/bin/zsh}" -lc 'aws sso login --sso-session ${s.sessionName.replace(/[^\w.-]/g, "")}'`] + : ["sso", "login", "--sso-session", s.sessionName, "--use-device-code", "--no-browser"]; + const cmd = mode === "mac" ? "ssh" : "aws"; + logEvent("login_start", { mode, before }); + return new Promise((resolve) => { + let out = ""; + let shown = false; + const child = spawn(cmd, args, { env: { ...process.env, AWS_PAGER: "" }, stdio: ["ignore", "pipe", "pipe"] }); + const onData = (b: Buffer) => { + out += b.toString(); + if (mode === "device" && !shown) { + const d = parseDeviceOutput(out, s.startUrl); + if (d) { + shown = true; + ui?.notify(`AWS SSO: approve code ${d.code} at\n${d.url}`, "warning"); + ui?.setStatus("aws-sso", `AWS ⏳ approve ${d.code}`); + logEvent("device_code_shown", { code: d.code }); + } + } + }; + child.stdout.on("data", onData); + child.stderr.on("data", onData); + const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs); + child.on("close", (rc) => { + clearTimeout(timer); + const after = readToken(s.sessionName)?.refreshFp ?? "-"; + const ok = rc === 0 && after !== before; + const detail = ok ? "new session" : (out.trim().split("\n").filter(Boolean).pop() ?? `exit ${rc}`).slice(0, 200); + logEvent("login_end", { mode, rc, before, after, ok, detail: ok ? undefined : detail }); + resolve({ ok, detail }); + }); + child.on("error", (e) => { clearTimeout(timer); resolve({ ok: false, detail: e.message }); }); + }); +} + +// ── extension ───────────────────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + let timer: ReturnType | null = null; + let inFlight: Promise<{ ok: boolean; detail: string }> | null = null; + let ui: Ui | null = null; + let warnedEnd: number | null = null; + + const enabled = () => process.env.PI_AWS_SSO_REFRESH === "0" ? false : process.env.PI_AWS_SSO_REFRESH === "1" ? true : !!loadState().enabled; + + /** Observe the token, keep the anchor current, return the assessment. */ + function observe() { + const s = ssoSession(); + if ("error" in s) return { s: null, error: s.error, a: null as ReturnType | null, token: null }; + const token = readToken(s.sessionName); + const state = loadState(); + if (token) { + // First sight of a refresh token: its file mtime is when it was written (≈ login time). + const next = reanchor(state, token.refreshFp, Math.min(token.mtime, Date.now())); + if (next) { state.anchor = next; saveState(state); logEvent("anchor", { refreshFp: next.refreshFp, since: new Date(next.since).toISOString() }); } + } + return { s, error: null, a: assess(state, token, Date.now()), token }; + } + + function render() { + if (!ui) return; + if (!enabled()) { ui.setStatus("aws-sso", undefined); return; } + if (inFlight) return; // the login path owns the status while it runs + const o = observe(); + if (o.error || !o.a) { ui.setStatus("aws-sso", `AWS ✗ ${o.error}`); return; } + const left = o.a.endsAt === null ? null : o.a.endsAt - Date.now(); + const txt = o.a.status === "none" ? "AWS ✗ not logged in" + : o.a.status === "expired" ? "AWS ✗ session expired" + : left === null ? "AWS ✓" : `AWS ${o.a.status === "due" ? "⚠" : "✓"} ${fmtLeft(left)}`; + ui.setStatus("aws-sso", txt); + } + + async function login(reason: string): Promise { + if (inFlight) return (await inFlight).ok; + const o = observe(); + if (!o.s) { ui?.notify(`AWS SSO: ${o.error}`, "error"); return false; } + if (!tryLock()) { ui?.notify("AWS SSO: another pi process is already running the login — approve it there.", "info"); return false; } + const state = loadState(); + const mode = state.mode ?? defaultMode(); + ui?.setStatus("aws-sso", mode === "mac" ? "AWS ⏳ approve in your browser" : "AWS ⏳ starting device login…"); + if (mode === "mac") ui?.notify(`AWS SSO (${reason}): a login page is opening in your browser on the host — approve it there.`, "warning"); + logEvent("login_trigger", { reason, mode }); + inFlight = runLogin(mode, o.s, ui); + try { + const r = await inFlight; + const st = loadState(); + st.lastLogin = { at: Date.now(), ok: r.ok, mode, detail: r.ok ? undefined : r.detail }; + saveState(st); + ui?.notify(r.ok ? "AWS SSO: new session — good for the next ~" + (st.sessionHours ?? DEFAULTS.sessionHours) + " h." : `AWS SSO login did not complete: ${r.detail}`, r.ok ? "info" : "error"); + return r.ok; + } finally { inFlight = null; unlock(); render(); } + } + + function tick() { + if (!enabled()) { render(); return; } + const o = observe(); + render(); + if (!o.a || inFlight) return; + // Proactive: one login attempt per session end, so a declined/ignored login does not loop. + if ((o.a.status === "due" || o.a.status === "expired") && o.a.endsAt !== null && warnedEnd !== o.a.endsAt) { + warnedEnd = o.a.endsAt; + void login(o.a.status === "due" ? "session ends soon" : "session expired"); + } + } + + pi.on("session_start", async (_e, ctx) => { + ui = ctx.hasUI ? ctx.ui : null; + if (!ui) return; // print/json children: never start logins or timers + render(); + timer = setInterval(tick, 60e3); + if (enabled()) tick(); + }); + + pi.on("session_shutdown", async () => { + if (timer) clearInterval(timer); + timer = null; + unlock(); + }); + + // Before a prompt goes out: if the session is already gone, log in first and wait. + pi.on("before_agent_start", async () => { + if (!ui || !enabled()) return; + const o = observe(); + if (o.a?.status === "expired" || o.a?.status === "none") await login("session expired"); + else if (inFlight) await inFlight; + }); + + // A request failed on expired credentials: start the login (once per session end). + pi.on("message_end", async (e) => { + const m = e.message as { role?: string; errorMessage?: string }; + if (!ui || !enabled() || m.role !== "assistant" || !m.errorMessage || !isSsoExpiredError(m.errorMessage)) return; + logEvent("request_failed_expired"); + void login("a request failed: session expired").then((ok) => { + if (ok) ui?.notify("AWS SSO renewed — send 'continue' to resume.", "info"); + }); + }); + + pi.registerCommand("aws-sso", { + description: "AWS SSO session keeper: on | off | status | now | mode mac|device | hours ", + handler: async (args, ctx) => { + ui = ctx.ui; + const [cmd, val] = String(args ?? "").trim().split(/\s+/); + const st = loadState(); + switch (cmd || "status") { + case "on": st.enabled = true; saveState(st); if (!timer) timer = setInterval(tick, 60e3); tick(); ctx.ui.notify(`AWS SSO keeper ON (mode ${st.mode ?? defaultMode()}).`, "info"); break; + case "off": st.enabled = false; saveState(st); render(); ctx.ui.notify("AWS SSO keeper OFF.", "info"); break; + case "now": await login("requested"); break; + case "mode": + if (val !== "mac" && val !== "device") { ctx.ui.notify("Usage: /aws-sso mode mac|device", "error"); break; } + st.mode = val; saveState(st); ctx.ui.notify(`AWS SSO mode: ${val}`, "info"); break; + case "hours": { + const n = Number(val); + if (!(n > 0 && n <= 24 * 90)) { ctx.ui.notify("Usage: /aws-sso hours ", "error"); break; } + st.sessionHours = n; saveState(st); render(); ctx.ui.notify(`AWS SSO session length set to ${n} h.`, "info"); break; + } + case "status": default: { + const o = observe(); + const lines = [ + `keeper: ${enabled() ? "ON" : "off"}${process.env.PI_AWS_SSO_REFRESH ? ` (env PI_AWS_SSO_REFRESH=${process.env.PI_AWS_SSO_REFRESH})` : ""} · mode: ${st.mode ?? defaultMode()} · session length: ${st.sessionHours ?? DEFAULTS.sessionHours} h`, + o.error ? `config: ✗ ${o.error}` : `sso-session: ${o.s!.sessionName} (profile chain ${o.s!.chain.join(" → ")})`, + o.a ? `session: ${o.a.status}${o.a.endsAt ? ` · ends ≈ ${new Date(o.a.endsAt).toLocaleTimeString()} (${fmtLeft(o.a.endsAt - Date.now())} left)` : " · start unknown"}` : "", + o.token ? `token: refresh ${o.token.refreshFp} · access valid until ${new Date(o.token.accessExpiresAt).toLocaleTimeString()}` : "token: none", + st.lastLogin ? `last login: ${new Date(st.lastLogin.at).toLocaleString()} · ${st.lastLogin.ok ? "ok" : "FAILED: " + st.lastLogin.detail} (${st.lastLogin.mode})` : "last login: —", + ].filter(Boolean); + ctx.ui.notify(lines.join("\n"), "info"); + } + } + }, + }); +} diff --git a/test/aws-sso.test.mjs b/test/aws-sso.test.mjs new file mode 100644 index 0000000..dfd2a8c --- /dev/null +++ b/test/aws-sso.test.mjs @@ -0,0 +1,127 @@ +// Two-sided tests for the pure parts of extensions/aws-sso.ts (node >= 23 strips +// the types; the only pi import is type-only). Shapes observed 2026-10-10/11 on +// mbp-m1-2020. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + fingerprint, cacheKeyFor, parseIni, resolveSsoSession, parseDeviceOutput, + isSsoExpiredError, assess, reanchor, fmtLeft, DEFAULTS, +} from "../extensions/aws-sso.ts"; + +const START = "https://d-1234567890.awsapps.com/start"; +const HOST = "d-1234567890.awsapps.com"; +const CONFIG = ` +[profile Base] +sso_session = MySession +sso_account_id = 111111111111 +sso_role_name = Dev +[sso-session MySession] +sso_start_url = ${START} +sso_region = eu-west-1 +[profile Bedrock] +role_arn = arn:aws:iam::111111111111:role/X +source_profile = Base +[profile LoopA] +source_profile = LoopB +[profile LoopB] +source_profile = LoopA +[profile Legacy] +sso_start_url = ${START} +[profile Dangling] +sso_session = Nope +`; + +test("fingerprint: 8 hex chars, stable, never the value", () => { + const v = "aoaAAAAAGh0dHBzOi8vZXhhbXBsZS5jb20v"; + assert.match(fingerprint(v), /^[0-9a-f]{8}$/); + assert.equal(fingerprint(v), fingerprint(v)); + assert.notEqual(fingerprint(v), fingerprint(v + "x")); + assert.ok(!v.includes(fingerprint(v))); + assert.equal(fingerprint(""), "-"); +}); + +test("cache key = sha1(session name) — the measured botocore file name", () => { + assert.equal(cacheKeyFor("GFTLIntelligenceOnTap"), "c8ef84539c753bc7f3a20b5dceef5c2f03e744a7"); +}); + +test("resolveSsoSession follows source_profile to the sso-session", () => { + const r = resolveSsoSession(CONFIG, "Bedrock"); + assert.equal(r.sessionName, "MySession"); + assert.equal(r.startUrl, START); + assert.deepEqual(r.chain, ["Bedrock", "Base"]); +}); + +test("resolveSsoSession refuses broken configs with a reason", () => { + assert.throws(() => resolveSsoSession(CONFIG, "LoopA"), /loop/); + assert.throws(() => resolveSsoSession(CONFIG, "Missing"), /not found/); + assert.throws(() => resolveSsoSession(CONFIG, "Legacy"), /legacy/); + assert.throws(() => resolveSsoSession(CONFIG, "Dangling"), /sso-session Nope/); + assert.deepEqual(parseIni("# c\n[profile A]\nk = v = w\n"), { "profile A": { k: "v = w" } }); +}); + +const CLI = (code, host = HOST) => `Browser will not be automatically opened. +Please visit the following URL: + +https://${host}/start/#/device + +Then enter the code: + +${code} + +Alternatively, you may visit the following URL which will autofill the code upon loading: +https://${host}/start/#/device?user_code=${code} +`; + +test("parseDeviceOutput: real shape accepted; foreign host / bad code refused", () => { + const r = parseDeviceOutput(CLI("PCDB-WSGH"), START); + assert.equal(r.code, "PCDB-WSGH"); + assert.equal(new URL(r.url).host, HOST); + assert.equal(parseDeviceOutput(CLI("PCDB-WSGH", "evil.example.com"), START), null); + assert.equal(parseDeviceOutput(CLI("PCDB-WSGH", `${HOST}.evil.example.com`), START), null); + assert.equal(parseDeviceOutput(CLI("pcdb-wsgh"), START), null); + assert.equal(parseDeviceOutput("Browser will not be automatically opened.\n", START), null); +}); + +test("isSsoExpiredError: the real pi error matches; unrelated failures do not", () => { + assert.ok(isSsoExpiredError("Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile.")); // measured 2026-10-11T14:18:08Z + assert.ok(isSsoExpiredError("The SSO session associated with this profile has expired or is otherwise invalid")); + assert.ok(isSsoExpiredError("ExpiredTokenException: The security token included in the request is expired")); + for (const m of ["Provider stopped with: content_filtered", "getaddrinfo ENOTFOUND bedrock-runtime.eu-west-1.amazonaws.com", "AccessDeniedException: not authorized", "ThrottlingException: Too many requests"]) { + assert.ok(!isSsoExpiredError(m), m); + } +}); + +const H = 3600e3; +const T0 = Date.UTC(2026, 9, 11, 14, 19, 0); +const tok = (fp, accessExp) => ({ refreshFp: fp, accessExpiresAt: accessExp }); + +test("assess: ok → due (lead window) → expired; unknown start stays ok", () => { + const st = { anchor: { refreshFp: "aaaa1111", since: T0 } }; + assert.equal(assess(st, tok("aaaa1111", T0 + H), T0 + 1 * H).status, "ok"); + assert.equal(assess(st, tok("aaaa1111", T0 + 8 * H), T0 + 7.5 * H).status, "ok"); + assert.equal(assess(st, tok("aaaa1111", T0 + 8 * H), T0 + 8 * H - DEFAULTS.leadMinutes * 60e3).status, "due"); + assert.equal(assess(st, tok("aaaa1111", T0 + 8.5 * H), T0 + 8.2 * H).status, "due"); // past end but access token still valid + assert.equal(assess(st, tok("aaaa1111", T0 + 8 * H), T0 + 8.2 * H).status, "expired"); + assert.equal(assess(st, null, T0).status, "none"); + assert.equal(assess({}, tok("aaaa1111", T0 + H), T0 + 30 * H).status, "ok"); // no anchor: never invent an end + assert.equal(assess(st, tok("bbbb2222", T0 + H), T0 + 30 * H).endsAt, null); // anchor belongs to another token +}); + +test("assess honours a configured session length", () => { + const st = { sessionHours: 72, anchor: { refreshFp: "aaaa1111", since: T0 } }; + assert.equal(assess(st, tok("aaaa1111", T0 + 9 * H), T0 + 8.2 * H).status, "ok"); + assert.equal(assess(st, tok("aaaa1111", T0 + 72 * H), T0 + 71.8 * H).status, "due"); +}); + +test("reanchor: only when the refresh token changes (hourly access refresh keeps it)", () => { + const st = { anchor: { refreshFp: "aaaa1111", since: T0 } }; + assert.equal(reanchor(st, "aaaa1111", T0 + 3 * H), null); + assert.deepEqual(reanchor(st, "bbbb2222", T0 + 3 * H), { refreshFp: "bbbb2222", since: T0 + 3 * H }); + assert.deepEqual(reanchor({}, "aaaa1111", T0), { refreshFp: "aaaa1111", since: T0 }); +}); + +test("fmtLeft", () => { + assert.equal(fmtLeft(-5), "0m"); + assert.equal(fmtLeft(25 * 60e3), "25m"); + assert.equal(fmtLeft(7 * H + 5 * 60e3), "7h05m"); +});