aws-sso: explicit local/remote mode (default local) and ntfy push for approvals

The mode cannot be inferred: Linux/Windows hosts can have a browser and an
always-on Mac can serve headless. /aws-sso local|remote, PI_AWS_SSO_MODE in
.env; a command-set mode wins; old mac/device names map. PI_AWS_SSO_NTFY
pushes the device link (tap to approve), a heads-up when a local login waits
>60 s, failures, and 'renewed'. Topic shown only as host + fingerprint.
Unprompted logins back off 30 min doubling to 4 h; a run that stopped on an
expired session resumes after any later successful login.
This commit is contained in:
pi@mbp-m1-2020
2026-10-11 22:50:31 +02:00
parent fdee3e42d6
commit 0717a5476f
4 changed files with 204 additions and 62 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ extensions/
mcp-loader.ts # Generic MCP server loader + /mcp slash command 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 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 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) 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/ install.sh # Idempotent installer — symlinks extensions/ into ~/.pi/agent/extensions/
package.json # pi package manifest — enables `pi install /path` as an alternative package.json # pi package manifest — enables `pi install /path` as an alternative
+25 -8
View File
@@ -366,13 +366,28 @@ while it is signed in). Off until you enable it.
| Command | Effect | | Command | Effect |
|---|---| |---|---|
| `/aws-sso on` / `off` | Enable/disable (persisted in `~/.pi/agent/aws-sso/state.json`) | | `/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 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 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 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 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 and the renewal succeeded, send `continue` (default on) | | `/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/<topic>` | 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 **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 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. 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 happen at most once per 10 min once the token has run out). Unprompted logins back off (30 min, doubling
30 min, so a login waiting for you does not pile up tabs. `before_agent_start` 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 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
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. Only interactive sessions act; print/JSON children (`task`, `fork`) stay inert.
A lock file stops several pi processes from starting several logins. A lock file stops several pi processes from starting several logins.
+143 -53
View File
@@ -3,12 +3,18 @@
* `awsAuthRefresh` does: when the session nears its end (or has expired), run * `awsAuthRefresh` does: when the session nears its end (or has expired), run
* `aws sso login` for you. You still approve the login yourself. * `aws sso login` for you. You still approve the login yourself.
* *
* Modes (`/aws-sso mode …`): * Where the login runs — `/aws-sso local` (default) or `/aws-sso remote`, or
* mac – run `aws sso login` on the Docker host over SSH (pi-devbox), so your * PI_AWS_SSO_MODE=local|remote in .env (a command-set mode wins over .env):
* own browser opens. For when you sit at the laptop. * local – the machine running Docker has your browser: run `aws sso login` there
* device – run `aws sso login --use-device-code --no-browser` here and show the * over SSH (pi-devbox ~/.ssh-local/config). While the browser is signed
* link + code in pi. Approve on any device (phone, other computer). * in this is silent. Verified on a macOS host; Linux/Windows hosts may
* For SSH/tmux sessions. * 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=<ntfy topic URL> (+ 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), * 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. * 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 { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync, openSync, closeSync, unlinkSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync, openSync, closeSync, unlinkSync } from "node:fs";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { homedir } from "node:os"; import { homedir, hostname } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
// ── pure helpers (tested in test/aws-sso.test.mjs) ───────────────────────────── // ── pure helpers (tested in test/aws-sso.test.mjs) ─────────────────────────────
@@ -103,7 +109,7 @@ export function isSsoExpiredError(message: string): boolean {
export interface State { export interface State {
enabled?: boolean; enabled?: boolean;
mode?: "mac" | "device"; mode?: "local" | "remote" | "mac" | "device"; // mac/device: names before 2026-10-11
sessionHours?: number; sessionHours?: number;
autoContinue?: boolean; autoContinue?: boolean;
/** since = start of the browser's interactive session (estimated). */ /** 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 }; 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<string, string | undefined>): { 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<string, string>; 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/<topic>)");
const ascii = (x: string) => x.replace(/[^\x20-\x7e]/g, "?");
const headers: Record<string, string> = { 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 { export function fmtLeft(ms: number): string {
if (ms <= 0) return "0m"; if (ms <= 0) return "0m";
const m = Math.round(ms / 60e3); 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 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 }; 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. */ async function push(p: Push): Promise<void> {
function runLogin(mode: "mac" | "device", s: SsoSession, ui: Ui | null, timeoutMs = 10 * 60e3): Promise<{ ok: boolean; detail: string }> { 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 before = readToken(s.sessionName)?.refreshFp ?? "-";
const args = mode === "mac" const session = s.sessionName.replace(/[^\w.-]/g, "");
// Login shell on the host: a non-interactive ssh PATH may not include aws. // 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", const hostCmd = (process.env.PI_AWS_SSO_LOCAL_CMD || `exec "\${SHELL:-/bin/sh}" -lc 'aws sso login --sso-session {session}'`).replaceAll("{session}", session);
`exec "\${SHELL:-/bin/zsh}" -lc 'aws sso login --sso-session ${s.sessionName.replace(/[^\w.-]/g, "")}'`] const [cmd, args] = mode === "local"
: ["sso", "login", "--sso-session", s.sessionName, "--use-device-code", "--no-browser"]; ? ["ssh", ["-F", SSH_CONFIG, "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", process.env.PI_AWS_SSO_SSH_HOST || "host", hostCmd]]
const cmd = mode === "mac" ? "ssh" : "aws"; : ["aws", ["sso", "login", "--sso-session", s.sessionName, "--use-device-code", "--no-browser"]];
logEvent("login_start", { mode, before }); logEvent("login_start", { mode, before });
return new Promise((resolve) => { return new Promise((resolve) => {
let out = ""; let out = "";
let shown = false; 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) => { const onData = (b: Buffer) => {
out += b.toString(); out += b.toString();
if (mode === "device" && !shown) { if (mode === "remote" && !shown) {
const d = parseDeviceOutput(out, s.startUrl); const d = parseDeviceOutput(out, s.startUrl);
if (d) { if (d) { shown = true; logEvent("device_code_shown", { code: d.code }); hooks.onDeviceCode(d.code, d.url); }
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.stdout.on("data", onData);
child.stderr.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 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) => { child.on("close", (rc) => {
clearTimeout(timer);
const after = readToken(s.sessionName)?.refreshFp ?? "-"; const after = readToken(s.sessionName)?.refreshFp ?? "-";
const ok = rc === 0 && after !== before; const ok = rc === 0 && after !== before;
const detail = ok ? "new session" : (out.trim().split("\n").filter(Boolean).pop() ?? `exit ${rc}`).slice(0, 200); 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 }); 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 probing = false;
let lastProbeAt = 0; let lastProbeAt = 0;
let lastAutoLogin = 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; 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; 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(); const o = observe();
if (!o.s) { ui?.notify(`AWS SSO: ${o.error}`, "error"); return false; } 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; } 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 } = resolveMode(loadState(), process.env);
const mode = state.mode ?? defaultMode(); let pushed = false;
ui?.setStatus("aws-sso", mode === "mac" ? "AWS ⏳ approve in your browser" : "AWS ⏳ starting device login…"); ui?.setStatus("aws-sso", mode === "local" ? "AWS ⏳ login 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"); 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 }); 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 { try {
const r = await inFlight; const r = await inFlight;
const st = loadState(); const st = loadState();
st.lastLogin = { at: Date.now(), ok: r.ok, mode, detail: r.ok ? undefined : r.detail }; st.lastLogin = { at: Date.now(), ok: r.ok, mode, detail: r.ok ? undefined : r.detail };
saveState(st); saveState(st);
ui?.notify(r.ok ? "AWS SSO: login complete." : `AWS SSO login did not complete: ${r.detail}`, r.ok ? "info" : "error"); 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; return r.ok;
} finally { inFlight = null; unlock(); render(); } } 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() { async function tick() {
if (!enabled() || inFlight || probing) { render(); return; } if (!enabled() || inFlight || probing) { render(); return; }
const o = observe(); const o = observe();
@@ -365,10 +454,11 @@ export default function (pi: ExtensionAPI) {
if (!o.token) return; if (!o.token) return;
if (!probeDue(o.token.accessExpiresAt, Date.now(), lastProbeAt)) return; if (!probeDue(o.token.accessExpiresAt, Date.now(), lastProbeAt)) return;
const r = await probe("refresh window"); 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. // Unprompted logins back off (30 min, doubling, max 4 h) so an unanswered one does not
if (r?.result === "refused" && Date.now() - lastAutoLogin >= 30 * 60e3) { // 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(); 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. // A request failed on expired credentials: renew, then (optionally) resume the run.
let lastAutoContinue = 0;
pi.on("message_end", async (e) => { pi.on("message_end", async (e) => {
const m = e.message as { role?: string; errorMessage?: string }; const m = e.message as { role?: string; errorMessage?: string };
if (!ui || !enabled() || m.role !== "assistant" || !m.errorMessage || !isSsoExpiredError(m.errorMessage)) return; if (!ui || !enabled() || m.role !== "assistant" || !m.errorMessage || !isSsoExpiredError(m.errorMessage)) return;
const st = loadState(); st.sessionEnded = true; saveState(st); const st = loadState(); st.sessionEnded = true; saveState(st);
pendingContinue = true;
logEvent("request_failed_expired"); logEvent("request_failed_expired");
void login("a request failed: session expired").then((ok) => { void login("a request failed: session expired");
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");
});
}); });
pi.registerCommand("aws-sso", { 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) => { handler: async (args, ctx) => {
ui = ctx.ui; ui = ctx.ui;
const [cmd, val] = String(args ?? "").trim().split(/\s+/); const [cmd, val] = String(args ?? "").trim().split(/\s+/);
const st = loadState(); const st = loadState();
switch (cmd || "status") { 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 "off": st.enabled = false; saveState(st); render(); ctx.ui.notify("AWS SSO keeper OFF.", "info"); break;
case "now": await login("requested"); break; case "now": await login("requested"); break;
case "mode": case "local": case "remote": case "mode": {
if (val !== "mac" && val !== "device") { ctx.ui.notify("Usage: /aws-sso mode mac|device", "error"); break; } const m = normalizeMode(cmd === "mode" ? val : cmd);
st.mode = val; saveState(st); ctx.ui.notify(`AWS SSO mode: ${val}`, "info"); break; 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": case "continue":
if (val !== "on" && val !== "off") { ctx.ui.notify("Usage: /aws-sso continue on|off", "error"); break; } 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; 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 o = observe();
const t = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); const t = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const lines = [ 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.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", 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)", lastProbe ? `last check: ${t(lastProbe.at)} · ${lastProbe.result}` : "last check: — (this pi process)",
+35
View File
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import { import {
fingerprint, cacheKeyFor, parseIni, resolveSsoSession, parseDeviceOutput, fingerprint, cacheKeyFor, parseIni, resolveSsoSession, parseDeviceOutput,
isSsoExpiredError, reanchor, fmtLeft, probeDue, probeWith, REFRESH_WINDOW_MS, isSsoExpiredError, reanchor, fmtLeft, probeDue, probeWith, REFRESH_WINDOW_MS,
normalizeMode, resolveMode, autoLoginGap, ntfyRequest, describeTopic,
} from "../extensions/aws-sso.ts"; } from "../extensions/aws-sso.ts";
const START = "https://d-1234567890.awsapps.com/start"; 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 }); 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", () => { test("fmtLeft", () => {
assert.equal(fmtLeft(-5), "0m"); assert.equal(fmtLeft(-5), "0m");
assert.equal(fmtLeft(25 * 60e3), "25m"); assert.equal(fmtLeft(25 * 60e3), "25m");