From e414ab81c249e21d85b6e3aa138d82e5d5f7d322 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 08:14:29 +0000 Subject: [PATCH] Wait visibly after an abandoned login, and keep a good token's schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #82, which fixed the 500ms retry loop behind #83. Three things that fix leaves open, plus the parts of the login path around it. The one that matters: retryDelayMsAfterError() was applied to every non-abort failure, including one that happens *after* the token was renewed. Those retries re-enter getNewToken(), so a failing profiles or EKS step opened a login page every 60 seconds while the token it already held was valid for another eight hours — in default-browser mode, a tab a minute. scheduleAfterFailure() now checks for a valid token first and keeps the ordinary expiry schedule there. - Frost stopping after an abandoned login was invisible: the tray read `expiresAt`, so it announced "Next refresh 8 hours ago" with nothing scheduled. It now reads the scheduled time (getNextRefreshAt()) and says "Sign-in needed", and a single notification says so too — not when the user closed the window or refused the sign-in themselves, which LoginAbortedError now carries as `cancelledByUser`. - Repeated failures back off 1m→30m instead of asking AWS the same question every minute forever; the streak resets on a clean run. - AccessDeniedException ends the run like ExpiredTokenException already does, and SlowDownException widens the poll interval (RFC 8628 §3.5) instead of being logged and ignored. - Default `expiresIn`/`interval` when AWS omits them: the first made the poll loop exit before it ran, the second made the sleep NaN and the loop hot. - Describe the openExternal failure with describeError(), per AGENTS.md. - docs/docs/{credential-refresh,login,troubleshooting}.html describe the new behaviour, and AGENTS.md gains schedule.ts with the two rules that keep #83 fixed. Also removes the assert-based self-check block from schedule.ts. Verified with `npm run build`, `npm run lint`, and a headless harness that loads dist/aws-sso.js against stubbed electron and a faked SSO OIDC client (15 checks). The post-login-failure case is the one that changed against main: token valid for an hour with every later step failing opens 2 login pages in 70s on main, 1 here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MgEBPwgAkov8Q1nFyJ78mt --- AGENTS.md | 942 ++++++++++++------------------ docs/docs/credential-refresh.html | 10 +- docs/docs/login.html | 17 +- docs/docs/troubleshooting.html | 22 +- src/aws-sso.ts | 313 +++++++--- src/schedule.ts | 74 +-- src/tray.ts | 36 +- 7 files changed, 673 insertions(+), 741 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index af16cc1..bb27b91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,611 +1,393 @@ # AGENTS.md -Orientation and gotchas for working on Frost. Read this before changing build -config, dependencies, or the release pipeline. +Gotchas for working on Frost. Read before changing build config, dependencies, +or the release pipeline. -## What this is +## Layout -Frost is an Electron menu-bar/tray app (an AWS SSO credentials refresher). -Almost every `src/*.ts` file executes in the **main process** — there is no -bundler. The login window (`src/aws-sso.ts`) just loads a remote AWS URL. +Frost is an Electron tray app (an AWS SSO credentials refresher) for macOS, +Windows and Linux. There is no bundler; almost every `src/*.ts` runs in the +main process. Two exceptions: -Two files are exceptions, and both are worth knowing about before you touch the -build: - -- `src/login-overlay.ts` runs **in the browser**, injected into the login page - (see "Login window credential overlay"). It has its own compile, - `tsconfig.overlay.json`, because the main build's ESM output is not - injectable and its Node types do not belong in a web page; the main - `tsconfig.json` excludes it so the two cannot fight over `dist/`. -- `src/dashboard.html` is copied verbatim by `npm run build:html` and is - neither type-checked nor linted (see below). - -If you add another copied asset, remember to add its `copyfiles` step to the -`build` script — a missing step only shows up at runtime. +- `src/login-overlay.ts` is browser code injected into the login page. It has + its own compile (`tsconfig.overlay.json`); the main `tsconfig.json` excludes + it. +- `src/dashboard.html` is copied verbatim by `build:html` and is neither + type-checked nor linted. -It ships for **macOS, Windows and Linux** — see "Cross-platform gotchas" below -before touching window chrome, tray icons, or anything path-shaped. +Any other copied asset needs its own `copyfiles` step in the `build` script; a +missing one fails at runtime only. -Five small modules exist because they hold a decision that is easy to undo by -accident. Prefer them over doing the thing inline: +## Prefer these modules over doing it inline - **`src/atomic-write.ts`** — `writeFilePreservingMode()` for files the user - co-owns (`~/.aws/config`, `~/.kube/config`). Never hand-roll temp-file + - rename: the rename replaces the *inode*, which drops the destination's mode, - its owner, and its identity as a symlink — a config symlinked into a dotfiles - repo gets replaced by a regular file. `atomically` handles all of that plus - the fsync and the Windows EPERM/EBUSY retry. Anything that must be 0600 - whatever it replaces (the SSO token cache) calls `atomically` directly. -- **`src/logging.ts`** — transport configuration, the retention sweep, and - `describeError()`. Log errors through `describeError()`: `${err}` throws away - the AWS SDK's exception name, HTTP status and request id. -- **`src/user-config.ts`** — `validateUserConfig()`, kept out of `config.ts` - because importing that constructs the electron-store and so needs a live - Electron app. -- **`src/run-log.ts`** — the per-run step log the Activity panel renders. One - run is in flight at a time; `refresh()` guards on `isWorking` because a second - run would overwrite the current-run slot. -- **`src/browsing-data.ts`** — `clearBrowsingData()`, behind Behavior → Login - Page's "Clear cookies and local storage". The login window takes no + co-owns (`~/.aws/config`, `~/.kube/config`). A hand-rolled temp-file + + rename replaces the inode, dropping the destination's mode, owner and + symlink identity. Files that must be 0600 whatever they replace (the SSO + token cache) call `atomically` directly. +- **`src/logging.ts`** — transport config, retention sweep, `describeError()`. + Log errors through it; interpolating the error loses the AWS SDK's exception + name, HTTP status and request id. +- **`src/user-config.ts`** — `validateUserConfig()`. Kept out of `config.ts`, + which constructs the electron-store and so needs a live Electron app. +- **`src/run-log.ts`** — the per-run step log behind the Activity panel. One + run at a time; `refresh()` guards on `isWorking` because a second run would + overwrite the current-run slot. +- **`src/schedule.ts`** — every delay `setNextTokenRefresh()` may use. Three + rules: a retry delay is never derived from the stored token expiry (after a + failure it is in the past, which collapses to the floor and reopens the login + page in a loop); a login nobody completed is not retried on a timer; a + failure *after* the token was renewed keeps the expiry schedule rather than + an error retry, which would reopen the login page for an unrelated failure. + No electron imports. +- **`src/browsing-data.ts`** — `clearBrowsingData()`. The login window takes no partition, so it clears `session.defaultSession`: `clearData()` plus - `clearAuthCache()`, which that does not cover. The electron-store stays — the - point is to start the next login clean *without* resetting the SSO settings. - -The one renderer is the dashboard: `src/dashboard.html`, a single self-contained -file (markup, CSS, and inline vanilla JS, no framework) that `npm run build:html` -copies verbatim into `dist/`. It is **not** type-checked or linted — `tsc` and -ESLint only see `src/*.ts` — so changes there are verified by eye and at runtime. -It talks to the main process purely over the `window.frost` bridge in -`src/preload.cts` (named operations + a `state-updated` push); the handlers live -in `src/window.ts`, which takes an `IpcCallbacks` object so it never has to -import `aws-sso.ts` (that would be a cycle). It runs sandboxed, with -`contextIsolation: true` / `nodeIntegration: false` and a CSP, so an unescaped -value is no longer code execution against a live token — but **every value -interpolated into `innerHTML` must still go through the `esc()` helper** — account names, cluster names, and error -strings all originate from AWS. For the same reason, never build a selector or -an inline `onclick` out of a value: put it in a `data-` attribute and read it -back (`esc()` is an HTML escaper, and an HTML attribute is decoded *before* its -contents are parsed as JS or CSS, so escaping does not hold there). - -Register new IPC handlers with **`handleFromDashboard()`**, not `ipcMain.handle` -directly: it rejects anything that is not the dashboard's own top frame. A new -handler also needs a named method in `src/preload.cts` — the renderer has no -`ipcRenderer` of its own, so a handler without a bridge entry is unreachable -from the page, and `dashboard.html` is linted by nothing that would notice. + `clearAuthCache()`, which that does not cover. Settings are left alone. + +## Dashboard + +`src/dashboard.html` is the only renderer: one self-contained file, no +framework, not type-checked or linted. + +- It reaches the main process only through the `window.frost` bridge in + `src/preload.cts`. Handlers live in `src/window.ts`, which takes an + `IpcCallbacks` object so it never imports `aws-sso.ts` — that would be a + cycle. +- Every value interpolated into `innerHTML` must go through `esc()`; account + names, cluster names and error strings come from AWS. Never build a selector + or an inline handler out of a value — an HTML attribute is decoded before its + contents are parsed as JS or CSS, so escaping does not hold there. Put it in + a `data-` attribute and read it back. +- New IPC handlers go through `handleFromDashboard()`, which rejects anything + but the dashboard's own top frame, and need a matching entry in + `src/preload.cts`. Nothing type-checks the two against each other, so a + missing entry is a button that silently does nothing. ## Commands -This project uses **npm** (`package-lock.json`; CI runs `npm ci`). Do not add a -`yarn.lock`. +npm only (`package-lock.json`; CI runs `npm ci`). Do not add a `yarn.lock`. -- `npm run build` — `tsc` (type-check + emit to `dist/`) then copies the tray - icons and `src/dashboard.html` into `dist/`. -- `npm run lint` — ESLint (flat config, see below). +- `npm run build` — `tsc`, then copies the tray icons and `dashboard.html`. +- `npm run lint` — ESLint, flat config. - `npm start` / `npm run package` / `npm run make` — Electron Forge. -`npm run build` and `npm run lint` are the local signal and both run in CI. They -do **not** prove the app launches — see "Verification limits". - -## Module system: ESM - -The project is **ESM** (`"type": "module"` in package.json, `tsconfig` -`module`/`moduleResolution: NodeNext`). Consequences: - -- Relative imports **must** carry a `.js` extension, e.g. `import { config } - from "./config.js"` (even though the source is `.ts`). -- No `__dirname`/`__filename`. Use - `path.dirname(fileURLToPath(import.meta.url))` (see `src/tray.ts`). -- `tsconfig` needs an explicit `rootDir` (TypeScript 6 requirement). -- `forge.config.js` and `eslint.config.js` are ESM (`export default` / - `import`). `process` is a global; no `require`. - -## Login window credential overlay - -Electron services WebAuthn (`navigator.credentials`) but ships **no UI** for it: -a page waiting for a YubiKey touch renders nothing at all, which is what made -hardware keys look broken (issue #17). `src/login-indicator.ts` fills that gap -for the login window and is wired up in `aws-sso.ts` *before* `loadURL` — only -on the built-in-window path, since the default-browser login mode gets the -browser's own prompts: - -- `src/login-overlay.ts` is compiled to `dist/login-overlay.js`, read off disk, - and injected with `executeJavaScript` on every `dom-ready` — the main frame - plus any sub-frame reached through `frame-created`, since `executeJavaScript` - on the `WebContents` only sees the top frame. It wraps `navigator.credentials.{get,create}` and draws a toast for - as long as a request is pending. It guards itself with a `window` flag, so - re-injecting into the same document is a no-op. -- Its compile (`tsconfig.overlay.json`) differs from the main one in three ways - that all matter: `module: ESNext` (NodeNext would append `export {}`, a syntax - error in an injected classic script), `lib: DOM` + `types: []` (so `document` - resolves and `process`/`require` do not), and no source map (the output is - read as text, never loaded as a file). Because it has no imports it stays a - plain script — adding one would make the emitted file a module and break - injection, which is the reason `SIGNAL` is duplicated rather than shared. -- The overlay runs in the **page's** JavaScript world (no preload script), so - its only channel back to the main process is `console.info()` with a magic - prefix, read via the `console-message` event. Keep `SIGNAL` in the overlay and - `LOGIN_OVERLAY_SIGNAL` in the indicator in sync. The remote page could forge - those lines, so nothing security-relevant may ever hang off them — today they - only pick a window title and bounce the dock icon. -- Because the overlay lands on pages Frost does not control, it avoids - `innerHTML` and `