Skip to content

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

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

Multi-tenant OIDC browser login on the client (LLP 0046-0048)#204
platypii wants to merge 51 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.
Comment thread src/core/remote/credentials.js Outdated
// Cache hit: same file, unchanged since the last parse. Return a clone so a
// mutating caller (the write path) can't corrupt the cached value.
if (rawCache && rawCache.path === p && rawCache.mtimeMs === stat.mtimeMs && rawCache.size === stat.size) {
return structuredClone(rawCache.value)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor efficiency: the cache hit still deep-clones. This cache exists so the per-message proxy resolve skips re-read + re-parse, but every hit still structuredClones the whole map. Only the write path mutates its result; the read paths (resolveAccessJwt/resolveToken) treat it as read-only. Returning the cached value directly for reads (and cloning only in the write path, which already re-reads) would make a fresh-JWT message allocation-free. Low impact given messages are serial, but it partly defeats the cache's purpose.

platypii added 3 commits June 29, 2026 15:42
The 0600 credential store is shared by concurrent hyp processes (a second
MCP client, or a verb call beside a running proxy). With one-time-use
refresh tokens, the loser of a refresh race got invalid_grant only because
the winner had already rotated the row, and was told to re-run
'hyp remote login' despite holding a valid session.

resolveAccessJwt now re-reads the store once on invalid_grant: if the
stored refresh token changed, it adopts the winner's freshly-minted session
(its JWT if still fresh, else a refresh with the rotated token) instead of
surfacing re-login guidance. An unchanged token is a real revocation and
still surfaces the guidance. Documented in LLP 0046 D5.
The no-browser-with-piped-stdin branch is only reached when useStatic is
false, and useStatic already forces the static path whenever --token-file
is present, so tokenFile is guaranteed absent here. Drop the dead condition.
Export the 401/403 predicate from client.js and have the stdio proxy's
per-message forward call it, so "which statuses mean auth" has one home
rather than a copy in the proxy.
@platypii

Copy link
Copy Markdown
Contributor Author

Code review — multi-agent (high effort, recall-biased)

8 finder angles → verify pass. 10 findings survived (2 candidates refuted: the deriveIdentityBase origin-only behavior is the documented LLP 0046 D6 caveat, and the runBrowserLogin disk-vs-memory config read provably resolves to the same file). Findings ranked most severe first; correctness above cleanup.

Correctness

1. Loopback: a stray callback with a mismatched/absent state permanently kills the loginsrc/core/remote/loopback.js:99
The wrong-path branch writes a 404 and returns without settling, leaving the flow open for the real callback. But the state-mismatch branch calls fail(), which sets settled = true, closes the server, and rejects waitForCode permanently. Any request to /callback with a wrong/absent state (returnedState is null for a prefetch) reaches it. A browser URL-prefetcher, a corporate link scanner, a second tab, or a refresh of the redirect_uri hitting the loopback before the genuine provider redirect makes hyp remote login fail with "mismatched state" even though the real authorization succeeded. Consider ignoring (404/return) unrecognized-state callbacks the same way wrong-path is ignored, and only failing on a matched-state error.

2. Proxy startup probe (presence-only) lets a stranded/revoked OIDC session "start"src/core/mcp/proxy.js:49
The startup probe uses resolveToken (presence only, never refreshes) while per-message forwarding uses resolveAccessJwt (refreshes). Two divergences result: (a) a session with a valid refresh token but an empty accessJwt fails the probe (bearerOf returns '') so the proxy refuses to start, even though resolveAccessJwt could mint a fresh JWT from the refresh token; (b) a fully-revoked session (expired JWT present, revoked refresh token) passes the probe, the proxy prints "proxying" and returns 0, then every forwarded message refreshes, hits invalid_grant, and returns a JSON-RPC error — a working-but-permanently-erroring server instead of a clean startup failure. Probing with the same session-aware resolve used per message would make both cases behave correctly.

3. Proxy emits a bare "remote returned HTTP 401" with no re-login guidance after a post-refresh rejectionsrc/core/mcp/proxy.js:141
When a refresh succeeds but the retried forward still returns 401/403 (clock skew, server-side revocation independent of the refresh row), attachWithRefresh returns the second op's {res: 401} and the proxy emits jsonRpcError(id, INTERNAL_ERROR, 'remote returned HTTP 401') — a bare status with no hint. The verb path's equivalent throws authRejectionError with "re-run 'hyp remote login'". Same condition, inconsistent guidance depending on the path.

4. Credentials parse cache can serve stale data on a same-size out-of-band rewritesrc/core/remote/credentials.js:132
The single-entry rawCache is keyed on path + mtimeMs + size and is invalidated only on in-module writes. A same-size rewrite by another process (or by the smoke's forceExpiry, which flips 29992000, identical length) landing within one coarse mtime tick (1s on HFS+/some CI/overlay/tmpfs mounts) returns a stale parse. Two impacts: the shipped remote_oidc_login smoke's refreshCalls === 1 assertion can flake (stale future-expiry → no refresh fires), and the concurrent-rotation invalid_grant re-read at credentials.js:327 can hit its own stale map, see an unchanged refresh token, and force a spurious re-login instead of adopting the winner's rotated session — defeating the D5 tolerance it exists for. Adding a content check (or busting the cache on the re-read path) would close it.

5. isFresh treats an unparseable expiresAt as stale → unbounded per-message refreshsrc/core/remote/credentials.js:412
isFresh returns false when Date.parse(expiresAt) is NaN. identity_client.js stores expires_at with only a non-empty-string check (no Date.parse), and the stdio proxy resolves a JWT per forwarded message with no throttle. If the server returns a non-ISO but non-empty expires_at (e.g. epoch-seconds-as-string "1719600000", which Date.parse rejects), every message becomes a full refresh_token round-trip and the freshly stored expiry is equally unparseable, so it never self-corrects — an IdP-hammering latency amplifier the moment the server's expiry contract slips. A Date.parse validity check at store time would fail fast instead.

6. Non-TTY stdin routes to static login and never opens the browsersrc/core/cli/remote_commands.js:126
useStatic is gated on stdinPiped (stdin.isTTY === false). Run from an IDE-integrated terminal, a process supervisor, or with < /dev/null, stdin is not a TTY, so the default browser flow never runs — runStaticLogin reads an empty token and exits 2. There is an actionable hint ("re-run with --browser"), so this is a UX papercut rather than a silent break, but the default login path breaks whenever stdin is incidentally not a TTY on a machine that has a browser.

7. Loopback: a present-but-empty error= param is misclassified as "no code"src/core/remote/loopback.js:104
const error = params.get('error') returns '' for ?error=, which is falsy, so if (error) is skipped and the callback falls through to the generic no_code path instead of being surfaced via explainLoginError. Requires an out-of-spec provider (RFC 6749 mandates a non-empty error code), so it is a cosmetic mislabel, but params.has('error') would classify it correctly.

Cleanup / altitude

8. isAuthError re-inlines the 401/403 check instead of the isAuthStatus helper this PR introducedsrc/core/mcp/remote_verb.js:121
client.js exports isAuthStatus as "the single home for which statuses mean auth," and proxy.js imports it. remote_verb.js hard-codes status === 401 || status === 403. The centralization is incomplete: if the auth-status set ever changes, the proxy path follows and the verb path silently diverges. isAuthStatus(err.status) (or just err.authError, since the producer always tags it) expresses the intent at the right altitude.

9. mapRefreshError and proxyAuthMessage duplicate the same InvalidGrantError → sessionExpiredMessage classificationsrc/core/mcp/remote_verb.js:107 / src/core/mcp/proxy.js:175
Both attach paths translate a refresh failure identically, differing only in wrapper shape (string vs verb-result). The load-bearing "is this a revocation → re-login" decision is copied; a future change to how refresh errors are classified must touch both files or the proxy and verb paths drift in what they tell the user. The PR otherwise centralized this kind of policy (attachWithRefresh, isRefreshable, sessionExpiredMessage) — this is the one spot left copied.

10. readRawCredentials deep-clones the whole map on every cache hit, on the proxy's per-message hot pathsrc/core/remote/credentials.js:133
The structuredClone guards the cache against the write path's in-place mutation, but the dominant caller readCredentials never mutates the raw value (it builds a fresh out). The stdio proxy resolves a token per forwarded message, so every message now deep-clones the entire credential map even on a cache hit, partially defeating the cache. A non-cloning read for readCredentials (cloning only in writeToken/removeToken) avoids the per-message allocation.


Generated by a multi-agent review (8 finders → verifiers). Items 4–7 are PLAUSIBLE (realistic but state-dependent); 1–3 are CONFIRMED.

- proxy: a 401/403 surviving the refresh+retry now returns the re-login
  guidance the verb path gives, not a bare "remote returned HTTP 401".
- identity: reject a non-date expires_at at parse time so a bad server
  value can't turn every forwarded message into a fresh refresh that
  re-stores the same value and never self-corrects.
- credentials: drop the parse cache before the invalid_grant re-read so a
  same-size concurrent token rotation can't hide behind the cache; and
  stop deep-cloning the whole store on every per-message read (only the
  in-place write path clones now).
- share the 401/403 status check (isAuthStatus) and the invalid_grant ->
  session-expired mapping (describeRefreshError) instead of copy-pasting
  them across the proxy and verb attach paths.
- loopback: treat a present-but-empty error= callback as an error.
@platypii

Copy link
Copy Markdown
Contributor Author

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

Reviewed master...HEAD (27 files). 8 finder angles, then per-candidate verification. 10 findings survived; one candidate (intra-proxy concurrent refresh) was refuted because the stdio server dispatches messages serially (src/core/mcp/stdio.js:34).

Correctness

1. Headless hyp remote login hangs ~5 minutes instead of failing fast - src/core/cli/remote_commands.js:164
The old TTY no-token fast-fail (print guidance, return 2) was removed. A login with no --token-file and nothing piped now has useStatic === false and falls through to runBrowserLogin -> loginWithBrowser -> waitForCode(), which blocks on the default DEFAULT_TIMEOUT_MS = 5 * 60 * 1000 (loopback.js:19). On an SSH/CI box with no display the browser cannot open, so the command hangs for five minutes before failing instead of erroring immediately. Consider a short headless timeout or detecting no-DISPLAY and routing to --no-browser guidance.

2. 401/403 on an env/static credential gives unactionable "re-run hyp remote login" - src/core/mcp/proxy.js:145
const detail = isAuthStatus(res.status) ? sessionExpiredMessage(target) : ... ignores credential kind. For a per-target env token (HYP_REMOTE_TOKEN_<TARGET>, which always wins at credentials.js:299) or a CI static token, hyp remote login cannot fix the credential, so the advice misleads and masks the real cause. The old code emitted the accurate remote returned HTTP <status>. Gate the message on isRefreshable(resolved).

3. Refreshable OIDC session refuses to start when the cached accessJwt is empty/missing - src/core/mcp/proxy.js:49 and src/core/remote/credentials.js:174
The startup probe uses the non-refreshing resolveToken/bearerOf. For an oidc record with a valid refreshToken but accessJwt: '', bearerOf returns '' so the probe exits 2 ("run hyp remote login"), even though resolveAccessJwt would mint a fresh JWT (isFresh treats '' as not-fresh). Worse, if accessJwt is absent entirely, normalizeRecord drops the whole record as null, forcing a full re-login. Reachable via a partial write or interrupted refresh. The presence check and the refresh path disagree on what counts as usable.

4. Token endpoint POST has no timeout, on the proxy per-message hot path - src/core/remote/identity_client.js:125
postToken's fetch passes no signal/AbortController. It is reached per JSON-RPC message via handleMessage -> resolveAccessJwt (proxy.js:93) -> refreshSession. A hung or slow identity /token blocks that message with no upper bound. Note the loopback receiver got a 5-minute timeout but the token call, the one actually on the request path, got none.

5. A single verb command can rotate the refresh token twice - src/core/mcp/remote_verb.js:44 and :69
When the cached JWT is stale, the initial resolveAccessJwt refreshes and persists (rotating the refresh token). If that fresh JWT then 401s mid-flight (clock skew / replication lag), attachWithRefresh's refresh() closure calls resolveAccessJwt({forceRefresh:true}), rotating again. Against a strict one-time-use refresh server, one command consumes two rotations; rapid commands amplify churn and widen the invalid_grant race window.

Robustness / UX

6. Loopback receiver lingers ~5s at exit - src/core/remote/loopback.js:180
Shutdown calls only server.close(), never closeAllConnections(), and the success response (:204) sets no Connection: close. The browser's keep-alive callback socket keeps the event loop alive until Node's keepAliveTimeout, so hyp remote login hangs ~5s after an otherwise-successful login. The sibling smoke (hypaware-core/smoke/flows/remote_oidc_login.js:101) already calls closeAllConnections() for exactly this reason.

7. "Opened your browser to sign in" prints even when nothing opened - src/core/remote/open_browser.js:35, caller src/core/remote/oidc_login.js:62
openBrowser returns true as soon as spawn returns and swallows the async error event (e.g. missing xdg-open). The success message is gated on that boolean, so a headless user is told a browser opened when it did not. Mitigated: the manual URL is always printed on the next line, so the user is not stranded, but the message is misleading.

8. Verb path lacks the globalThis.fetch guard that the proxy has - src/core/mcp/remote_verb.js:44
proxy.js:58 fails clearly with "no fetch implementation available" before refreshing. The verb path has no such precheck, so on a fetchless runtime a stale-JWT oidc command throws from the refresh path into mapRefreshError, yielding a generic exit-1 with no actionable guidance.

Lower severity

9. Parse cache can serve a stale credential on a same-size, same-mtime-tick external rewrite - src/core/remote/credentials.js:137
rawCache is keyed on path + mtimeMs + size and busted only by internal writers. The new smoke's forceExpiry rewrites expiresAt from a 2999 to a 2000 date (both 20 chars, size unchanged) out-of-band without nulling the cache, so on a coarse-mtime filesystem the next read is a cache hit on the pre-expiry value, the refresh is skipped, and the "exactly one refresh" assertion flakes. The code comment at :333 already acknowledges this same-tick hazard.

10. exchangeCode hard-requires org while refreshSession tolerates it missing - src/core/remote/identity_client.js:78
org: str(json.org, 'org') throws "identity response missing 'org'" on an absent value, whereas refreshSession uses typeof json.org === 'string' ? json.org : '' (:99). A valid login response that omits org fails entirely. Likely intended (the server establishes org at login per the :95 comment), so lower severity, but the asymmetry is real.


Generated with /code-review (high effort). Findings 1-5 are CONFIRMED correctness issues; 9 is a test flake; 10 is plausible-but-likely-intended.

platypii added 7 commits June 29, 2026 17:34
normalizeRecord dropped an explicit oidc record that had a valid refresh
token but a missing or empty accessJwt (a partial write, an interrupted
refresh, a hand edit), and the proxy presence-probe (resolveToken)
likewise treated it as no-credential. Both forced a needless full
re-login for a session that resolveAccessJwt could mint a fresh JWT for.

Keep an explicit-kind oidc record whenever its refresh token is non-empty,
defaulting accessJwt to ''. Inferred (no-kind) records still require the
full refresh+access pair so one carrying a static token is not hijacked.
resolveToken now reports an oidc record as present even when its cached
JWT is empty, so the fail-fast probe does not refuse a refreshable session.
postToken's fetch had no deadline, but the stdio proxy refreshes on the
per-message hot path (resolveAccessJwt -> refreshSession), so a hung or
slow identity endpoint blocked a forwarded JSON-RPC message indefinitely.
The loopback receiver had a timeout; the token call, the one actually on
the request path, did not.

Wrap the fetch and body read in a 30s AbortController, clearing the timer
in finally so a fast response does not keep the event loop alive, and
surface an abort as a clear "did not respond within 30s" error.
A 401/403 surviving the refresh+retry was reported as "remote session
expired - re-run hyp remote login" regardless of credential kind. For a
per-target env override (never read from the store) or a CI static token,
re-login cannot fix it. Gate the session-expired wording on
isRefreshable(resolved); otherwise point at the credential itself.
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.

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