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:
@@ -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
|
||||
|
||||
@@ -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/<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
|
||||
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.
|
||||
|
||||
|
||||
+142
-52
@@ -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=<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.
|
||||
@@ -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<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 {
|
||||
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<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 args = mode === "mac"
|
||||
const session = s.sessionName.replace(/[^\w.-]/g, "");
|
||||
// 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 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)",
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user