4e48456591
Overnight 2026-10-12: in the session's last hour AWS issued tokens capped at the end (00:18:48 local, 8 h after sign-in); the old 'expiresAt advanced' rule read their millisecond jitter as 'refreshed'. A refusal at 00:13 led to a login that inherited the ending session, and the 30-min wait then delayed the useful login to 00:47. Now: new full token = refreshed; new token issued for < 55 min = capped, its expiry is the end (session_end_seen, status 'until HH:MM'), login at end+30 s; nothing written = refused, login at once. anchor logs token_min/inherited from the cap, not a clock. 2 min between unprompted logins after a success; 30 min, 1 h, 2 h, 4 h after unanswered ones.
591 lines
32 KiB
TypeScript
591 lines
32 KiB
TypeScript
/**
|
||
* 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.
|
||
*
|
||
* 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=<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),
|
||
* or env PI_AWS_SSO_REFRESH=1. PI_AWS_SSO_REFRESH=0 is a hard off switch.
|
||
*
|
||
* Detection (AWS IdC docs, "Understanding authentication sessions"): the CLI holds an
|
||
* *application session* whose 1-hour token is refreshed "as long as the underlying
|
||
* user interactive session remains valid" (the browser's AWS sign-in, admin-capped;
|
||
* measured both 8 h and <= 6 h, so its length is NOT predictable). When it ends,
|
||
* the next refresh is refused. Inside the SDK's 5-minute window a refused refresh is
|
||
* silent: the SDK keeps returning the still-valid token, so requests only fail once
|
||
* that token runs out. The keeper makes the refresh attempt itself as soon as the
|
||
* window opens (same SDK, same cache file) and checks whether the cache advanced.
|
||
* If not, the session has ended: it logs in right away, while the old token still
|
||
* covers requests. A login while the browser sign-in is still valid is silent and
|
||
* inherits its end, which is why nothing is renewed on a clock. If a request fails
|
||
* anyway, it renews and can send "continue" so an unattended run resumes.
|
||
* 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, realpathSync, renameSync, statSync, writeFileSync, openSync, closeSync, unlinkSync } from "node:fs";
|
||
import { createRequire } from "node:module";
|
||
import { homedir, hostname } 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<string, Record<string, string>> {
|
||
const out: Record<string, Record<string, string>> = {};
|
||
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?: "local" | "remote" | "mac" | "device"; // mac/device: names before 2026-10-11
|
||
sessionHours?: number; // unused since 2026-10-12 (the end is read from AWS, not a clock)
|
||
autoContinue?: boolean;
|
||
/** since = when this refresh token appeared; kept across a login that inherited the session. */
|
||
anchor?: { refreshFp: string; since: number };
|
||
/** AWS refused a refresh, or a request failed on an expired session. */
|
||
sessionEnded?: boolean;
|
||
/** The session's real end, read from AWS: new tokens are capped there (seen in its last hour). */
|
||
sessionEndsAt?: number;
|
||
lastLogin?: { at: number; ok: boolean; mode: string; detail?: string };
|
||
}
|
||
|
||
export const DEFAULTS = { sessionHours: 8 };
|
||
|
||
/**
|
||
* A full access token lives 60 min (measured). In the session's last hour AWS still
|
||
* issues new tokens but caps them at the session's end, so a token issued for less
|
||
* than this marks the end (measured 2026-10-12: five refreshes in 00:13–00:17 all
|
||
* expired at 00:18:48 local, exactly 8 h after the browser sign-in).
|
||
*/
|
||
export const FULL_TOKEN_MS = 55 * 60e3;
|
||
/** Log in this long after the end, so the login cannot inherit the ending session. */
|
||
export const AFTER_END_MS = 30e3;
|
||
|
||
export type TokenView = { accessExpiresAt: number; mtime: number };
|
||
|
||
/** The session's end if this token was capped at it, else null (file mtime = issue time). */
|
||
export function sessionEndFrom(t: TokenView | null): number | null {
|
||
if (!t || !Number.isFinite(t.accessExpiresAt) || !Number.isFinite(t.mtime)) return null;
|
||
return t.accessExpiresAt - t.mtime < FULL_TOKEN_MS ? t.accessExpiresAt : null;
|
||
}
|
||
|
||
/** The AWS SDK's own refresh window (token-providers EXPIRE_WINDOW_MS). */
|
||
export const REFRESH_WINDOW_MS = 5 * 60e3;
|
||
|
||
/**
|
||
* Should the keeper make a refresh attempt now? Only inside the SDK's window (outside
|
||
* it the SDK does not refresh, so an attempt proves nothing). Every 60 s while the
|
||
* token is still valid; every 10 min once it has run out (laptop slept, login pending).
|
||
*/
|
||
export function probeDue(accessExpiresAt: number, now: number, lastProbeAt: number): boolean {
|
||
const left = accessExpiresAt - now;
|
||
if (left > REFRESH_WINDOW_MS) return false;
|
||
return now - lastProbeAt >= (left > 0 ? 60e3 : 10 * 60e3);
|
||
}
|
||
|
||
export type ProbeResult = { result: "fresh" | "refreshed" | "capped" | "refused" | "unavailable"; before: number; after: number; endsAt?: number; error?: string };
|
||
|
||
/**
|
||
* One refresh attempt through the SDK's own token provider, judged by the cache file:
|
||
* refreshed = a new full token was written; capped = a new token was written but AWS
|
||
* capped it at the session's end (endsAt); refused = nothing was written (session
|
||
* over, or offline). "unavailable" = the SDK could not be loaded: says nothing about
|
||
* the session. The token the provider returns is dropped unread.
|
||
* (A bare "expiresAt advanced" test is wrong: capped tokens differ by milliseconds.)
|
||
*/
|
||
export async function probeWith(loadFromSso: () => (init: object) => () => Promise<unknown>, readTok: () => TokenView | null, profile: string, now: () => number = Date.now): Promise<ProbeResult> {
|
||
const b = readTok();
|
||
const before = b?.accessExpiresAt ?? 0;
|
||
if (before - now() > REFRESH_WINDOW_MS) return { result: "fresh", before, after: before };
|
||
let provider: (init: object) => () => Promise<unknown>;
|
||
try { provider = loadFromSso(); } catch (e) { return { result: "unavailable", before, after: before, error: (e as Error).message }; }
|
||
let error: string | undefined;
|
||
try { await provider({ profile, ignoreCache: true })(); } catch (e) { error = (e as Error).message; }
|
||
const a = readTok();
|
||
const after = a?.accessExpiresAt ?? 0;
|
||
if (!a || !b || a.mtime === b.mtime) return { result: "refused", before, after, error };
|
||
const endsAt = sessionEndFrom(a);
|
||
return endsAt === null ? { result: "refreshed", before, after } : { result: "capped", before, after, endsAt };
|
||
}
|
||
|
||
/**
|
||
* Anchor update when the refresh token changed (null = unchanged). inherited = the new
|
||
* token is capped at a session end, i.e. the login joined the ending session (silent
|
||
* renewal) instead of starting one. Without a cap there is no evidence either way, so
|
||
* the new token's first sight counts as the start.
|
||
*/
|
||
export function reanchor(state: State, refreshFp: string, firstSeen: number, inherited: boolean): State["anchor"] | null {
|
||
const a = state.anchor;
|
||
if (a && a.refreshFp === refreshFp) return null;
|
||
return { refreshFp, since: a && inherited ? a.since : firstSeen };
|
||
}
|
||
|
||
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 since the last unprompted login before the next one: 2 min after a success (only
|
||
* a guard against loops: the login just after a known end must not wait), then after
|
||
* unanswered logins 30 min, 1 h, 2 h, and at most 4 h.
|
||
*/
|
||
export function autoLoginGap(failures: number): number {
|
||
if (failures <= 0) return 2 * 60e3;
|
||
return Math.min(30 * 60e3 * 2 ** (failures - 1), 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"; }
|
||
}
|
||
|
||
const hhmm = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||
|
||
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<string, unknown> = {}) {
|
||
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; }
|
||
}
|
||
|
||
/** The SDK pi itself uses (resolved from pi's install, not from this file's location). */
|
||
let fromSsoCached: ((init: object) => () => Promise<unknown>) | null = null;
|
||
function loadFromSso() {
|
||
if (fromSsoCached) return fromSsoCached;
|
||
const bases = [process.argv[1], "/usr/lib/node_modules/@earendil-works/pi-coding-agent/package.json"].filter(Boolean) as string[];
|
||
let last: unknown;
|
||
for (const b of bases) {
|
||
try { fromSsoCached = createRequire(realpathSync(b))("@aws-sdk/token-providers").fromSso; if (fromSsoCached) return fromSsoCached; }
|
||
catch (e) { last = e; }
|
||
}
|
||
throw last instanceof Error ? last : new Error("@aws-sdk/token-providers not found");
|
||
}
|
||
|
||
/** 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 */ } }
|
||
|
||
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 };
|
||
|
||
async function push(p: Push): Promise<void> {
|
||
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 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 as string[], { env: { ...process.env, AWS_PAGER: "" }, stdio: ["ignore", "pipe", "pipe"] });
|
||
const onData = (b: Buffer) => {
|
||
out += b.toString();
|
||
if (mode === "remote" && !shown) {
|
||
const d = parseDeviceOutput(out, s.startUrl);
|
||
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) => {
|
||
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 });
|
||
done({ ok, detail });
|
||
});
|
||
child.on("error", (e) => done({ ok: false, detail: e.message }));
|
||
});
|
||
}
|
||
|
||
// ── extension ─────────────────────────────────────────────────────────────────
|
||
|
||
export default function (pi: ExtensionAPI) {
|
||
let timer: ReturnType<typeof setInterval> | null = null;
|
||
let inFlight: Promise<{ ok: boolean; detail: string }> | null = null;
|
||
let ui: Ui | null = null;
|
||
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;
|
||
|
||
/** 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, token: null, state: loadState() };
|
||
const token = readToken(s.sessionName);
|
||
const state = loadState();
|
||
if (token) {
|
||
const end = sessionEndFrom(token);
|
||
let dirty = false;
|
||
// 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()), end !== null);
|
||
if (next) {
|
||
state.anchor = next; state.sessionEnded = false; dirty = true;
|
||
logEvent("anchor", { refreshFp: next.refreshFp, since: new Date(next.since).toISOString(), token_min: Math.round((token.accessExpiresAt - token.mtime) / 60e3), inherited: end !== null });
|
||
}
|
||
// A capped token shows the session's end; a full one proves >= 55 min left, so drop a stale end.
|
||
if (end !== null && Math.abs((state.sessionEndsAt ?? 0) - end) > 60e3) {
|
||
state.sessionEndsAt = end; dirty = true;
|
||
logEvent("session_end_seen", { at: new Date(end).toISOString() });
|
||
} else if (end === null && state.sessionEndsAt !== undefined) { delete state.sessionEndsAt; dirty = true; }
|
||
if (dirty) saveState(state);
|
||
}
|
||
return { s, error: null, token, state };
|
||
}
|
||
|
||
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) { ui.setStatus("aws-sso", `AWS ✗ ${o.error}`); return; }
|
||
if (!o.token) { ui.setStatus("aws-sso", "AWS ✗ not logged in"); return; }
|
||
const end = o.state.sessionEndsAt;
|
||
if (o.state.sessionEnded || (end !== undefined && Date.now() >= end)) { ui.setStatus("aws-sso", "AWS ✗ session ended"); return; }
|
||
if (end !== undefined) { ui.setStatus("aws-sso", `AWS ✓ until ${hhmm(end)}`); return; }
|
||
ui.setStatus("aws-sso", o.state.anchor ? `AWS ✓ since ${hhmm(o.state.anchor.since)}` : "AWS ✓");
|
||
}
|
||
|
||
/** Refresh attempt inside the SDK window; on refusal, log in while the old token still works. */
|
||
async function probe(why: string): Promise<ProbeResult | null> {
|
||
const o = observe();
|
||
if (!o.s || !o.token || probing) return null;
|
||
probing = true;
|
||
lastProbeAt = Date.now();
|
||
try {
|
||
const r = await probeWith(loadFromSso, () => readToken(o.s!.sessionName), o.s.chain[o.s.chain.length - 1]);
|
||
lastProbe = { at: lastProbeAt, result: r.result === "capped" && r.endsAt ? `capped: session ends ${hhmm(r.endsAt)}` : r.result };
|
||
if (r.result !== "fresh") logEvent("probe", { why, result: r.result, left_s: Math.round((r.before - lastProbeAt) / 1000), after: r.after ? new Date(r.after).toISOString() : null, ends: r.endsAt ? new Date(r.endsAt).toISOString() : undefined, error: r.result === "refused" ? r.error?.slice(0, 160) : undefined });
|
||
if (r.result === "refused") { const st = loadState(); st.sessionEnded = true; saveState(st); }
|
||
return r;
|
||
} finally { probing = false; render(); }
|
||
}
|
||
|
||
async function login(reason: string): Promise<boolean> {
|
||
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 { 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, {
|
||
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();
|
||
render();
|
||
if (!o.token) return;
|
||
// The end is known (AWS capped the tokens there): log in just after it. Before it a
|
||
// login would only inherit the ending session (measured); the capped token covers until then.
|
||
const end = o.state.sessionEndsAt;
|
||
if (end !== undefined && Date.now() >= end + AFTER_END_MS) { autoLogin(`session ended at ${hhmm(end)}`); return; }
|
||
if (!probeDue(o.token.accessExpiresAt, Date.now(), lastProbeAt)) return;
|
||
const r = await probe("refresh window");
|
||
if (r?.result === "refused") autoLogin("AWS refused a refresh");
|
||
}
|
||
|
||
/** Unprompted login. Back-off after unanswered ones (autoLoginGap) so they do not pile up
|
||
* tabs or pushes overnight; a prompt, a failed request or /aws-sso now still log in at once. */
|
||
function autoLogin(reason: string) {
|
||
if (Date.now() - lastAutoLogin < autoLoginGap(autoLoginFailures)) return;
|
||
lastAutoLogin = Date.now();
|
||
void login(reason).then((ok) => { if (!ok) autoLoginFailures++; });
|
||
}
|
||
|
||
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(() => void tick(), 20e3);
|
||
if (enabled()) void 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;
|
||
if (inFlight) { await inFlight; return; }
|
||
let o = observe();
|
||
if (!o.token) { await login("not logged in"); return; }
|
||
// Token in (or past) the refresh window: find out now whether the session still exists.
|
||
if (o.token.accessExpiresAt - Date.now() <= REFRESH_WINDOW_MS && !o.state.sessionEnded) await probe("before prompt");
|
||
o = observe();
|
||
const end = o.state.sessionEndsAt;
|
||
if (o.state.sessionEnded || (end !== undefined && Date.now() >= end)) await login("session ended");
|
||
});
|
||
|
||
// A request failed on expired credentials: renew, then (optionally) resume the run.
|
||
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");
|
||
});
|
||
|
||
pi.registerCommand("aws-sso", {
|
||
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 (${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 "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;
|
||
case "status": default: {
|
||
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})` : ""} · ${(() => { 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 ? ` since ≈ ${t(o.state.anchor.since)}` : ""} · ${o.state.sessionEndsAt !== undefined ? `ends ${t(o.state.sessionEndsAt)} (AWS caps tokens there) → login just after` : "end not visible yet (AWS shows it in the last hour)"}` : "token: none",
|
||
o.token ? `token: valid until ${t(o.token.accessExpiresAt)} · next check from ${t(o.token.accessExpiresAt - REFRESH_WINDOW_MS)}` : "",
|
||
lastProbe ? `last check: ${t(lastProbe.at)} · ${lastProbe.result}` : "last check: — (this pi process)",
|
||
st.lastLogin ? `last login: ${new Date(st.lastLogin.at).toLocaleString()} · ${st.lastLogin.ok ? "ok" : "FAILED: " + st.lastLogin.detail} (${normalizeMode(st.lastLogin.mode) ?? st.lastLogin.mode})` : "last login: —",
|
||
].filter(Boolean);
|
||
ctx.ui.notify(lines.join("\n"), "info");
|
||
}
|
||
}
|
||
},
|
||
});
|
||
}
|