aws-sso: detect the real session end by a refresh attempt, renew before requests fail
Measured: a session lasted <= 6 h (2026-10-11), not the 8 h assumed, so a clock cannot predict the end. token-providers 3.1127.0 refreshes only inside a 5-min window and, on refusal, silently returns the still-valid token. The keeper now makes that attempt itself (pi's own SDK, same cache) when the window opens, and if expiresAt did not advance, logs in while the old token still works. Clock-based renewal, signed-in and hours commands removed.
This commit is contained in:
+107
-71
@@ -13,24 +13,25 @@
|
||||
* 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.
|
||||
*
|
||||
* Timing (AWS IdC docs, "Understanding authentication sessions"): the CLI holds an
|
||||
* *application session* whose 1-hour token the SDK refreshes by itself "as long as
|
||||
* the underlying user interactive session remains valid". The interactive session
|
||||
* is the browser's AWS sign-in, admin-capped (8 h measured). A login while it is
|
||||
* still valid completes silently but INHERITS its end — renewing early gains
|
||||
* nothing. A login just AFTER it ends makes the browser sign in again (through the
|
||||
* IdP, silent while "stay signed in" holds) and starts a fresh interactive session.
|
||||
* So the keeper renews a couple of minutes after the estimated end, while the CLI's
|
||||
* last 1-hour token still works, and if a request fails anyway it renews and can
|
||||
* send "continue" so an unattended run resumes.
|
||||
* The session start is not stored anywhere: it is inferred from refresh-token
|
||||
* changes (a change after the end = new session; before the end = inherited).
|
||||
* 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, 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 { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -104,7 +105,6 @@ export interface State {
|
||||
enabled?: boolean;
|
||||
mode?: "mac" | "device";
|
||||
sessionHours?: number;
|
||||
renewAfterMinutes?: number;
|
||||
autoContinue?: boolean;
|
||||
/** since = start of the browser's interactive session (estimated). */
|
||||
anchor?: { refreshFp: string; since: number };
|
||||
@@ -113,24 +113,39 @@ export interface State {
|
||||
lastLogin?: { at: number; ok: boolean; mode: string; detail?: string };
|
||||
}
|
||||
|
||||
export const DEFAULTS = { sessionHours: 8, renewAfterMinutes: 2 };
|
||||
export const DEFAULTS = { sessionHours: 8 };
|
||||
|
||||
/** The AWS SDK's own refresh window (token-providers EXPIRE_WINDOW_MS). */
|
||||
export const REFRESH_WINDOW_MS = 5 * 60e3;
|
||||
|
||||
/**
|
||||
* Where the session stands (endsAt = estimated end of the browser's sign-in).
|
||||
* none – no cached token
|
||||
* expired – past the end AND the CLI's 1-hour token has run out too
|
||||
* due – past the end + renewAfterMinutes: renew now (a fresh sign-in)
|
||||
* ok – before that
|
||||
* 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 assess(state: State, token: { refreshFp: string; accessExpiresAt: number } | null, now: number) {
|
||||
const hours = state.sessionHours ?? DEFAULTS.sessionHours;
|
||||
const after = (state.renewAfterMinutes ?? DEFAULTS.renewAfterMinutes) * 60e3;
|
||||
if (!token) return { status: "none" as const, endsAt: null as number | null };
|
||||
const since = state.anchor && state.anchor.refreshFp === token.refreshFp ? state.anchor.since : null;
|
||||
const endsAt = since === null ? null : since + hours * 3600e3;
|
||||
if (endsAt !== null && now >= endsAt && now >= token.accessExpiresAt) return { status: "expired" as const, endsAt };
|
||||
if (endsAt !== null && now >= endsAt + after) return { status: "due" as const, endsAt };
|
||||
return { status: "ok" as const, endsAt };
|
||||
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" | "refused" | "unavailable"; before: number; after: number; error?: string };
|
||||
|
||||
/**
|
||||
* One refresh attempt through the SDK's own token provider, judged by the cache:
|
||||
* refreshed = expiresAt advanced; refused = it did not (session over, or offline).
|
||||
* "unavailable" = the SDK could not be loaded: says nothing about the session.
|
||||
* The token the provider returns is dropped unread.
|
||||
*/
|
||||
export async function probeWith(loadFromSso: () => (init: object) => () => Promise<unknown>, readExpiresAt: () => number, profile: string, now: () => number = Date.now): Promise<ProbeResult> {
|
||||
const before = readExpiresAt();
|
||||
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 after = readExpiresAt();
|
||||
return { result: after > before ? "refreshed" : "refused", before, after, error };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,6 +206,19 @@ function readToken(sessionName: string): { refreshFp: string; accessExpiresAt: n
|
||||
} 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 });
|
||||
@@ -257,14 +285,17 @@ 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 warnedEnd: number | null = null;
|
||||
let probing = false;
|
||||
let lastProbeAt = 0;
|
||||
let lastAutoLogin = 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, a: null as ReturnType<typeof assess> | null, token: null };
|
||||
if ("error" in s) return { s: null, error: s.error, token: null, state: loadState() };
|
||||
const token = readToken(s.sessionName);
|
||||
const state = loadState();
|
||||
if (token) {
|
||||
@@ -276,7 +307,7 @@ export default function (pi: ExtensionAPI) {
|
||||
logEvent("anchor", { refreshFp: next.refreshFp, since: new Date(next.since).toISOString(), inherited });
|
||||
}
|
||||
}
|
||||
return { s, error: null, a: assess(state, token, Date.now()), token };
|
||||
return { s, error: null, token, state };
|
||||
}
|
||||
|
||||
function render() {
|
||||
@@ -284,12 +315,26 @@ export default function (pi: ExtensionAPI) {
|
||||
if (!enabled()) { ui.setStatus("aws-sso", undefined); return; }
|
||||
if (inFlight) return; // the login path owns the status while it runs
|
||||
const o = observe();
|
||||
if (o.error || !o.a) { ui.setStatus("aws-sso", `AWS ✗ ${o.error}`); return; }
|
||||
const left = o.a.endsAt === null ? null : o.a.endsAt - Date.now();
|
||||
const txt = o.a.status === "none" ? "AWS ✗ not logged in"
|
||||
: o.a.status === "expired" ? "AWS ✗ session expired"
|
||||
: o.a.status === "due" ? "AWS ⚠ renewing" : left === null ? "AWS ✓" : `AWS ✓ ${fmtLeft(left)}`;
|
||||
ui.setStatus("aws-sso", txt);
|
||||
if (o.error) { ui.setStatus("aws-sso", `AWS ✗ ${o.error}`); return; }
|
||||
if (!o.token) { ui.setStatus("aws-sso", "AWS ✗ not logged in"); return; }
|
||||
if (o.state.sessionEnded) { ui.setStatus("aws-sso", "AWS ✗ session ended"); return; }
|
||||
const since = o.state.anchor ? new Date(o.state.anchor.since).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : null;
|
||||
ui.setStatus("aws-sso", since ? `AWS ✓ since ${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)?.accessExpiresAt ?? 0, o.s.chain[o.s.chain.length - 1]);
|
||||
lastProbe = { at: lastProbeAt, result: 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, error: r.result === "refreshed" ? undefined : r.error?.slice(0, 160) });
|
||||
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> {
|
||||
@@ -313,15 +358,17 @@ export default function (pi: ExtensionAPI) {
|
||||
} finally { inFlight = null; unlock(); render(); }
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!enabled()) { render(); return; }
|
||||
async function tick() {
|
||||
if (!enabled() || inFlight || probing) { render(); return; }
|
||||
const o = observe();
|
||||
render();
|
||||
if (!o.a || inFlight) return;
|
||||
// Just after the end: one attempt per session end, so an ignored login does not loop.
|
||||
if ((o.a.status === "due" || o.a.status === "expired") && o.a.endsAt !== null && warnedEnd !== o.a.endsAt) {
|
||||
warnedEnd = o.a.endsAt;
|
||||
void login("session ended");
|
||||
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) {
|
||||
lastAutoLogin = Date.now();
|
||||
void login("session ended (refresh refused)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,8 +376,8 @@ export default function (pi: ExtensionAPI) {
|
||||
ui = ctx.hasUI ? ctx.ui : null;
|
||||
if (!ui) return; // print/json children: never start logins or timers
|
||||
render();
|
||||
timer = setInterval(tick, 60e3);
|
||||
if (enabled()) tick();
|
||||
timer = setInterval(() => void tick(), 20e3);
|
||||
if (enabled()) void tick();
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
@@ -342,9 +389,13 @@ export default function (pi: ExtensionAPI) {
|
||||
// Before a prompt goes out: if the session is already gone, log in first and wait.
|
||||
pi.on("before_agent_start", async () => {
|
||||
if (!ui || !enabled()) return;
|
||||
const o = observe();
|
||||
if (o.a?.status === "expired" || o.a?.status === "none") await login("session expired");
|
||||
else if (inFlight) await inFlight;
|
||||
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();
|
||||
if (o.state.sessionEnded) await login("session ended");
|
||||
});
|
||||
|
||||
// A request failed on expired credentials: renew, then (optionally) resume the run.
|
||||
@@ -367,13 +418,13 @@ export default function (pi: ExtensionAPI) {
|
||||
});
|
||||
|
||||
pi.registerCommand("aws-sso", {
|
||||
description: "AWS SSO session keeper: on | off | status | now | mode mac|device | continue on|off | signed-in HH:MM | hours <n>",
|
||||
description: "AWS SSO session keeper: on | off | status | now | mode mac|device | continue on|off",
|
||||
handler: async (args, ctx) => {
|
||||
ui = ctx.ui;
|
||||
const [cmd, val] = String(args ?? "").trim().split(/\s+/);
|
||||
const st = loadState();
|
||||
switch (cmd || "status") {
|
||||
case "on": st.enabled = true; saveState(st); if (!timer) timer = setInterval(tick, 60e3); tick(); ctx.ui.notify(`AWS SSO keeper ON (mode ${st.mode ?? defaultMode()}).`, "info"); break;
|
||||
case "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 "off": st.enabled = false; saveState(st); render(); ctx.ui.notify("AWS SSO keeper OFF.", "info"); break;
|
||||
case "now": await login("requested"); break;
|
||||
case "mode":
|
||||
@@ -382,29 +433,14 @@ export default function (pi: ExtensionAPI) {
|
||||
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 "signed-in": {
|
||||
// Correct the estimate: when did the browser last sign in through the IdP?
|
||||
const hm = String(val ?? "").match(/^(\d{1,2}):(\d{2})$/);
|
||||
const o = observe();
|
||||
if (!hm || !o.token) { ctx.ui.notify("Usage: /aws-sso signed-in HH:MM (local time, today; needs a cached token)", "error"); break; }
|
||||
const d = new Date(); d.setHours(Number(hm[1]), Number(hm[2]), 0, 0);
|
||||
if (d.getTime() > Date.now()) d.setDate(d.getDate() - 1);
|
||||
const s2 = loadState(); s2.anchor = { refreshFp: o.token.refreshFp, since: d.getTime() }; s2.sessionEnded = false; saveState(s2);
|
||||
logEvent("anchor_set", { since: d.toISOString() });
|
||||
render(); ctx.ui.notify(`AWS SSO: session start set to ${d.toLocaleString()}.`, "info"); break;
|
||||
}
|
||||
case "hours": {
|
||||
const n = Number(val);
|
||||
if (!(n > 0 && n <= 24 * 90)) { ctx.ui.notify("Usage: /aws-sso hours <session length in hours>", "error"); break; }
|
||||
st.sessionHours = n; saveState(st); render(); ctx.ui.notify(`AWS SSO session length set to ${n} h.`, "info"); break;
|
||||
}
|
||||
case "status": default: {
|
||||
const o = observe();
|
||||
const 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()} · session length: ${st.sessionHours ?? DEFAULTS.sessionHours} h · 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})` : ""} · mode: ${st.mode ?? defaultMode()} · auto-continue: ${(st.autoContinue ?? true) ? "on" : "off"}`,
|
||||
o.error ? `config: ✗ ${o.error}` : `sso-session: ${o.s!.sessionName} (profile chain ${o.s!.chain.join(" → ")})`,
|
||||
o.a ? `session: ${o.a.status}${o.a.endsAt ? ` · browser sign-in ${new Date(o.a.endsAt - (st.sessionHours ?? DEFAULTS.sessionHours) * 3600e3).toLocaleTimeString()} → ends ≈ ${new Date(o.a.endsAt).toLocaleTimeString()} (${fmtLeft(o.a.endsAt - Date.now())} left) · renew ≈ ${new Date(o.a.endsAt + (st.renewAfterMinutes ?? DEFAULTS.renewAfterMinutes) * 60e3).toLocaleTimeString()}` : " · start unknown"}` : "",
|
||||
o.token ? `token: refresh ${o.token.refreshFp} · access valid until ${new Date(o.token.accessExpiresAt).toLocaleTimeString()}` : "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)",
|
||||
st.lastLogin ? `last login: ${new Date(st.lastLogin.at).toLocaleString()} · ${st.lastLogin.ok ? "ok" : "FAILED: " + st.lastLogin.detail} (${st.lastLogin.mode})` : "last login: —",
|
||||
].filter(Boolean);
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
|
||||
Reference in New Issue
Block a user