Skip to content

Multi-tenant OIDC browser login on the client (LLP 0046-0048)#204

Open
platypii wants to merge 58 commits into
masterfrom
oidc-client-login
Open

Multi-tenant OIDC browser login on the client (LLP 0046-0048)#204
platypii wants to merge 58 commits into
masterfrom
oidc-client-login

Conversation

@platypii

Copy link
Copy Markdown
Contributor

Implements chunk 2 of the multi-tenant OAuth login program: teaching hyp to run a browser OIDC flow against hypaware-server, store the refresh token in the existing 0600 credential store, and refresh silently on the query path. Follows the design in LLP 0046 (decision), 0047 (design), and 0048 (plan). Provider-agnostic; works against any hypaware-server with login configured.

This branch assembles six independently-reviewed PRs (#197, #198, #203, #200, #201, #202); each carries an independent code review and its review-driven fixes.

What lands

New, dependency-free modules under src/core/remote/:

  • pkce.js - the client's downstream PKCE leg, S256 over stdlib crypto (D3).
  • loopback.js - ephemeral single-shot 127.0.0.1 redirect receiver, RFC 8252 (D2).
  • open_browser.js - platform opener with the print-URL fallback (D8).
  • identity_client.js - exchangeCode / refreshSession over <origin>/v1/identity/token; typed invalid_grant.
  • oidc_login.js - loginWithBrowser() orchestrating PKCE + state + loopback + exchange.

Extended:

  • credentials.js - records gain a kind: 'static' | 'oidc' discriminator (legacy token-only reads as static, no rewrite); writeSession; resolveAccessJwt with silent refresh + 60s skew; deriveIdentityBase (D4/D5/D6).
  • remote_commands.js - hyp remote login gains browser mode (default when no static token); --org / --browser / --no-browser; static --token-file/stdin unchanged; callback errors explained (D1/D7/D8).
  • mcp/remote_verb.js + mcp/client.js - attach path uses resolveAccessJwt; a live 401/403 on an oidc session refreshes once and retries; invalid_grant surfaces re-login guidance (D5).

Docs: LLP 0033 updated to reference the kind record and session-aware attach; LLP 0046/0047/0048 promoted Draft → Accepted.

Out of scope (tracked elsewhere)

hyperparam.app OIDC provider, login-minted gateway enrollment, DNS-TXT domain claiming (server chunks 3-5); OS keychain; path-prefixed identity hosting (the identityUrl override).

Verification

  • npm test: 1539 pass, 1 pre-existing skip.
  • npm run smoke -- remote_oidc_login: green (stub identity+MCP server, scripted loopback, login → attach → silent refresh → revoked-refresh re-login).
  • npm run typecheck, npm run lint: clean. All @ref anchors resolve.

🤖 Generated with Claude Code

platypii added 7 commits June 29, 2026 10:28
Integration branch base for the chunk-2 OIDC client work. Carries the
decision, design, and plan that the implementation PRs realize.
* remote/oidc: PKCE pair + ephemeral loopback receiver (T1, T2)

Two dependency-free local primitives for the browser login flow:

- pkce.js: createPkcePair() -> { verifier, challenge }, S256 over stdlib
  crypto. The client's downstream PKCE leg (LLP 0046 D3).
- loopback.js: startLoopbackReceiver({ state, timeoutMs }) binds a
  single-shot 127.0.0.1:0 HTTP listener serving /callback, returns its
  redirectUri up front, and resolves { code } on a state-matched
  callback (rejecting on mismatch, error=, or timeout). RFC 8252
  ephemeral redirect (LLP 0046 D2).

Unit tests cover the SHA-256 challenge derivation, fresh randomness,
and the loopback success / state-mismatch / error / timeout paths.

* remote/oidc: loopback close() rejects pending waitForCode; route post-listen errors

Review follow-ups (PR #197):
- close() before a code arrives now rejects an in-flight (or future)
  waitForCode() instead of leaving it permanently pending.
- a post-listen server 'error' now settles a pending waitForCode() and
  closes the server (via fail()), not a no-op rejectStart.
- tests: close()-rejects-pending, and a non-/callback probe 404s without
  consuming the single shot.
…3, T5) (#198)

* remote/oidc: identity client, browser opener, login orchestrator (T3, T5)

Completes milestone-1 local primitives, composing chunk 1's PKCE +
loopback:

- identity_client.js: exchangeCode / refreshSession over an injectable
  fetch against <origin>/v1/identity/token; a 401 invalid_grant surfaces
  a typed InvalidGrantError. Response field is access_jwt, expires_at is
  ISO. No external JWKS on the client.
- open_browser.js: platform opener (open / xdg-open / cmd start),
  detached; returns whether an opener was found (LLP 0046 D8).
- oidc_login.js: loginWithBrowser() orchestrates PKCE + a random state,
  starts the loopback receiver, builds the /login/start URL, opens the
  browser (or prints the URL), awaits the code, and exchanges it for the
  session. No persistence; the caller stores it (LLP 0046 D2/D3).
- types.d.ts: OidcSession / RefreshedAccess shared interfaces.

Unit tests cover the token request bodies + response mapping, the
invalid_grant typing, each platform opener, and the full
PKCE->loopback->exchange orchestration including --no-browser and
loopback cleanup on failure.

* remote/oidc: open_browser must not crash on async spawn ENOENT

Review follow-ups (PR #198):
- spawn delivers a missing-opener failure as an async 'error' event, not
  a synchronous throw. Without a listener it became an uncaught exception
  that crashed the CLI on the D8 no-opener path. Attach child.on('error')
  to swallow it; document the boolean return as best-effort.
- oidc_login now always prints the URL as a fallback even when the opener
  reported success, so a silently-failed launcher never strands the user.
- test now models spawn's async 'error' emission (EventEmitter child) and
  asserts no process crash; keep the synchronous-throw case too.
…(T4) (#203)

* remote/oidc: discriminated credential record + session-aware resolve (T4)

Extends the 0600 credential store to carry OIDC sessions alongside the
existing static tokens (LLP 0046 D4), and adds the attach-path resolver
(LLP 0046 D5):

- Records gain a kind discriminator. readCredentials normalizes each
  record; a legacy token-only record (no kind) reads as static, so
  existing files keep working without a rewrite. writeToken now stamps
  kind: 'static'.
- writeSession(stateDir, target, { refreshToken, accessJwt, expiresAt,
  org }) writes a kind: 'oidc' record through the same atomic 0600 path.
- resolveAccessJwt({ target, env, stateDir, identityBase, now, fetchImpl
  }): env override wins; a static record returns its token; an oidc
  record returns a fresh access JWT, refreshing + persisting when the
  cached one is within a 60s skew of expiry, and propagating a refresh
  failure (typed invalid_grant). resolveToken stays for the stdio proxy.
- removeToken drops either kind (whole-record delete, unchanged).
- types.d.ts: RemoteStaticRecord / RemoteOidcRecord / the union.

Unit tests cover the round-trip, legacy normalization, env override,
fresh vs stale refresh + persistence, and failure propagation. Full
suite green.

* remote/oidc: normalizeRecord keeps a usable static token on a corrupt record

Review follow-up (PR #199): an incomplete oidc shape (refreshToken but no
accessJwt) on a hand-edited record no longer returns null and drop a
working static `token`; it falls through to the static branch. Added
boundary tests: the static fallthrough, a malformed-oidc drop, resolveToken
on an oidc record, and a skew-window-boundary refresh.
* remote/oidc: browser mode for `hyp remote login` (T6)

runRemoteLogin gains an interactive browser authorization-code mode
(LLP 0046 D1), selected by default when no static token is supplied:

- Flag parsing: --token-file / stdin keep the static path unchanged
  (kind: 'static', the headless escape hatch, D8); --org <name> selects
  an org; --browser forces the flow even with stdin piped; --no-browser
  prints the URL instead of opening it.
- The identity base is derived from the configured target URL's origin as
  <origin>/v1/identity, so no second URL is configured (D6).
- On success the resolved org is printed and the OIDC session is stored
  via writeSession.
- A server-surfaced callback error (access_denied, no_membership,
  org_selection_required, org_not_permitted) is translated to a clear
  message; org_selection_required instructs a --org re-run rather than
  enumerating the user's orgs (D7).

A small `deps.login` seam keeps the browser path unit-testable. Tests
cover --org + identity-base forwarding, --no-browser, each error mapping,
the unconfigured-target refusal, and the unchanged static token-file and
piped-stdin paths.

* remote/oidc: robust login flag parsing; note --org on the static path

Review follow-ups (PR #200):
- the target name now skips the value slot of --token-file/--org via a
  firstPositional() helper, so `login --org acme` (name omitted) is a
  usage error instead of misreading 'acme' as the target.
- a static path with --org now prints a note that --org applies only to
  the browser flow, instead of silently dropping it.
- tests: --browser overriding piped stdin, only-flags usage error,
  --org-missing-value exit, and the static --org-ignored note.
…T7) (#201)

* remote/oidc: silent refresh + one-shot 401 retry on the attach path (T7)

The query attach path becomes session-aware (LLP 0046 D5):

- remote_verb.js resolves the bearer via resolveAccessJwt (not
  resolveToken), passing the origin-derived identity base. A stale cached
  JWT is refreshed and persisted before the call.
- client.js tags a 401/403 rejection (authError + status) so the attach
  path can recognize it. On a live auth error for an oidc/file session,
  the attach path forces a single refresh and retries once; an env
  override or static token is surfaced as-is (nothing to refresh).
- A refresh that fails invalid_grant surfaces "remote session expired -
  re-run 'hyp remote login <target>'".
- resolveAccessJwt gains forceRefresh for the retry; deriveIdentityBase
  moves to credentials.js as the shared home for the login command and
  the attach path (single D6 definition).

Tests: a stale stored JWT refreshes + persists pre-call; a mid-flight
401 triggers exactly one refresh + retry; invalid_grant maps to
re-login guidance; a static 401 is not retried. Full suite green (1527).

* remote/oidc: map invalid_grant on the initial resolve too (not just 401 retry)

Review follow-up (PR #201): the initial resolveAccessJwt can itself
refresh (and throw invalid_grant) when the stored JWT is already stale,
which escaped as an unhandled rejection. Wrap it and map via a shared
mapRefreshError helper, exactly like the mid-flight 401 retry path. Added
a stale-JWT pre-call-refresh-fails unit test.
* remote/oidc: hermetic smoke + LLP follow-ups (T8)

- remote_oidc_login smoke: one in-process server plays both the identity
  surface (/v1/identity/login/start + /token, signing real per-call
  tokens) and the MCP endpoint (/mcp, accepting only the current JWT). A
  scripted opener drives the real loopback redirect. Asserts the
  user-visible result and the remote-oidc smoke_step telemetry across:
  browser login -> session stored kind: 'oidc' -> query attaches the JWT
  -> forced expiry drives a silent refresh + persist -> a revoked refresh
  row drives the re-login message.

- Fix surfaced by the smoke: when the stored JWT is already stale, the
  initial resolveAccessJwt refreshes pre-call and can throw invalid_grant
  outside the 401-retry try/catch. remote_verb now maps a refresh failure
  to the re-login guidance on both the initial-resolve and the
  mid-flight-401 paths (shared mapRefreshError). Added a unit test for
  the stale-initial-resolve path.

- LLP 0033: its credential-store and attach sections now note the kind
  discriminator and the silent-refresh + 401-retry behavior, pointing at
  LLP 0046 D4/D5.

- Promote LLP 0046/0047/0048 Draft -> Accepted now that the milestones
  land. All @refs resolve (ref-check clean).

* remote/oidc: smoke convention + cleanup follow-ups

Review follow-ups (PR #202):
- hoist the inline import('node:http').IncomingMessage type to the
  top-level @import block (repo hard rule: no inline import() types).
- drop runQuery's unused cmd param.
- move obs.shutdown() into the finally and force-close keep-alive sockets
  (closeAllConnections) so the server does not wait on idle timeouts.
@platypii

Copy link
Copy Markdown
Contributor Author

Code review: Multi-tenant OIDC browser login (LLP 0046-0048)

Reviewed the PR diff (24 files): new src/core/remote/ primitives (PKCE, loopback receiver, browser opener, identity client, login orchestrator), a discriminated kind credential record with silent refresh, browser mode on hyp remote login, and a one-shot 401 refresh+retry on the query attach path, plus a hermetic smoke. The structure is clean and well-annotated. Findings below, most severe first.

Correctness

  1. src/core/remote/open_browser.js:46 - browser login is broken on Windows. openerFor('win32') returns cmd /c start "" and the start URL is appended unquoted. The URL always contains multiple &-joined query params (redirect_uri, code_challenge, code_challenge_method, state, org). Node does not caret-escape & for cmd.exe (no shell: true), so cmd treats & as a command separator: the browser opens a truncated, PKCE-less /login/start (the server rejects it) and the trailing code_challenge=... runs as a stray command. Quote the URL argument (e.g. start "" "<url>" with proper escaping, or open via rundll32 url.dll,FileProtocolHandler).

  2. src/core/mcp/proxy.js:40 - the stdio proxy cannot use an OIDC session. The proxy still calls non-refreshing resolveToken and captures resolved.token once for the whole long-lived proxy (used in the closure at line 66). Now that hyp remote login defaults to the browser/OIDC flow, a user who logs in then runs hyp mcp --remote <t> gets the short-lived access JWT, which expires within minutes; every forwarded request then returns a generic remote returned HTTP 401 with no silent refresh and no re-login guidance. The resolveToken docstring calls this "kept for the stdio proxy," so it may be intentional scoping, but the result is a session that silently dies. Worth either wiring the proxy to resolveAccessJwt or documenting the limitation in the login output.

  3. src/core/cli/remote_commands.js:118 - non-interactive runs can't reach the now-default browser login. useStatic = !!tokenFile || (stdinPiped && !forceBrowser) with stdinPiped = !stdin.isTTY. Any non-TTY stdin (redirected, /dev/null, some IDE integrated terminals, process wrappers) routes to the static path; runStaticLogin then reads empty stdin and exits 2 with empty token rather than running the browser flow the user intended. The user has to discover --browser. Consider only treating stdin as a static token source when bytes are actually available, or making the empty token message point at --browser.

  4. src/core/remote/identity_client.js:62 - refreshSession hard-requires org. str(json.org, 'org') throws on a refresh response that omits org. The documented contract does return org on refresh, so this is contract-correct, but a server that returns only { access_jwt, expires_at } would make every silent refresh fail with a misleading identity response missing 'org' (exit 1) instead of refreshing. Cheap to harden: keep the previous org when the refresh response omits it.

Cleanup

  1. src/core/remote/pkce.js:36 - base64url() reinvents stdlib. Node's Buffer.toString('base64url') produces unpadded base64url directly; the three chained .replace() calls are hand-rolled and force readers to verify RFC 7636 padding by hand.

  2. Duplicated helpers. identity_client.js defines a safeText(res) identical to the one in src/core/mcp/client.js, and oidc_login.js's buildStartUrl inlines the same replace(/\/+$/,'') trailing-slash trim that identity_client.js names as trimSlash. Two copies each, free to drift.

  3. src/core/cli/remote_commands.js:147 - firstPositional only fixes one command. The new value-flag-aware parser is used by login, but remote add/list/remove still use argv.filter(a => !a.startsWith('-')), which misparses the value slot of any future value-taking flag - the exact bug firstPositional was written to prevent. Unifying the file's arg handling would be the deeper fix.

platypii added 7 commits June 29, 2026 12:31
`cmd /c start <url>` treats `&` as a command separator, so the authorize
URL (always multi-param: redirect_uri, code_challenge, state, ...) was
truncated at the first `&`, opening a PKCE-less URL the server rejects.
Spawn `rundll32 url.dll,FileProtocolHandler` directly instead, so the URL
reaches the handler verbatim with no shell parsing.
The `hyp mcp --remote` proxy resolved the bearer token once via the
non-refreshing resolveToken and reused it for the whole long-lived proxy,
so an OIDC session's short-lived access JWT expired and every forwarded
message returned a generic HTTP 401 with no refresh and no re-login hint.

Resolve a fresh access JWT per forwarded message via resolveAccessJwt
(env/static tokens pass through unchanged), refresh once and retry on a
live 401/403, and map invalid_grant to "re-run 'hyp remote login'". LLP
0033 now documents that the proxy fallback shares the session-aware path.
refreshSession hard-required `org`, so a refresh grant that re-mints only
the access JWT (org is fixed for the refresh token's life) threw a
misleading "missing org" error and no near-expiry session could refresh.
Treat org as optional on refresh and keep the org already stored. Also
share trimSlash between identity_client and the start-URL builder, and
correct the resolveToken doc now that the proxy refreshes too.
Any non-TTY stdin (a wrapper, an IDE terminal, /dev/null) routes login to
the static path, so an empty stdin failed with a bare "empty token" even
though browser sign-in is the default. The error now points at --browser.

Also unify positional parsing: the value-flag-aware parser firstPositional
becomes positionals(), used by add/remove/login alike, so a future
value-taking flag on any subcommand can't misread its value as a name.
Buffer's 'base64url' encoding produces unpadded base64url (RFC 7636)
directly, so the hand-rolled +/_/= replace chain was redundant.
@platypii

Copy link
Copy Markdown
Contributor Author

No blocking correctness issues were identified in the branch. The unit tests, lint, typecheck, and type build pass; the OIDC smoke could not run in this sandbox because localhost binding is denied.

@platypii

Copy link
Copy Markdown
Contributor Author

Code review — OIDC browser login (multi-agent, high effort)

Reviewed master...HEAD. 8 finder angles + verification. Conventions (no-semicolons, no em dashes, @import specifiers) are clean. Findings ranked most-severe first.

Correctness

1. src/core/remote/loopback.js:75 — uncaught new URL throw crashes the login process.
The request handler runs new URL(req.url ?? '/', 'http://127.0.0.1') with no try/catch. Verified locally that request targets like // or http://[ make new URL throw; a synchronous throw in an http request listener becomes an uncaught exception that kills hyp remote login mid-flow. During the 5-minute window any local process or port scanner that hits the OS-assigned loopback port can crash the login. Wrap the parse in try/catch and return 400.

2. src/core/cli/remote_commands.js:126--no-browser with a piped token silently discards the token.
useStatic = !!tokenFile || (stdinPiped && !forceBrowser && !noBrowser). echo "$TOKEN" | hyp remote login foo --no-browser makes useStatic false, so the browser flow runs, stdin is never read, and the supplied token vanishes with no diagnostic (contrast the helpful note: printed when --org is ignored). Treat piped stdin as static regardless of --no-browser, or at least warn.

3. src/core/remote/credentials.js:273 — refresh-token rotation is dropped.
refreshSession (identity_client.js:57) never reads a rotated refresh_token from the token response, and resolveAccessJwt re-stores the old refreshToken via { ...entry, ... }. If hypaware-server rotates refresh tokens on use (standard OAuth hardening), the next silent refresh sends a consumed token, gets invalid_grant, and forces a full re-login every session after the first. This is a hard dependency on the server not rotating (LLP 0047). Confirm the server contract; if uncertain, persist a returned refresh_token when present.

4. src/core/remote/credentials.js:108 — login/logout silently drops sibling records it can't normalize.
readCredentials omits any entry normalizeRecord returns null for (valid JSON but an unrecognized / partial / future-version record). writeToken/writeSession then persist the normalized map, so an unrelated hyp remote login/remove permanently deletes that sibling record from the 0600 file. Forward-compat and data-loss footgun; consider preserving unknown records on write-back.

5. src/core/cli/remote_commands.js (TTY-guard removal) — interactive headless TTY now hangs ~5 min instead of failing fast.
The old path errored immediately when stdin was a TTY with no token (documented "never a hang"). Now an interactive TTY falls into the browser flow; on a headless box openBrowser spawns e.g. xdg-open, whose async failure is swallowed but still returns true, so the CLI prints "Opened your browser… waiting" and blocks for the full DEFAULT_TIMEOUT_MS. The timeout message never points at --token-file/--browser/--no-browser. Regression for SSH/headless use.

6. src/core/mcp/proxy.js:46 — startup probe can refresh over the network, aborting the proxy on a transient blip, and double-refreshes.
For an oidc record near expiry the fail-fast probe calls refreshSession; a transient non-invalid_grant failure (DNS/503/reset) throws and returns exit 2, so the long-lived proxy never starts even though the per-message resolve moments later might succeed. On success, the first handleMessage refreshes again — two refresh round-trips and two writeSession writes before any message is forwarded. Make the probe a presence check (no refresh), or reuse its token for the first send.

7. src/core/mcp/proxy.js:115 — a forced-refresh {ok:false} is silently swallowed.
In the 401/403 retry, if resolveAccessJwt(forceRefresh:true) returns ok:false (e.g. no identity endpoint resolved), the if (refreshed.ok) block is skipped and the stale 401 res falls through to the generic remote returned HTTP <status>, discarding refreshed.error and the re-login guidance. remote_verb.js handles the same case by surfacing refreshed.error (exit 2). The proxy is inconsistent and keeps reissuing the doomed request on every subsequent message.

8. src/core/mcp/proxy.js:108 and src/core/mcp/remote_verb.js:128 — HTTP 403 is treated as a refreshable auth error.
Both paths force a token refresh + full re-attempt on 403, but 403 is authorization-denied (org not permitted, scope) that a fresh JWT cannot fix, so it burns an extra refresh + initialize/callTool and can mask the real 403 message behind a refreshed-credential retry. Consider refreshing only on 401.

Altitude / cleanup

9. Refresh-once-retry policy and its messaging are duplicated across proxy.js and remote_verb.js.
The resolve → attempt → on-auth-error force-refresh → retry sequence, the resolved.kind === 'oidc' && resolved.source === 'file' "refreshable" predicate, and the InvalidGrantError → "remote session expired" mapping all live in both files. Auth-rejection itself is defined three ways: client.js isAuthStatus, proxy.js inline res.status check, and remote_verb.js isAuthError. A change to retry policy or wording must be made in 2–3 uncoordinated spots and can drift (already does: proxy checks raw status, remote_verb checks a tagged error). Factor a shared helper, and let the credential layer own "is this refreshable" rather than re-deriving oidc && file at each call site.

10. src/core/mcp/proxy.js resolves credentials from disk on every forwarded message.
resolveAccessJwtreadCredentialsfs.readFile + JSON.parse of the 0600 file runs unconditionally per MCP request for the proxy's whole lifetime; the isFresh skew check that gates the network refresh runs only after the read, so the disk I/O happens every message regardless. An in-memory cache of the parsed record, invalidated on writeSession, would let fresh-JWT messages skip disk entirely.

🤖 multi-agent review via /code-review

platypii added 9 commits June 29, 2026 14:18
A request whose target makes `new URL` throw (e.g. `GET //`) raised an
uncaught exception inside the loopback request listener, crashing the whole
`hyp remote login` process. Any local process or port scanner hitting the
OS-assigned loopback port during the login window could trigger it.

Wrap the parse in try/catch and answer 400 without settling the flow, so the
real callback can still arrive.
`echo $TOKEN | hyp remote login foo --no-browser` routed to the browser flow
and never read stdin, so the supplied token was silently dropped. --no-browser
is a browser-mode modifier (print the URL) yet a piped token signals static
intent.

Peek stdin in that case: a non-empty piped value is stored statically (with a
note that --no-browser was ignored), while an empty pipe still falls through to
the browser flow, preserving the documented non-TTY --no-browser behavior.
Extracts persistStaticToken so both static paths share one writer.
On a headless box the opener silently fails but still reports success, so the
browser flow blocks until the loopback timeout and then printed only the bare
timeout error. The old fast-fail TTY guard that named --token-file is gone now
that browser login is the default.

On any non-callback failure (timeout, transport), append guidance pointing at
--token-file, piped stdin, and --no-browser, so the user is never left stuck
without a next step.
refreshSession dropped any refresh_token in the token response and
resolveAccessJwt re-stored the old refreshToken, so a server that rotates
refresh tokens on use (one-time-use) would 401 on the next silent refresh and
force a full re-login every session after the first.

Surface a rotated refresh_token through RefreshedAccess and keep the stored one
only when the server omits it (stable-token servers are unaffected).
…malize

readCredentials drops any record it can't normalize; writeToken/writeSession/
removeToken read through it, so an unrelated login or remove silently deleted a
hand-edited or newer-version sibling record from the 0600 store.

Route the write path through a raw read that keeps unrecognized records intact.
removeToken can now also drop a record that doesn't normalize, instead of
reporting it absent.
…e check

The fail-fast probe called resolveAccessJwt, so a near-expiry OIDC JWT
triggered a network refresh at startup that the first handleMessage then
repeated (two refreshes + two writes before any message forwarded), and a
transient refresh blip aborted a proxy that would otherwise have started.

Use the non-refreshing resolveToken for the presence check; the per-message
resolveAccessJwt still does the session-aware refresh.
On a live 401, when the forced refresh returned ok:false (e.g. the record
vanished, or no identity endpoint resolved), the proxy skipped the retry block
and let the stale 401 fall through to a generic 'remote returned HTTP 401',
discarding the refresh reason. The one-shot verb path already surfaces it.

Return refreshed.error from the retry block so the actual reason rides back.
The re-login message and the 'refreshable' predicate were duplicated verbatim
across proxy.js and remote_verb.js, free to drift. Extract sessionExpiredMessage
(beside InvalidGrantError) and isRefreshable (beside the resolver that owns the
kind+source distinction), and have both attach paths call them. The retry
orchestration stays per-path since the proxy and the one-shot verb return
different shapes.
The stdio proxy resolves a token per forwarded message, so resolveAccessJwt
re-read and re-parsed the 0600 file on every message even when the cached JWT
was nowhere near expiry. Add a single-entry parse cache keyed by path + mtime +
size, busted on every write through this module, so a fresh-JWT message skips
the read and parse while our own writes and external edits are still picked up.
@platypii

Copy link
Copy Markdown
Contributor Author

Review fixes pushed (9 commits, each with tests)

Addressed the review findings as standalone commits on this branch:

Finding Commit
1. Loopback new URL crash a5689c9 harden loopback against a malformed request target
2. --no-browser discards a piped token 1a3f04b don't silently discard a piped token under --no-browser
5. Headless login hangs with no guidance 5f492b6 point a failed browser login at the headless escape hatches
3. Refresh-token rotation dropped 6d61bf7 persist a rotated refresh token from the refresh grant
4. Sibling records dropped on write 093ec1f preserve sibling credential records the writer can't normalize
6. Startup probe network-refreshes / double-refreshes b14ce3e make the startup credential probe a non-refreshing presence check
7. Failed forced-refresh swallowed a4c3323 surface a failed forced refresh, not a bare HTTP 401
9. Duplicated refresh-retry policy b70e02d give the refresh-retry policy primitives one home
10. Per-message file read+parse 42fedbf cache the parsed credential file between reads

Finding 8 (403 treated as refreshable): not changed. On a closer look this is intentional - client.js isAuthStatus and the existing test "a notification 401 or 403 during handshake triggers exactly one refresh + retry" deliberately treat 401 and 403 uniformly as credential rejection per LLP 0046 D5. Narrowing to 401-only would contradict that tested server-contract assumption, so I left it; worth a one-line confirmation of whether hypaware-server ever uses 403 for authorization-denied vs revoked tokens.

Full suite green (1563 pass), lint, typecheck, and the remote_oidc_login smoke all pass.

@platypii

Copy link
Copy Markdown
Contributor Author

Code review: OIDC browser login (LLP 0046-0048)

High-effort review of the branch diff against master. The change is careful and well-commented; most candidate bugs surfaced by the finders were already guarded (the proxy's if (sid) sessionId = sid never clears the session, isAuthError guards the type before reading .status, isFresh already treats an unparseable expiresAt as stale). Five findings survived verification.

1. invalid_grant is only recognized on HTTP 401 (correctness / UX)

src/core/remote/identity_client.js:125

The refresh-rejection mapping fires only when res.status === 401 && errCode === 'invalid_grant'. OAuth 2.0 (RFC 6749 §5.2) specifies HTTP 400 for invalid_grant. If hypaware-server follows the RFC and returns 400 for a revoked or expired refresh token, this branch is skipped, InvalidGrantError is never thrown, and the user gets the generic "identity endpoint rejected the grant" error instead of the "re-run hyp remote login" guidance the whole attach path is built to show. Worth confirming the server's exact status; if it can be 400, key the mapping on errCode rather than the status code.

2. The "refresh + retry once" loop is implemented twice (altitude)

src/core/mcp/proxy.js:108-131 and src/core/mcp/remote_verb.js:56-77

Both attach paths re-implement the same control flow: try, on auth failure call resolveAccessJwt({ forceRefresh: true }), retry once, else surface. They share the primitives (isRefreshable, mapRefreshError/proxyAuthMessage) but diverge on detection (proxy checks HTTP res.status 401/403; verb checks a thrown isAuthError) and on how the failure is returned. A future change to the retry policy has to be made in two places that already disagree. Consider a shared refreshAndRetry helper that owns the policy, with the two callers supplying only the "is this an auth failure" predicate.

3. Inline import() type violates repo convention

src/core/remote/credentials.js:121

/** @type {import('node:fs').Stats} */ uses an inline import('...') type. CLAUDE.md: "Never use inline import('...') types. Declare type imports at the top of the file with @import JSDoc comments, then reference the bare names." Move Stats to a top-of-file @import.

4. safeText duplicated verbatim (reuse)

src/core/remote/identity_client.js:158 and src/core/mcp/client.js:188

The same async safeText(res) wrapper (try res.text(), return '' on throw) exists in both files. Extract to one shared helper so the two cannot drift.

5. Unsanitized OAuth error param in log/error text (low)

src/core/remote/loopback.js:106

The IdP-supplied error query param is interpolated straight into the thrown Error message and passed as the log ERROR_KIND attribute. The served HTML page is fixed text, so there is no reflected XSS, but a value containing newlines can inject lines into the terminal output and structured logs. Low severity (the IdP is trusted), but trimming to a known token set or stripping control chars before logging would close it.

platypii added 2 commits June 29, 2026 15:16
RFC 6749 §5.2 returns HTTP 400 for invalid_grant, but the token client
only raised InvalidGrantError on a 401. Against a spec-compliant server a
revoked or expired refresh row would surface as a generic error instead
of the re-login guidance the attach paths are built to show. Key the
typed rejection on the body's error code and accept either status.
The MCP client and the identity client both wrapped res.text() to return
'' on an unreadable body, byte-for-byte the same. Extract it to
src/core/http_text.js so the two cannot drift.
platypii added 4 commits June 29, 2026 17:34
The proxy fails fast with a clear message when globalThis.fetch is absent;
the verb path did not, so a fetchless runtime threw the identity client's
lower-level "no fetch" deep inside the attach path with no guidance. Add
the same early guard, since both the refresh and the tool call need fetch.
The browser opens /callback over a keep-alive connection. server.close()
waits for in-flight sockets to drain, so without Connection: close the idle
keep-alive socket held the event loop until Node's keepAliveTimeout (~5s)
and hyp remote login hung that long at exit. Set Connection: close on the
response so close() finishes promptly while the user still sees the page.
openBrowser's boolean is best-effort: a launcher that exists but fails (no
display on a headless box) still returns true. "Opened your browser" then
overstated what happened. Say "Opening" instead; the URL is still printed
right below as the real fallback.
forceExpiry rewrote the credential file with a raw fs.writeFile at the same
byte length. A same-size rewrite landing within one mtime tick is hidden
behind the credential module's parse cache, so the next resolve could read
the pre-expiry record, skip the refresh, and flake the one-refresh
assertion. Write through writeSession, which invalidates that cache.
@platypii

Copy link
Copy Markdown
Contributor Author

Code review — OIDC client login (high effort, recall-biased)

Reviewed master...oidc-client-login. The feature is unusually well-tested; happy paths and most documented edges hold up. Findings below, correctness first. None are merge-blockers in my view, but #1 is a real user-facing inconsistency worth fixing before merge.

Correctness

1. Verb attach path advises re-login for an env/static 401 that re-login can't fixsrc/core/mcp/remote_verb.js:80
The proxy was explicitly fixed for this in commit 6ccf8fe ("do not advise re-login for an env/static credential 401"), gating the session-expired wording on isRefreshable(resolved). The verb path was not given the same fix: when op throws a tagged 401 on a non-refreshable credential, attachWithRefresh returns first.value verbatim, which is client.js's generic remote rejected the credential (HTTP 401) - re-run 'hyp remote login'. So HYP_REMOTE_TOKEN_PROD=bad hyp query ... --remote prod tells the user to re-login even though an env var is never read from the store. Separately, when a refreshable session's freshly-minted JWT is still 401'd (clock skew / independent revocation), the verb returns that generic message at exit code 1, whereas the proxy returns sessionExpiredMessage(target) and invalid_grant exits 2 — message and exit code drift for the same dead-session condition. LLP 0046 D5 intends both attach paths to share describeRefreshError wording.

2. Cross-target lost update can revert a rotated one-time-use refresh tokensrc/core/remote/credentials.js:229 (writeSession), also writeToken/removeToken
writeSession does an unlocked full-file read-modify-write of the multi-target store. Two hyp processes for different targets (a verb call for prod beside a proxy for staging) can both read the map, edit their own target, and rename; the later rename clobbers the earlier process's just-rotated refreshToken for the other target. With one-time-use refresh tokens that target's next refresh sends a consumed token → invalid_grant → forced hyp remote login. The concurrent-rotation tolerance at ~credentials.js:343 only covers a collision on the same target, not a lost write to a sibling.

3. A 401 with an empty or non-JSON body loses the invalid_grant re-login guidancesrc/core/remote/identity_client.js:160
postToken parses the body as JSON before classifying. A revoked refresh token where the identity endpoint (or an edge proxy / gateway) answers 401 with an empty body or text/html never becomes InvalidGrantError: empty body → json={}errCode undefined → generic identity endpoint rejected the grant (HTTP 401); HTML → JSON.parse throws first → non-JSON response. Either way describeRefreshError reports sessionExpired=false, so the user gets a generic error at exit 1 with no re-run 'hyp remote login' guidance. The server contract specifies a JSON body, so this is robustness-against-misbehaving-edge, not the happy path.

4. Loopback 404/400 responses omit Connection: close, risking the ~5s exit hang respond() exists to avoidsrc/core/remote/loopback.js:88 (and :83)
respond() sets connection: close precisely because browsers use keep-alive and server.close() waits for idle sockets to drain. The 404 and 400 branches write their own headers without it. A stray keep-alive request to the loopback port (a favicon or probe on a non-/callback path) before the real redirect can then hold the event loop until Node's keepAliveTimeout (~5s), delaying hyp remote login exit — the exact hang the helper was written to prevent.

5. The live-401 retry reads the entry through the parse cache without busting it firstsrc/core/remote/credentials.js:323
On a forced refresh (forceRefresh: true), resolveAccessJwt reads creds straight from rawCache, unlike the invalid_grant path which does rawCache = null before re-reading (~:355). If a concurrent process rotated the token with an identical-size rewrite within one mtime tick (the same staleness that path's comment guards against), this read returns the stale entry and refreshSession sends a consumed token. It self-heals via the invalid_grant tolerance, but at the cost of an extra failed round-trip; the asymmetry between the two read sites is the smell.

Cleanup

6. runRemoteLogin's --no-browser-with-piped-stdin peek is an over-complex special casesrc/core/cli/remote_commands.js:143
useStatic already encodes the static-vs-browser decision; lines 143-164 then re-derive it for the single stdinPiped && noBrowser && !forceBrowser combination, adding a second readAllStdin call, a nested note cascade, and a duplicated runBrowserLogin(...) tail. Treating a piped token as static intent regardless of --no-browser (with --no-browser as a pure browser-mode modifier) collapses the branch and removes the stdin peek.

7. Duplicated user-facing stringsrc/core/cli/remote_commands.js:131 and :155
The exact note: --org is ignored with a static token ... line appears twice; both route through persistStaticToken. Emit it once at that choke point so the wording can't drift.

8. resolveToken and resolveAccessJwt duplicate the env-override blocksrc/core/remote/credentials.js:282 and :318
The four-line per-target env override is copied verbatim (differing only by kind: 'static'). A small envOverride(env, target) helper keeps the "env always wins" invariant (LLP 0033) in one place.

9. The no-fetch guard is repeated across three call sites with three messagessrc/core/mcp/remote_verb.js:42
remote_verb checks global fetch up front, the proxy guards "the same way", and identity_client.postToken already throws its own variant. Three wordings for one precondition; surfacing it once in the shared client layer would generalize it.


Generated by /code-review (high effort: 8 finder angles, recall-biased). Severity is my read; verify against your own judgement.

platypii added 5 commits June 29, 2026 19:08
A browser favicon or probe hitting the loopback port on a non-/callback path
was answered without Connection: close, so the idle keep-alive socket held the
event loop until Node's keepAliveTimeout (~5s) and hyp remote login hung that
long at exit. Match respond()'s keep-alive close on the 404 and 400 paths.
…sh resolver

- Wrap writeToken/writeSession/removeToken in a cross-process O_EXCL lock that
  re-reads the freshest file inside the lock, so two logins for different
  targets can no longer clobber each other's just-rotated one-time-use refresh
  token (which forced a needless re-login). A stale lock is stolen by age.
- Drop the parse cache before the forced-refresh read so the live-401 retry
  sees a concurrent rotation, symmetric with the invalid_grant re-read.
- Read the per-target env override through one helper in both resolvers.
- Add describeAuthRejection, the shared "why is this credential dead" wording
  for a 401 surviving the refresh + retry, and surface attachWithRefresh's
  final authFailed so callers can word their guidance accordingly.
…b and proxy

The verb path advised "re-run hyp remote login" for any surviving 401,
including an env-override token that re-login can never fix, and never gave the
session-expired guidance the proxy gives when a freshly-refreshed JWT is still
rejected (clock skew, or a revocation independent of the refresh row). Route
both attach paths through describeAuthRejection so an expired oidc session, an
env override, and a static file token each get the right message and exit code.
Share the no-fetch wording through one constant so the three entrypoints cannot
drift.
…xpired

A revoked refresh row answered with an empty or non-JSON 401 body never became
InvalidGrantError, so the user got a generic error instead of re-login
guidance. The only credential the token endpoint authenticates is the refresh
token, so classify any 401 there as invalid_grant (alongside an explicit
invalid_grant at any status), and only fail on a non-JSON body when the
response was otherwise ok.
Drop the stdin peek that tried to reconcile --no-browser with a piped token.
--no-browser now unconditionally means "browser flow, print the URL"; a piped
token without a browser-mode flag still takes the static path, so a token is
only ignored when --no-browser is given explicitly. Removes a fragile branch
and a duplicated note.
@platypii

Copy link
Copy Markdown
Contributor Author

Code review — OIDC client login (LLP 0046-0048)

High-effort review (8 finder angles + independent verification). The OIDC core is solid: PKCE (S256), the CSRF state guard, env-override precedence, refresh-token rotation handling, and the new cross-process lock are all correct and well-tested. The items below survived verification. Severity descends down the list.

Correctness

1. resolveAccessJwt refreshes outside the write lock — concurrent double-spend / clobber (src/core/remote/credentials.js:339-393)
The lock only wraps the write. The record read (:339) and the refresh POST (:358) happen outside withCredentialsLock; the lock is first taken inside writeSession (:393). Two paths sharing HYP_HOME (proxy + verb, or two hyp processes) can both read the same stale OIDC record and both POST the same one-time-use refresh token — the loser gets invalid_grant. The invalid_grant re-read (:372) only recovers if the winner's write already landed; a tight race still throws and maps to a spurious "re-run hyp remote login". Separately, a concurrent hyp remote login writing a fresh refresh token can be clobbered by the proxy's later writeSession carrying the old token. Consider holding the lock across the read-decide-refresh window, or funnelling refresh through a single owner.

2. Loopback misclassifies provider errors as a CSRF state mismatch (src/core/remote/loopback.js:103-108)
The returnedState !== state check runs before params.has('error'). An error callback that omits state (e.g. /callback?error=access_denied, which RFC 6749 permits) has returnedState === null, so it falls into the state branch and fails with state_mismatch and no callbackError. The user is shown the generic "mismatched state" message plus the headless static-token hint instead of the real "login denied at the provider". Check error before the state comparison (or carry the error through even on a state mismatch).

3. New lock-timeout throw escapes the static-login and remove paths (src/core/cli/remote_commands.js:216 and :368)
writeToken/removeToken now run inside withCredentialsLock, which throws timed out acquiring the remote credentials lock after ~5s of contention. Both call sites are unguarded (only the token read / config mutation around them is in try/catch), so under contention the command rejects with a raw uncaught error instead of the friendly hyp remote login: ... message. Worse, runRemoteRemove already mutated and persisted config before removeToken throws → partial config-only removal plus a raw error. Pre-PR these were plain tmp+rename writes that could not throw a lock error, so this is a contract regression.

4. A transient empty token-endpoint body becomes a hard "missing field" error (src/core/remote/identity_client.js:155-172, :207)
safeText returns '' on both an empty body and a mid-body read failure (connection reset). Then json = text ? JSON.parse(text) : {} takes the empty-string branch, producing {} with parseFailed === false, which skips the "non-JSON response" guard. str(json.access_jwt, ...) then throws a terminal-looking identity response missing 'access_jwt', classified non-retryable. During a silent proxy refresh a transient network blip surfaces as a permanent error. Treat an empty 2xx body as transient/retryable.

5. remote list reports an empty-token record as stored while every query rejects it (src/core/remote/credentials.js:204, src/core/cli/remote_commands.js:320)
normalizeRecord accepts token: '' as a present static record, so remote list shows stored, but resolveToken/resolveAccessJwt reject an empty token with "no token … run hyp remote login". The reverse also holds: a record with an unrecognized kind normalizes to null and shows missing though the entry physically exists. Reachable via a hand-edited / partially-written file. Low severity (display inconsistency, not a crash).

6. Static-token parse cache can serve stale credentials (PLAUSIBLE) (src/core/remote/credentials.js:143-164)
The cache is keyed on path + mtimeMs + size. A same-size external rewrite landing within one mtime tick is invisible to the next non-forced read, and a static token has no 401-driven cache bust, so a long-lived process serves the stale map until restart. Narrow on modern filesystems (nanosecond mtimeMs); the oidc paths self-heal, only the static path is exposed. Noting it because the four manual rawCache = null bust sites are easy for a future read/write path to forget.

Cleanup / conventions

7. Proxy re-implements the MCP request header set (src/core/mcp/proxy.js:75-78)
The content-type / accept / authorization / mcp-session-id header map is byte-identical to the one createHttpMcpClient builds in client.js. The verb path reuses the client; the proxy duplicates it. A future protocol-header change applied in client.js would silently miss the stdio proxy, drifting hyp <verb> --remote from hyp mcp. Consider sharing one request-builder.

8. Semicolons in new test files violate project style (test/core/remote-login-command.test.js, remote-oidc-login.test.js, remote-credentials-oidc.test.js)
Project CLAUDE.md Code Style: "JavaScript, no semicolons." Several inline stubs use statement-separating semicolons, e.g. called = true; return {} and openedUrls.push(url); return true. Split onto separate lines.


Verified findings only; isAuthError status-coupling, the per-message proxy JWT rotation, and an isFresh refresh-storm were investigated and refuted (the isoTimestamp guard and authRejectionError tagging already cover them).

platypii added 6 commits June 29, 2026 21:44
…mmit

resolveAccessJwt read the stored record and POSTed the refresh grant
outside the write lock, taking the lock only for the final writeSession.
Two `hyp` processes sharing one HYP_HOME (a verb call beside a proxy, two
MCP clients) could therefore both read the same stale oidc record and both
spend the same one-time-use refresh row, and a concurrent `hyp remote
login` writing a fresh session could be clobbered by a proxy's later
write carrying the superseded token.

Refresh now runs through an optimistic compare-and-swap: the network call
stays outside the lock (a 30s identity call must not wedge the 5s write
lock), but the commit re-reads under the lock and persists only when the
stored refresh token still matches the one we refreshed from. A mismatch
means a sibling rotated first, so we adopt the stored session when usable
or loop and refresh with the rotated token instead of overwriting it. The
invalid_grant re-read now happens under the lock too, so a loser that
refreshed before the winner persisted reliably sees the winner's write
rather than forcing a spurious re-login.
The callback handler ran the CSRF state comparison before the error
check, so an error redirect that omits `state` (RFC 6749 only requires
echoing it when present, and some providers drop it on errors like
access_denied) fell into the state branch. The user was told their login
hit a "mismatched state" and got the headless static-token hint, instead
of the real "login failed: access_denied".

Surface an `error` callback first. That branch never reads the
authorization code, so the CSRF guard (which exists only to stop an
attacker's code being adopted) does not apply, and the error value is
already sanitized before it reaches the message, log, and terminal. The
state guard still runs ahead of the code path.
…hrow

writeToken and removeToken now run inside withCredentialsLock, which
throws "timed out acquiring the remote credentials lock" under contention.
Their call sites in the static-login (persistStaticToken) and remove
(runRemoteRemove) paths were unguarded, so a contended write surfaced a
raw uncaught error instead of the friendly `hyp remote login:` / `hyp
remote remove:` message that held before the lock existed. The remove path
also already mutated config before the throw, leaving a partial removal
with no explanation.

Wrap both calls. Static login keeps its `hyp remote login:` contract and
exit 1; remove reports the lock failure and, since the config edit already
landed, tells the user the stored token could not be removed.
…ing-field

safeText returns '' for both an empty body and a mid-body read failure
(a reset connection). On a successful token response that empty string
parsed to {} with parseFailed=false, so it slipped past the non-JSON
guard and reached the field extractors, which threw "identity response
missing 'access_jwt'" - a permanent-looking contract error for what was a
transient network blip during a silent refresh.

Reject an empty body on a 2xx with an explicit "empty response - try
again" error before the field extraction, so the failure reads as
transient.
normalizeRecord accepted a record whose `token` was the empty string as a
present static record, so `hyp remote list` reported it as `stored` while
resolveToken and resolveAccessJwt both rejected an empty token with the
"run hyp remote login" guidance - contradictory signals for the same
target. An empty token is no credential, so normalize it to absent; list
and the resolvers now agree the target is logged out.
The Streamable-HTTP request header map (content-type, the JSON+SSE accept,
bearer, mcp-session-id) was built byte-identically in two places: the
in-process client's rpc() and the stdio proxy's forward(). A protocol
header change in one would silently miss the other, drifting
`hyp <verb> --remote` from `hyp mcp`. Extract mcpRequestHeaders() in
client.js (alongside the already-shared isAuthStatus and parseRpcResponse)
and call it from both.
@platypii

Copy link
Copy Markdown
Contributor Author

Code review — OIDC client login (PR #204)

Multi-agent review (8 finder angles + adversarial verify) over the master...HEAD diff. Three findings survived verification; the conventions check found no CLAUDE.md violations. Ranked by severity.

1. (correctness) Concurrent remote remove is resurrected by an in-flight refresh

src/core/remote/credentials.js:1978 (refreshOidcSession commit path)

After the refresh network call returns, the commit re-reads the stored record under the lock:

const latest = await readOidcRecord(stateDir, target)
if (latest && latest.refreshToken !== from.refreshToken) { ... }
const base = latest || from        // <- latest is null when the row was deleted
await commitSession(stateDir, target, { refreshToken: ..., ... })

If a concurrent hyp remote remove <target> deletes the record while the refresh is in flight, latest is null, the rotation guard short-circuits, and base falls back to the stale from. The commit then writes the record back, resurrecting credentials the user just removed. A if (!latest) return { token: undefined } (treat as removed, abort the commit) before the base line closes the window.

2. (low / consistency) Token refresh ignores the proxy's captured fetch

src/core/mcp/proxy.js:1306 and src/core/mcp/remote_verb.js (the resolveAccessJwt({ target, env, stateDir, identityBase }) calls)

The proxy captures globalThis.fetch for its forward path but does not pass fetchImpl to resolveAccessJwt, so the OIDC refresh inside it falls back to globalThis.fetch independently. In production both are the same reference, so this is not a functional bug — but it breaks test isolation (a mock installed after init reaches the forward path but not the refresh) and is an avoidable inconsistency. Thread the captured fetchImpl through both resolveAccessJwt call sites.

3. (pre-existing, adjacent) remote list crashes on a corrupt credentials file

src/core/cli/remote_commands.js:321 (runRemoteList)

readCredentials throws (remote credentials file is not valid JSON) on a malformed remote-credentials.json, and runRemoteList calls it without a try/catch, so the command crashes instead of reporting the problem. This PR added exactly that guard to the sibling runRemoteRemove path but not to list. Pre-existing, but worth closing the gap while the area is open.


Dropped after verification: a 401→invalid_grant mapping (the code has a sound documented rationale — this endpoint only authenticates the refresh token), a parse-cache shared-reference foot-gun (no caller mutates it), a per-message resolve cost (an mtime parse-cache already covers it), lastAuthStatus reporting the wrong code (status is always set), and a lock-throw on unguarded write callers (only the smoke test, which has no contention).

The OIDC refresh compare-and-swap re-read the stored record under the
lock but fell back to the in-flight `from` record when the row was gone
(`base = latest || from`), so a concurrent `hyp remote remove` landing
while a refresh was in flight got its deletion clobbered: the commit
wrote the stale session back. Treat a missing record under the lock as a
removal and decline to commit, returning the normal no-token error.
@platypii

Copy link
Copy Markdown
Contributor Author

Code review: OIDC browser login (LLP 0046-0048)

High-effort multi-agent review of oidc-client-login vs master. The implementation is unusually defensive and well-tested; the refresh/retry/header policy is correctly shared across the verb and proxy paths. Findings below are ranked; each was confirmed against the code by a second pass. Several are deliberate design tradeoffs already annotated in the diff, flagged here so they get a second look rather than because they are clearly wrong.

Correctness / UX

  1. Misleading "refresh token was rejected" on first-time login - src/core/remote/identity_client.js:190
    postToken maps any 401 to InvalidGrantError for both grants, and exchangeCode (authorization_code) shares it. A 401 on the initial code exchange (expired/replayed code, mis-scoped client) surfaces the default message refresh token was rejected (invalid_grant) to a user who never had a refresh token. Consider a grant-specific message.

  2. --no-browser with a piped token silently discards the token, then hangs 5 minutes - src/core/cli/remote_commands.js:126
    printf '%s' "$TOKEN" | hyp remote login prod --no-browser makes useStatic false (the && !noBrowser), so it routes to the loopback flow, never reads stdin, and blocks until DEFAULT_TIMEOUT_MS before exiting 1. The comment defends this as intentional, but a swallowed credential plus a 5-minute hang is a sharp footgun; an explicit error ("--no-browser ignores piped stdin; drop it to store a static token") would be kinder.

  3. Long-lived proxy pins one mcp-session-id while rotating the bearer - src/core/mcp/proxy.js:73
    The proxy captures one mcp-session-id from initialize and reuses it for life, while resolveAccessJwt may silently mint a new JWT between messages. If hypaware-server binds a session to its initializing JWT, a refreshed bearer under the old session id 401s, the retry force-refreshes (a no-op), 401s again, and the user gets a spurious "session expired, re-run login" on a valid login. Server-dependent, but the client does not preclude it. (The verb path re-inits per call and is unaffected.)

  4. Browser login can lose a freshly minted session and print the wrong hint - src/core/cli/remote_commands.js:268
    login() (single-use code exchange) and writeSession share one try/catch. If writeSession throws the lock-timeout under contention, the catch has no callbackError, so it prints the timeout AND appends the headless "pass a static token / --no-browser" hint even though the browser login just succeeded. The consumed code cannot be replayed, forcing a full re-login. Worth distinguishing a post-exchange write failure.

  5. Dead OIDC session passes the proxy startup probe - src/core/mcp/proxy.js:49
    The fail-fast probe uses presence-only resolveToken, which returns ok for any OIDC record with a refresh-token string, with no network validation. A revoked/expired refresh token starts the proxy "healthy" and fails only per-message with INTERNAL_ERROR. The diff documents this as intentional (avoid a startup refresh blip aborting a startable proxy), but it is an observability regression vs a fast startup signal.

  6. Stale-lock steal by age can clobber on a stall, not just a crash - src/core/remote/credentials.js:646
    A holder suspended >30s mid-commit (laptop sleep, long GC) gets its lock stolen by age; the stalled holder then completes its unguarded fs.rename, clobbering the thief's just-rotated one-time-use refresh token (and its finally even removes the thief's lock). Rare, and the lock is held only across local read+write (the network refresh is outside it), but it reopens the exact double-spend the lock exists to prevent.

  7. Auth-rejection exit code split: OIDC=2, static=1 - src/core/mcp/remote_verb.js:92
    A 401 that survives refresh+retry now exits 2 for OIDC/file targets (via describeAuthRejection) but 1 for static/env, where master returned 1 for all. Intentional per the JSDoc, but the OIDC/static split is undocumented for users and would break a wrapper that treats exit == 1 as "auth rejected".

  8. Loopback aborts a login on an unauthenticated error= callback - src/core/remote/loopback.js:107
    The error= branch is checked before the state CSRF guard, so any local process (or a loopback-permitted web page) can GET /callback?error=access_denied with no state and tear down the single-shot server, denying the real callback. The comment marks this deliberate (errors surface before the state check); flagging because it is a local availability vector worth a conscious accept. No credential injection: the success path still validates state.

Cleanup

  1. Dead "inferred oidc" normalize branch - src/core/remote/credentials.js:203
    The kind === undefined && refreshToken && accessJwt branch matches no record any writer produces (every OIDC write stamps kind: 'oidc'); reachable only via a hand-edited file. It adds a second oidc-detection path future readers must keep correct. Safe to drop unless kept deliberately for forward-compat.

  2. Duplicated, already-drifted "re-run login" guidance - src/core/mcp/client.js:143
    The re-login wording is hand-written in four spots with inconsistent target inclusion (authRejectionError and the static branch of describeAuthRejection omit the target; sessionExpiredMessage and noTokenError include it). authRejectionError's string is also dead on the attach path: op folds it into the value, then describeAuthRejection overrides it. Consider routing all four through one helper that always threads the target.


Minor: every credential write does two fs.mkdir(stateDir) syscalls (lock placement at credentials.js:633 then again in writeCredentials:672) - harmless, one is droppable.

Review method: 8 finder angles (3 correctness, reuse/simplify/efficiency, altitude, conventions) then per-finding verification. Conventions (no-semicolons, no em dashes, @import paths) came back clean.

platypii added 3 commits June 30, 2026 10:13
…e 401

A rejected authorization code (expired, replayed, or mis-scoped client)
went through the same 401 -> InvalidGrantError mapping as a refresh, so a
first-time login reported 'refresh token was rejected' for a credential
that never had a refresh token. Map an authorization_code 401 to a
code-specific message instead.
…e fails

In runBrowserLogin the single-use code exchange and writeSession shared
one try/catch, so a write failure (most likely a lock timeout under a
concurrent hyp process) printed the generic login-failed message plus the
headless 'no browser' hint, even though the sign-in succeeded. Split the
two phases: a post-exchange write failure now reports a store failure and
points at the lock, without implying the browser login failed.
…dance

describeAuthRejection's static-token branch told the user to 're-run hyp
remote login' with no target name, while the env branch, sessionExpiredMessage,
and noTokenError all include it. Thread the target through so the re-login
advice is consistent across paths.
@platypii

Copy link
Copy Markdown
Contributor Author

Code review: correctness findings

Reviewed the OIDC client-login diff for real bugs (not style). The PKCE/state/CSRF/permissions/expiry core is solid. Four issues worth a look, roughly highest-confidence first.

1. Refresh race: a concurrent loser reports a false "session expired" (src/core/remote/credentials.js:421)

In refreshOidcSession, when a refresh fails with invalid_grant, the catch re-reads under the lock and re-throws if the stored refresh token is unchanged. The comment claims the re-read is "guaranteed visible (it holds the lock across its write)", but the winner only takes the lock at line 429, after its HTTP refresh returns.

Two hyp processes (e.g. a running proxy beside a verb call) both refresh with R1 against a one-time-use refresh server. The server mints R2 for the winner W and rejects the loser L with invalid_grant. If L acquires the lock at 421 before W has reached its lock at 429, L still sees R1 unchanged and re-throws, surfacing "remote session expired, re-run hyp remote login" even though the session was just validly refreshed (W commits R2 moments later). The lock only serializes against a sibling that has already acquired it, so the comment's guarantee does not hold in that window.

2. Loopback receiver is single-shot: one stray /callback aborts the whole login (src/core/remote/loopback.js:107-128)

The handler calls fail() (which settles and server.close()s) on the first /callback request carrying error=, a mismatched state, or no code. The error= branch does not even require a matching state. The ephemeral loopback port is reachable by any local process and by a page in the user's browser during login (a no-cors fetch / navigation still reaches the server even though the response can't be read).

A single GET http://127.0.0.1:<port>/callback?error=x before the genuine redirect arrives makes the receiver fail and close, so the real browser callback gets connection-refused and the login dies. It's a repeatable local login denial-of-service. The CSRF guard still protects the code path, so this is availability, not credential theft. A robust receiver should ignore non-matching callbacks and keep waiting until the correct state arrives or it times out.

3. Stale-lock steal is a TOCTOU that lets two writers clobber (src/core/remote/credentials.js:644-650)

withCredentialsLock steals a lock older than LOCK_STALE_MS (30s) by fs.rm-ing it then re-acquiring. If two processes both observe the same stale lock, A removes it and open('wx') succeeds, then B's unconditional fs.rm(lockPath, {force:true}) removes A's freshly-created lock and B's open('wx') also succeeds. Both now run the read-modify-write critical section concurrently and the later rename drops the other's just-rotated one-time-use refresh token, forcing a needless re-login. The finally { fs.rm(lockPath) } compounds it: A on exit removes what is now B's lock. Rarer than #1 (needs a crashed holder leaving a stale lock plus two concurrent stealers), but it defeats the mutual exclusion the lock exists to provide. The steal needs to be atomic (e.g. rename-based, or re-verify ownership after the rm).

4. --org=acme / --token-file=path (equals form) silently ignored (src/core/cli/remote_commands.js:106)

argv.indexOf('--org') matches only the bare token --org, so --org=acme yields orgIdx === -1 and org stays undefined; positionals then treats --org=acme as a flag and consumes no value. The browser flow runs with no org, so a multi-org account silently fails later with org_selection_required instead of selecting acme. Same for --token-file=/path, which falls through to the browser flow instead of reading the file. The codebase accepts the equals form for this exact flag elsewhere (core_commands.js:3361 handles --token-file=), so this is an inconsistency users will hit.


Lower-signal items I looked at and am not flagging: exchangeCode requiring org on the code grant (appears intentional, org is fixed for the refresh-token life), the --no-browser cross-machine hint, and the resolveToken empty-bearer contract (no live caller misuses it). The parse cache and proxy session-id paths trace clean.

platypii added 3 commits June 30, 2026 11:15
The optimistic refresh compare-and-swap (refresh outside the lock, commit
if the stored token still matched) had a race: a loser that re-read on
invalid_grant before the winner committed saw an unchanged token and
wrongly forced a re-login. The lock's stale-steal was also unsafe - two
contenders could both steal an aged lock and clobber each other, and a
slow-but-live holder could be stolen by the 30s age guess.

Refresh is now single-flight: the whole read-decide-refresh-commit runs
inside one lock hold, so only one process ever calls the token endpoint
for a store at a time. A sibling never sees invalid_grant from contention,
so invalid_grant under the lock is an unambiguous revocation; the retry
loop and the "did the token change" re-read are gone.

The lock records the holder's host+pid and is a real mutex: a contender
steals a held lock only when that process is confirmed dead, and steals
atomically (single-winner rename) so two stealers can't both adopt it; a
holder only removes a lock still its own. Age remains a backstop for a
cross-host or unparseable lock. Lock wait/stale thresholds raised to cover
holding the lock across the bounded token call.

Updates LLP 0046 D5 to specify the mechanism, not the racy policy.
…e login

The receiver settled (and closed, single-shot) on the first /callback hit
carrying error=, a mismatched state, or no code - and the error= branch ran
before the state check. The ephemeral loopback port is reachable by any local
process and by any page the user is browsing (a no-cors GET still reaches us),
so a single `GET /callback?error=x` or `?state=wrong` before the genuine
redirect arrived would fail-and-close the listener, leaving the real browser
callback with connection-refused: a trivial login denial-of-service.

Apply the CSRF state check first and ignore any non-matching-state request
(neutral page, never settle), so only a callback bearing our exact state can
abort or complete the login. The identity server echoes state on both success
and error (LLP 0047), so genuine provider errors still match and surface.

Aligns LLP 0047, which already documents error redirects as carrying state.
runRemoteLogin matched these flags with argv.indexOf('--org'), which only
finds the bare ` --org value` form. `hyp remote login prod --org=acme` left
org undefined and let positionals skip --org=acme as a flag, so the browser
flow ran with no org and a multi-org account later failed with
org_selection_required. --token-file=path likewise fell through to the
browser flow instead of reading the file. The rest of the CLI already takes
the equals form (core_commands' --token-file=), so this was an inconsistency
users hit.

Parse both forms via a shared valueFlag helper. The space form still rejects
a following flag-looking token as "expects a value"; the equals form takes
its value verbatim and rejects only an empty one.
@platypii

Copy link
Copy Markdown
Contributor Author

Code review: real-bug pass

Reviewed the branch diff (focus on correctness, not style). Most of the PR is solid; the live issues cluster in the new cross-process credential lock (src/core/remote/credentials.js). Ranked by severity.

1. Age backstop steals a live same-host lock holder, reintroducing the lost-refresh-token race

src/core/remote/credentials.js:654

const abandoned = lockHolderIsDead(ownerText) || Date.now() - st.mtimeMs > LOCK_STALE_MS

The || applies the 90s age threshold unconditionally, even to a holder we can probe and that is provably alive. This contradicts withCredentialsLock's own doc comment ("...nor (the inverse) lets a fixed age threshold misfire on a merely slow live holder"). The lock mtime is set at acquisition and never renewed, so any same-host holder whose hold exceeds 90s (laptop sleep / SIGSTOP mid-refresh, or writeCredentials stalling on an NFS $HYP_HOME past the 30s token-endpoint timeout) gets its lock stolen by a contender. Two writers then run concurrently and the later tmp+rename clobbers the first writer's just-rotated one-time-use refresh token, so the next refresh hits invalid_grant and forces a full re-login. That is exactly the race the lock exists to prevent. The age backstop should only apply when liveness can't be probed (cross-host / unparseable owner).

2. LOCK_TIMEOUT_MS (45s) < LOCK_STALE_MS (90s): in-flight waiters can never reach the steal threshold

src/core/remote/credentials.js:709 vs :654

A waiter's deadline is now + 45s, but an abandoned lock only becomes stealable once its age passes 90s. So a waiter that arrives while the lock is younger than ~45s gives up (throwing timed out acquiring the remote credentials lock) before the lock ever ages into stealable territory. After a cross-host crash (NFS shared home, host mismatch so liveness can't be probed), or a same-host crash where the pid is recycled (process.kill(pid,0) reports the recycled process alive), every hyp invocation in the first ~45-90s after the crash waits 45s and fails; only fresh invocations starting >=90s after the crash recover. Recovery is meant to be automatic but a single waiter can't trigger it.

3. The actual MCP request fetch has no timeout (only the token endpoint does)

src/core/mcp/client.js:55, src/core/mcp/proxy.js:74

const res = await doFetch(opts.url, { method: 'POST', headers, body: JSON.stringify(body) })   // client rpc
return fetchImpl(entry.url, { method: 'POST', headers, body: JSON.stringify(message) })        // proxy forward

The /token refresh is bounded by TOKEN_TIMEOUT_MS (30s), but the hot-path MCP call this PR routes through attachWithRefresh has no AbortSignal. A remote that accepts the connection but never finishes the response body hangs the hyp <verb> --remote call and every forwarded stdio-proxy message indefinitely. Give these the same bounded-timeout treatment as the token call.

4. A failed owner-tag write leaks an empty lock file that wedges all writes for ~90s

src/core/remote/credentials.js:712-720

fs.open(lockPath, 'wx') creates the file; if handle.writeFile(LOCK_OWNER) then throws (ENOSPC/EIO/quota), the error is non-EEXIST and rethrows out of withCredentialsLock before any cleanup, leaving a 0-byte lock. Subsequent acquirers read '', lockHolderIsDead('') returns false (JSON.parse fails -> "not dead"), and age < 90s -> not abandoned, so every writeToken/writeSession/refresh blocks and then throws until the 90s age backstop frees it. Write the owner tag before/atomically with creation, or unlink on write failure.

5. stealLockIfAbandoned restore path briefly removes a live successor's lock (mutual-exclusion gap)

src/core/remote/credentials.js:658-679

When a steal renames the lock aside but finds (via the owner-text mismatch) that a new holder grabbed it in the race window, it renames the captured file back to lockPath. Between the steal-rename and the restore-rename, lockPath does not exist, so a third contender's fs.open(lockPath, 'wx') can succeed and enter the critical section alongside the holder whose lock was just restored. The doc comment only reasons about two stealers colliding; this is a stealer-plus-fresh-acquirer path. Very tight multi-process race, lowest probability of the set, but it is a real exclusivity hole.

6. No https enforcement on the derived identity origin

src/core/remote/credentials.js:77

deriveIdentityBase trusts the remote URL scheme, so hyp remote add x http://host/mcp makes login and every silent refresh POST the PKCE code, refresh token, and access JWT to http://host/v1/identity in cleartext. The design assumes https "in any real deployment" but nothing enforces it, so a misconfigured/downgraded origin silently leaks the long-lived refresh token to an on-path attacker. Consider rejecting a non-https identity base (with a localhost/dev exception).


Items 1-3 are the ones I'd fix before merge. Note that 1 and 2 are in tension over the same LOCK_STALE_MS constant (too eager for live same-host holders, too slow for in-flight waiters of abandoned locks), which suggests separating "probeable -> use liveness only" from "unprobeable -> use a shorter age backstop, < the wait timeout."

Automated review, focused on correctness. Skipped style/cleanup per request.

… mutex

The cross-process credential lock (LLP 0046 D5) kept its single-flight
refresh but shipped a crash-recovery mechanism with six defects: a same-host
liveness probe that could not see a suspended-but-alive holder (so the age
backstop stole live locks), a 45s wait shorter than the 90s steal age (so
waiters never reached the steal), an empty-lock wedge on a failed owner-tag
write, and a rename-aside-and-restore steal whose restore gap allowed a double
hold.

Replace all of it with one rule set (LLP 0049 D1): grant only by O_EXCL,
break a lock past one stale age with a plain fs.rm, and release only when a
per-acquisition nonce still matches. Breaking never grants the lock, so a
four-line break is safe where the old steal was not. One LOCK_STALE_MS (60s,
2x the bounded token call) replaces two constants in tension; the {host,pid}
tag, process.kill probe, stealLockIfAbandoned, and LOCK_TIMEOUT_MS are gone,
along with the node:os import.

The single-flight refresh under the lock is unchanged and still necessary for
one-time-use refresh-token rotation. Retargets the one steal test from a
dead-pid probe to an age-stale break.
@platypii

Copy link
Copy Markdown
Contributor Author

Code review — correctness pass (multi-agent, high effort)

Reviewed the OIDC client login diff (master...HEAD) for real bugs, not style. The lock/refresh hardening in credentials.js is genuinely careful; most paths hold up. Below are the defects worth acting on, ranked. The known lock-race TOCTOU windows that LLP 0049 already documents as accepted (breakLockIfStale and the release-path nonce check) are not re-reported here.

1. A genuine 403 Forbidden is treated as a refreshable/expired session

src/core/mcp/client.js:130isAuthStatus returns true for both 401 and 403, so a 403 (valid token, insufficient permission/scope) triggers a refresh + retry, and when it survives, describeAuthRejection returns sessionExpiredMessage ("remote session expired - re-run hyp remote login"). A refresh cannot fix a permissions denial, so the user is sent into a re-login loop that can never succeed, and on the proxy the same 403 drives the per-message refresh churn below. 403 should not be conflated with 401 for the refresh-and-relogin policy.

2. A JSON-RPC notification that 401s is refreshed and re-forwarded (double delivery)

src/core/mcp/proxy.js:111 — notifications (message.id === undefined, isNotify) still flow through attachWithRefreshop. If the first forward returns 401 on a refreshable session, it forces a refresh and calls op() again, re-POSTing the same notification. The server processes it twice (the second response is discarded), violating at-most-once for non-idempotent notifications, and it spends a one-time-use refresh-token rotation on a message whose result is thrown away. Notifications shouldn't be retried.

3. parseRpcResponse reports an empty response as a successful undefined result

src/core/mcp/client.js:163 (and :171) — pickById(messages, id) ?? messages[messages.length - 1]. When an SSE/array body yields no parseable frames, messages is [], so messages[-1] is undefined and the function returns undefined instead of throwing. The caller then treats undefined as a successful result (exit 0) rather than surfacing a transport error. A messages.length === 0 guard should throw the non-JSON/empty-body error the JSON path already raises.

4. exchangeCode hard-requires org, but refresh treats it as optional

src/core/remote/identity_client.js:92 — the authorization_code grant does str(json.org, 'org'), which throws on a missing/empty org, while refreshSession:113 deliberately tolerates an omitted org (typeof json.org === 'string' ? json.org : ''). A single-org server (or one that returns org: "" on the code grant) fails the entire browser login at the very last step, after the code is already spent, and surfaces the misleading headless hint. The two grants should handle org consistently.

5. Sibling-adopt freshness check uses a clock sampled before the lock wait

src/core/remote/credentials.js:423now is captured at resolveAccessJwt entry (:351) and threaded unchanged into refreshOidcSession, where the lock may be awaited for tens of seconds (a sibling holding it across the ~30s token call, or breaking a 60s-stale lock). isFresh(latest, now) then evaluates expiry against a stale now, biasing toward "fresh", so it can adopt and return a JWT that is actually within-skew or already expired. The first forwarded request 401s. It self-heals via the one-shot retry, but the resolver's "returns a fresh JWT" contract is violated and a round-trip is wasted. Re-sample Date.now() after acquiring the lock. (Flagged independently by 4 of the finder passes.)

6. No cross-message backstop on a persistently-401 endpoint with a working refresh

src/core/mcp/proxy.js:113attachWithRefresh is bounded to one retry per message, but the proxy loop is not bounded across messages. If the MCP endpoint rejects the access JWT persistently (wrong audience/scope/misconfig) while /token keeps minting fresh JWTs, no invalid_grant is ever raised, so the proxy force-refreshes on every forwarded message indefinitely, churning (and on a rotating server, exhausting) the refresh token instead of detecting a dead session and stopping.

7. Headless escape-hatch hint is appended to login failures it doesn't fit

src/core/cli/remote_commands.js:304 — any login error without a callbackError (a rejected/expired authorization code from exchangeCode, an identity-endpoint network blip) gets the "on a machine with no browser, pass a static token / --no-browser prints the URL" hint. A user who did open a browser and complete sign-in, but hit a code-exchange race or transient blip, is told to switch to a headless flow that neither diagnoses nor fixes the failure. Only the loopback-timeout case the hint is written for is correctly classified by !callbackError.


Examined and deliberately not reported: the breakLockIfStale stat→rm and release-path nonce→rm TOCTOU windows (and the commitSession re-resurrect that depends on losing exclusion) — all bounded, self-healing, and explicitly accepted in LLP 0049; the missing AbortSignal on the MCP forward fetch (LLP 0049 out-of-scope); Host-header/DNS-rebinding on the loopback (mitigated by 128-bit state + random ephemeral port); and the --no-browser/empty-stdin flow edges (intended per commits d8de797 / 044b3d7). PKCE (S256, 256-bit verifier, native unpadded base64url) and open_browser (URL passed as a spawn arg, no shell on any platform) are correct.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant