diff --git a/AGENTS.md b/AGENTS.md index 9b5043e..e22b975 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +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 + aws-sso.ts # /aws-sso: renew the AWS SSO session when it ends (local: host browser; remote: device code + ntfy push) 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 22ae179..f26cc12 100644 --- a/README.md +++ b/README.md @@ -366,13 +366,28 @@ while it is signed in). 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, session state, last check, last login | +| `/aws-sso status` | Mode, push target, profile chain, session state, last check, 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 continue on\|off` | After a request failed on an expired session and the renewal succeeded, send `continue` (default on) | +| `/aws-sso local` | **Default.** The machine running Docker has your browser: run `aws sso login` there over SSH (`~/.ssh-local/config`, host alias `host` or `$PI_AWS_SSO_SSH_HOST`). Silent while that browser is signed in. | +| `/aws-sso remote` | No browser you can see there (headless server, ssh + tmux, an always-on Mac used as a server): run `aws sso login --use-device-code --no-browser` in the container, show link + code in pi and push them; approve on any device. AWS always asks for a click in this flow. | +| `/aws-sso continue on\|off` | After a request failed on an expired session, send `continue` once the renewal succeeds (default on) | +| `/aws-sso push-test` | Send a test push | -`PI_AWS_SSO_REFRESH=1` / `=0` force it on / off regardless of the saved state. +Settings in pi-devbox `.env` (reaches the container via `env_file`; recreate the container after editing): + +| Variable | Meaning | +|---|---| +| `PI_AWS_SSO_REFRESH=1` / `0` | Force the keeper on / off regardless of `/aws-sso on\|off` | +| `PI_AWS_SSO_MODE=local\|remote` | Mode when none was set by command (a command-set mode wins) | +| `PI_AWS_SSO_NTFY=https://ntfy.sh/` | Push target: an [ntfy](https://ntfy.sh) topic your phone's ntfy app subscribes to. Treat the topic name as a password (long and random), or use a self-hosted ntfy. | +| `PI_AWS_SSO_NTFY_TOKEN` | Access token for a protected ntfy topic (optional) | +| `PI_AWS_SSO_SSH_HOST` | SSH alias of the Docker host for `local` (default `host`) | +| `PI_AWS_SSO_LOCAL_CMD` | Command run on the host for `local`; `{session}` is replaced by the sso-session name. Default: `exec "${SHELL:-/bin/sh}" -lc 'aws sso login --sso-session {session}'`. Verified on a macOS host; a Linux or Windows host may need its own command so the login can reach the desktop browser from an SSH session. | + +**Pushes** (only when `PI_AWS_SSO_NTFY` is set): the approval link and code (`remote`, +tap to open); "login waiting" when a `local` login has not finished after 60 s; a +login that did not complete; and "renewed" after a login you were pushed about. +Status and logs show only the ntfy server and a fingerprint of the topic. **How it detects the end.** Per AWS ("Understanding authentication sessions"), the CLI's 1-hour token is refreshed "as long as the underlying user interactive session @@ -391,11 +406,13 @@ 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 happen at most once per -30 min, so a login waiting for you does not pile up tabs. `before_agent_start` +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 +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 -token, the keeper renews and sends `continue` (once per 10 min). +token, the keeper renews and sends `continue` (once per 10 min), also when you +approve hours later. Only interactive sessions act; print/JSON children (`task`, `fork`) stay inert. A lock file stops several pi processes from starting several logins. diff --git a/extensions/aws-sso.ts b/extensions/aws-sso.ts index afaad6f..5acf474 100644 --- a/extensions/aws-sso.ts +++ b/extensions/aws-sso.ts @@ -3,12 +3,18 @@ * `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. + * Where the login runs — `/aws-sso local` (default) or `/aws-sso remote`, or + * PI_AWS_SSO_MODE=local|remote in .env (a command-set mode wins over .env): + * local – the machine running Docker has your browser: run `aws sso login` there + * over SSH (pi-devbox ~/.ssh-local/config). While the browser is signed + * in this is silent. Verified on a macOS host; Linux/Windows hosts may + * need PI_AWS_SSO_LOCAL_CMD so the command can reach the desktop browser. + * remote – no browser you can see there (headless server, ssh + tmux): run + * `aws sso login --use-device-code --no-browser` here and show the link + * + code; approve on any device. AWS always asks for a click here. + * Push (optional): PI_AWS_SSO_NTFY= (+ PI_AWS_SSO_NTFY_TOKEN for a + * protected topic) sends the approval link to your phone, a heads-up when a local + * login is waiting for you, failures, and "renewed" after you approved. * * 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. @@ -32,7 +38,7 @@ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync, openSync, closeSync, unlinkSync } from "node:fs"; import { createRequire } from "node:module"; -import { homedir } from "node:os"; +import { homedir, hostname } from "node:os"; import { join } from "node:path"; // ── pure helpers (tested in test/aws-sso.test.mjs) ───────────────────────────── @@ -103,7 +109,7 @@ export function isSsoExpiredError(message: string): boolean { export interface State { enabled?: boolean; - mode?: "mac" | "device"; + mode?: "local" | "remote" | "mac" | "device"; // mac/device: names before 2026-10-11 sessionHours?: number; autoContinue?: boolean; /** since = start of the browser's interactive session (estimated). */ @@ -161,6 +167,48 @@ export function reanchor(state: State, refreshFp: string, firstSeen: number, now return { refreshFp, since: ended ? firstSeen : a.since }; } +export type Mode = "local" | "remote"; + +export function normalizeMode(v: string | undefined | null): Mode | null { + const x = String(v ?? "").trim().toLowerCase(); + return x === "local" || x === "mac" ? "local" : x === "remote" || x === "device" ? "remote" : null; +} + +/** A mode set by command wins over .env (PI_AWS_SSO_MODE); default local. */ +export function resolveMode(state: State, env: Record): { mode: Mode; source: "command" | "env" | "default" } { + const fromState = normalizeMode(state.mode); + if (fromState) return { mode: fromState, source: "command" }; + const fromEnv = normalizeMode(env.PI_AWS_SSO_MODE); + if (fromEnv) return { mode: fromEnv, source: "env" }; + return { mode: "local", source: "default" }; +} + +/** Gap before the next unprompted login: 30 min, doubling per unanswered login, max 4 h. */ +export function autoLoginGap(failures: number): number { + return Math.min(30 * 60e3 * 2 ** Math.max(0, failures), 4 * 3600e3); +} + +export interface Push { title: string; message: string; click?: string; priority?: "min" | "low" | "default" | "high" | "urgent"; tags?: string[] } + +/** ntfy publish request. Header values must be Latin-1 for fetch: titles are kept ASCII. */ +export function ntfyRequest(topicUrl: string, token: string | undefined, p: Push): { url: string; init: { method: string; headers: Record; body: string } } { + const u = new URL(topicUrl); + if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error("PI_AWS_SSO_NTFY must be an http(s) ntfy topic URL"); + if (!u.pathname.replace(/\/+$/, "").slice(1)) throw new Error("PI_AWS_SSO_NTFY has no topic (expected https://ntfy.sh/)"); + const ascii = (x: string) => x.replace(/[^\x20-\x7e]/g, "?"); + const headers: Record = { Title: ascii(p.title), Priority: p.priority ?? "default" }; + if (p.click) headers.Click = p.click; + if (p.tags?.length) headers.Tags = p.tags.map(ascii).join(","); + if (token) headers.Authorization = `Bearer ${token}`; + return { url: u.toString(), init: { method: "POST", headers, body: p.message } }; +} + +/** For status/logs: the server and a fingerprint, never the topic itself (it is the secret). */ +export function describeTopic(topicUrl: string | undefined): string { + if (!topicUrl) return "not configured"; + try { const u = new URL(topicUrl); return `${u.host} (topic ${fingerprint(u.pathname)})`; } catch { return "INVALID URL"; } +} + export function fmtLeft(ms: number): string { if (ms <= 0) return "0m"; const m = Math.round(ms / 60e3); @@ -234,48 +282,55 @@ function tryLock(): boolean { } function unlock() { try { if (Number(readFileSync(LOCK, "utf8")) === process.pid) unlinkSync(LOCK); } catch { /* gone */ } } -function defaultMode(): "mac" | "device" { return existsSync(SSH_CONFIG) ? "mac" : "device"; } +const DEVICE = process.env.MEMPALACE_PI_DEVICE || process.env.PI_AWS_SSO_DEVICE_NAME || hostname(); 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 }> { +async function push(p: Push): Promise { + const url = process.env.PI_AWS_SSO_NTFY; + if (!url) return; + try { + const r = ntfyRequest(url, process.env.PI_AWS_SSO_NTFY_TOKEN, p); + const res = await fetch(r.url, { ...r.init, signal: AbortSignal.timeout(10e3) }); + logEvent("push", { title: p.title, status: res.status }); + } catch (e) { logEvent("push_failed", { title: p.title, error: (e as Error).message.slice(0, 160) }); } +} + +/** One login. ok = the cached refresh token changed. */ +function runLogin(mode: Mode, s: SsoSession, hooks: { onDeviceCode(code: string, url: string): void; onWaiting(): void }, 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"; + const session = s.sessionName.replace(/[^\w.-]/g, ""); + // Login shell on the host: a non-interactive ssh PATH may not include aws. + const hostCmd = (process.env.PI_AWS_SSO_LOCAL_CMD || `exec "\${SHELL:-/bin/sh}" -lc 'aws sso login --sso-session {session}'`).replaceAll("{session}", session); + const [cmd, args] = mode === "local" + ? ["ssh", ["-F", SSH_CONFIG, "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", process.env.PI_AWS_SSO_SSH_HOST || "host", hostCmd]] + : ["aws", ["sso", "login", "--sso-session", s.sessionName, "--use-device-code", "--no-browser"]]; 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 child = spawn(cmd, args as string[], { env: { ...process.env, AWS_PAGER: "" }, stdio: ["ignore", "pipe", "pipe"] }); const onData = (b: Buffer) => { out += b.toString(); - if (mode === "device" && !shown) { + if (mode === "remote" && !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 }); - } + if (d) { shown = true; logEvent("device_code_shown", { code: d.code }); hooks.onDeviceCode(d.code, d.url); } } }; child.stdout.on("data", onData); child.stderr.on("data", onData); + // Local logins finish in seconds while the browser is signed in; still running after 60 s = waiting for you. + const waiting = mode === "local" ? setTimeout(() => hooks.onWaiting(), 60e3) : null; const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs); + const done = (r: { ok: boolean; detail: string }) => { clearTimeout(timer); if (waiting) clearTimeout(waiting); resolve(r); }; 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 }); + done({ ok, detail }); }); - child.on("error", (e) => { clearTimeout(timer); resolve({ ok: false, detail: e.message }); }); + child.on("error", (e) => done({ ok: false, detail: e.message })); }); } @@ -288,6 +343,9 @@ export default function (pi: ExtensionAPI) { let probing = false; let lastProbeAt = 0; let lastAutoLogin = 0; + let autoLoginFailures = 0; + let pendingContinue = false; // a request failed on an expired session; resume after the next successful login + let lastAutoContinue = 0; let lastProbe: { at: number; result: string } | null = null; const enabled = () => process.env.PI_AWS_SSO_REFRESH === "0" ? false : process.env.PI_AWS_SSO_REFRESH === "1" ? true : !!loadState().enabled; @@ -342,22 +400,53 @@ export default function (pi: ExtensionAPI) { 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"); + const { mode } = resolveMode(loadState(), process.env); + let pushed = false; + ui?.setStatus("aws-sso", mode === "local" ? "AWS ⏳ login in your browser" : "AWS ⏳ starting device login…"); + if (mode === "local") ui?.notify(`AWS SSO (${reason}): a login page is opening in your browser on the host. Usually it completes on its own; otherwise approve it there.`, "warning"); logEvent("login_trigger", { reason, mode }); - inFlight = runLogin(mode, o.s, ui); + inFlight = runLogin(mode, o.s, { + onDeviceCode: (code, url) => { + ui?.notify(`AWS SSO (${reason}): approve code ${code} at\n${url}`, "warning"); + ui?.setStatus("aws-sso", `AWS ⏳ approve ${code}`); + pushed = true; + void push({ title: `AWS SSO approval needed (${DEVICE})`, message: `Code ${code}. Tap to approve. Reason: ${reason}.`, click: url, priority: "high", tags: ["key"] }); + }, + onWaiting: () => { + ui?.setStatus("aws-sso", "AWS ⏳ waiting for you in the browser"); + pushed = true; + void push({ title: `AWS SSO login waiting (${DEVICE})`, message: `The login opened in the browser on ${DEVICE} needs you. Reason: ${reason}.`, priority: "high", tags: ["key"] }); + }, + }); 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: login complete." : `AWS SSO login did not complete: ${r.detail}`, r.ok ? "info" : "error"); + if (r.ok) { + autoLoginFailures = 0; + if (pushed) void push({ title: `AWS SSO renewed (${DEVICE})`, message: pendingContinue ? "Session renewed; the interrupted run is resuming." : "Session renewed.", priority: "low", tags: ["white_check_mark"] }); + resumeIfPending(); + } else { + void push({ title: `AWS SSO login did not complete (${DEVICE})`, message: `${r.detail}. Another attempt follows automatically; or run /aws-sso now.`, priority: "default", tags: ["warning"] }); + } return r.ok; } finally { inFlight = null; unlock(); render(); } } + /** After any successful login: resume a run that stopped on an expired session (once per 10 min). */ + function resumeIfPending() { + if (!pendingContinue) return; + pendingContinue = false; + if ((loadState().autoContinue ?? true) && Date.now() - lastAutoContinue > 10 * 60e3) { + lastAutoContinue = Date.now(); + logEvent("auto_continue"); + ui?.notify("AWS SSO renewed — resuming with 'continue'.", "info"); + pi.sendUserMessage("continue", { deliverAs: "followUp" }); + } else ui?.notify("AWS SSO renewed — send 'continue' to resume.", "info"); + } + async function tick() { if (!enabled() || inFlight || probing) { render(); return; } const o = observe(); @@ -365,10 +454,11 @@ export default function (pi: ExtensionAPI) { if (!o.token) return; if (!probeDue(o.token.accessExpiresAt, Date.now(), lastProbeAt)) return; const r = await probe("refresh window"); - // At most one unprompted login per 30 min, so a login waiting for you does not pile up tabs. - if (r?.result === "refused" && Date.now() - lastAutoLogin >= 30 * 60e3) { + // Unprompted logins back off (30 min, doubling, max 4 h) so an unanswered one does not + // pile up tabs or pushes overnight. A prompt, a failed request or /aws-sso now still log in at once. + if (r?.result === "refused" && Date.now() - lastAutoLogin >= autoLoginGap(autoLoginFailures)) { lastAutoLogin = Date.now(); - void login("session ended (refresh refused)"); + void login("session ended").then((ok) => { if (!ok) autoLoginFailures++; }); } } @@ -399,37 +489,37 @@ export default function (pi: ExtensionAPI) { }); // A request failed on expired credentials: renew, then (optionally) resume the run. - let lastAutoContinue = 0; 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; const st = loadState(); st.sessionEnded = true; saveState(st); + pendingContinue = true; logEvent("request_failed_expired"); - void login("a request failed: session expired").then((ok) => { - if (!ok) return; - // At most once per 10 min: if the renewed session fails at once, stop instead of looping. - if ((loadState().autoContinue ?? true) && Date.now() - lastAutoContinue > 10 * 60e3) { - lastAutoContinue = Date.now(); - logEvent("auto_continue"); - ui?.notify("AWS SSO renewed — resuming with 'continue'.", "info"); - pi.sendUserMessage("continue", { deliverAs: "followUp" }); - } else ui?.notify("AWS SSO renewed — send 'continue' to resume.", "info"); - }); + void login("a request failed: session expired"); }); pi.registerCommand("aws-sso", { - description: "AWS SSO session keeper: on | off | status | now | mode mac|device | continue on|off", + description: "AWS SSO session keeper: on | off | status | now | local | remote | continue on|off | push-test", 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(() => void tick(), 20e3); void tick(); ctx.ui.notify(`AWS SSO keeper ON (mode ${st.mode ?? defaultMode()}).`, "info"); break; + case "on": st.enabled = true; saveState(st); if (!timer) timer = setInterval(() => void tick(), 20e3); void tick(); ctx.ui.notify(`AWS SSO keeper ON (${resolveMode(st, process.env).mode}).`, "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 "local": case "remote": case "mode": { + const m = normalizeMode(cmd === "mode" ? val : cmd); + if (!m) { ctx.ui.notify("Usage: /aws-sso local | /aws-sso remote", "error"); break; } + st.mode = m; saveState(st); + ctx.ui.notify(m === "local" ? "AWS SSO: local — logins run on the Docker host and open its browser." : "AWS SSO: remote — logins show a link + code (and push it, if PI_AWS_SSO_NTFY is set).", "info"); + break; + } + case "push-test": + if (!process.env.PI_AWS_SSO_NTFY) { ctx.ui.notify("PI_AWS_SSO_NTFY is not set (add it to .env and recreate the container).", "error"); break; } + await push({ title: `AWS SSO push test (${DEVICE})`, message: "If you can read this, approval links will reach you.", priority: "default", tags: ["test_tube"] }); + ctx.ui.notify(`Push sent to ${describeTopic(process.env.PI_AWS_SSO_NTFY)} — check your phone; result in events.jsonl.`, "info"); + break; case "continue": if (val !== "on" && val !== "off") { ctx.ui.notify("Usage: /aws-sso continue on|off", "error"); break; } st.autoContinue = val === "on"; saveState(st); ctx.ui.notify(`AWS SSO auto-continue after a failed request: ${val}`, "info"); break; @@ -437,7 +527,7 @@ export default function (pi: ExtensionAPI) { const o = observe(); const t = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); 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()} · auto-continue: ${(st.autoContinue ?? true) ? "on" : "off"}`, + `keeper: ${enabled() ? "ON" : "off"}${process.env.PI_AWS_SSO_REFRESH ? ` (env PI_AWS_SSO_REFRESH=${process.env.PI_AWS_SSO_REFRESH})` : ""} · ${(() => { const r = resolveMode(st, process.env); return `${r.mode} (${r.source})`; })()} · push: ${describeTopic(process.env.PI_AWS_SSO_NTFY)} · auto-continue: ${(st.autoContinue ?? true) ? "on" : "off"}`, o.error ? `config: ✗ ${o.error}` : `sso-session: ${o.s!.sessionName} (profile chain ${o.s!.chain.join(" → ")})`, o.token ? `session: ${o.state.sessionEnded ? "ENDED" : "alive"}${o.state.anchor ? ` · started ≈ ${t(o.state.anchor.since)}` : ""} · 1-h token until ${t(o.token.accessExpiresAt)} (next check from ${t(o.token.accessExpiresAt - REFRESH_WINDOW_MS)})` : "token: none", lastProbe ? `last check: ${t(lastProbe.at)} · ${lastProbe.result}` : "last check: — (this pi process)", diff --git a/test/aws-sso.test.mjs b/test/aws-sso.test.mjs index 87fba9c..0fdca72 100644 --- a/test/aws-sso.test.mjs +++ b/test/aws-sso.test.mjs @@ -6,6 +6,7 @@ import assert from "node:assert/strict"; import { fingerprint, cacheKeyFor, parseIni, resolveSsoSession, parseDeviceOutput, isSsoExpiredError, reanchor, fmtLeft, probeDue, probeWith, REFRESH_WINDOW_MS, + normalizeMode, resolveMode, autoLoginGap, ntfyRequest, describeTopic, } from "../extensions/aws-sso.ts"; const START = "https://d-1234567890.awsapps.com/start"; @@ -144,6 +145,40 @@ test("reanchor: a new token BEFORE the end inherits it (silent renewal); AFTER t assert.deepEqual(reanchor({}, "aaaa1111", T0, T0), { refreshFp: "aaaa1111", since: T0 }); }); +test("mode: command beats .env beats default local; old names map; junk ignored", () => { + assert.deepEqual(resolveMode({}, {}), { mode: "local", source: "default" }); + assert.deepEqual(resolveMode({}, { PI_AWS_SSO_MODE: "remote" }), { mode: "remote", source: "env" }); + assert.deepEqual(resolveMode({ mode: "local" }, { PI_AWS_SSO_MODE: "remote" }), { mode: "local", source: "command" }); + assert.deepEqual(resolveMode({ mode: "device" }, {}), { mode: "remote", source: "command" }); + assert.deepEqual(resolveMode({ mode: "mac" }, {}), { mode: "local", source: "command" }); + assert.deepEqual(resolveMode({ mode: "bogus" }, { PI_AWS_SSO_MODE: " Remote " }), { mode: "remote", source: "env" }); + assert.equal(normalizeMode("headless"), null); +}); + +test("autoLoginGap: 30 min, doubling, capped at 4 h", () => { + assert.deepEqual([0, 1, 2, 3, 4, 9].map((n) => autoLoginGap(n) / 60e3), [30, 60, 120, 240, 240, 240]); +}); + +test("ntfyRequest: title/click/priority headers, bearer only when given, ASCII-safe; bad URLs refused", () => { + const r = ntfyRequest("https://ntfy.sh/aws-x7Kq2", undefined, { title: "AWS SSO approval needed (mbp)", message: "Code PCDB-WSGH — tap", click: `https://${HOST}/start/#/device?user_code=PCDB-WSGH`, priority: "high", tags: ["key"] }); + assert.equal(r.url, "https://ntfy.sh/aws-x7Kq2"); + assert.equal(r.init.method, "POST"); + assert.deepEqual(r.init.headers, { Title: "AWS SSO approval needed (mbp)", Priority: "high", Click: `https://${HOST}/start/#/device?user_code=PCDB-WSGH`, Tags: "key" }); + assert.equal(r.init.body, "Code PCDB-WSGH — tap"); // the body may be UTF-8, headers may not + assert.equal(ntfyRequest("https://n.example/t", "tk_abc", { title: "x", message: "y" }).init.headers.Authorization, "Bearer tk_abc"); + assert.equal(ntfyRequest("https://n.example/t", undefined, { title: "Förnyad ✓", message: "y" }).init.headers.Title, "F?rnyad ?"); + assert.throws(() => ntfyRequest("https://ntfy.sh/", undefined, { title: "x", message: "y" }), /no topic/); + assert.throws(() => ntfyRequest("file:///etc/passwd", undefined, { title: "x", message: "y" }), /http/); + assert.throws(() => ntfyRequest("not a url", undefined, { title: "x", message: "y" })); +}); + +test("describeTopic never prints the topic (it is the secret)", () => { + const d = describeTopic("https://ntfy.sh/aws-x7Kq2-very-secret"); + assert.match(d, /^ntfy\.sh \(topic [0-9a-f]{8}\)$/); + assert.ok(!d.includes("secret")); + assert.equal(describeTopic(undefined), "not configured"); +}); + test("fmtLeft", () => { assert.equal(fmtLeft(-5), "0m"); assert.equal(fmtLeft(25 * 60e3), "25m");