Skip to content

🚧 feat: AI agent chat - #1176

Draft
thelg4 wants to merge 213 commits into
mainfrom
feature/agent-chat-interface
Draft

🚧 feat: AI agent chat#1176
thelg4 wants to merge 213 commits into
mainfrom
feature/agent-chat-interface

Conversation

@thelg4

@thelg4 thelg4 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Important

Do not merge. This PR exists to spin up a preview/staging build so people can try the feature. Opened as a draft to keep it un-mergeable.

What this is

Mycal: an AI chat panel embedded in the desktop app, backed by the sibling ai-agent service. Always on in local dev builds; in deployed builds it soft-launches behind the hidden Test UI.

Trying it on the preview build

  1. Hold shift+option (alt) and click your avatar (bottom-left) → choose Test UI
  2. The robot icon appears in the header — that's the chat toggle
  3. Go to Settings → Test Settings → enable "Override agent service":
    • Agent service URL: https://dev-ai-agent.remote.it
    • Agent MCP audience: https://mcp.demo.remote.it/mcp
  4. Open the chat, sign in to the agent when prompted, and talk to it

What's in the branch

  • Chat panel — docked column beside the app panels with streaming replies, tool-call display, and write-action approval prompts
  • Agent sign-in — separate Hydra OAuth session (PKCE + dynamic client registration); tears down with app sign-out
  • Org scoping — the chat follows the app's active org (sidebar selector) and sends it with each turn so the agent scopes org queries without hunting; shown as a read-only "Current Org" label
  • Popout window — the chat pops out to its own window (native window in Electron) and hands the conversation back on pop-in/close
  • Test Settings overrides — point the chat at any deployed agent (URL + MCP audience), no env changes
  • UX polish — user-facing unavailable/sign-in states, app-standard styling, themed scrollbars

Known blockers for a real reply

  • The deployed agent's Anthropic API key secret needs re-saving as plaintext + a service redeploy (chat turns currently return an upstream auth error)
  • ai-agent main is missing the tool-discipline prompt changes (stranded on feature/chat-org-scope); a rebuild after that lands improves org-scoped behavior

Comment thread frontend/src/services/agent.ts Fixed
Comment thread frontend/src/services/hydra.ts Fixed
@aws-amplify-us-west-1

Copy link
Copy Markdown

This pull request is automatically being deployed by Amplify Hosting (learn more).

Access this pull request here: https://pr-1176.d20k671nqqv4kl.amplifyapp.com

@JamieRuderman JamieRuderman changed the title feat: Mycal — in-app AI agent chat (dev preview, do not merge) 🚧 feat: Mycal — in-app AI agent chat (dev preview, do not merge) Aug 5, 2026
thelg4 and others added 27 commits August 10, 2026 15:19
Docked right-column chat talking to the ai-agent service over SSE:
- ChatPanel/ChatMessages/ChatToolCalls/ChatApproval/ChatInput components
- Persisted chat model (transcript, streaming, write-tool confirmations)
- Panel width folds into sidePanelWidth so all panel layouts reflow
- Expand toggle (400px/640px), full-screen overlay below single-panel width
- Vite dev proxy /agent -> :3001 (CSP-safe); VITE_AGENT_URL for deploys
- react-markdown + remark-gfm for assistant replies
- Dev-gated: header robot toggle and panel render only in dev builds
Stage A of agent auth: attach Authorization from a locally stored token
(pasted from the ai-agent dev harness) on chat/confirm/health calls,
surface 401 reauth_required and mid-turn auth failures as a sign-in
notice with a token paste field. The in-app PKCE flow replaces the
paste field as the token writer in a later stage.
Replaces the pasted-token stopgap: a Sign in button self-registers an
OAuth client (Dynamic Client Registration), redirects to the Hydra
login/consent pages, exchanges the code on return (PKCE), and silently
refreshes the 30m access token before each turn. Register/token calls
ride the new dev-only /hydra vite proxy so no CORS setup is needed;
packaged builds need the origin allow-listed or a main-process exchange.
Amplify's OAuth listener consumes and strips ?code/state on page load for
the Cognito flow; the Hydra callback uses the same params on the same
origin, so the agent exchange never ran and sign-in looped. Capture the
params at module-evaluation time (before Amplify configures), claim them
only when this tab started an agent sign-in, and strip the URL
immediately so neither flow double-handles the code.
App sign-out now clears the stored Hydra access/refresh tokens, resets
the chat transcript, and best-effort revokes the refresh token at Hydra
so the otherwise never-expiring refresh chain dies server-side. Local
credentials are cleared synchronously so a sign-out-triggered reload
cannot race the revoke call.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fall back to membership.name in ChatOrgSelect and chat.ts send() when
organization.accounts is unloaded, so org scope never silently
degrades to personal; guard against a whitespace-only name so the
client never sends one the server's non-empty-after-trim validation
would reject. Force-adopt the app's active org on the first syncOrg
after load so orgId (redux-persisted) doesn't survive page reloads,
per the "not persisted" spec decision, while preserving intra-session
divergence across panel close/open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Matches the ai-agent service's move off mcp.evan.remote.it (older
build without org-scoped list_scripts). The client cache key includes
the audience, so a fresh sign-in re-registers automatically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AGENT_PROXY_TARGET in frontend/.env points the same-origin /agent proxy
at a deployed agent (e.g. http://dev-ai-agent.remote.it) instead of the
local dev service — the proxy hop keeps CSP satisfied while the ALB is
HTTP-only.
Replaces the dev-facing ':3001' banner: zero-state layout (matching the
sign-in state) when the chat is empty, compact notice above an existing
transcript, and the input disabled while Mycal is unreachable.
The org sidebar (bottom-left avatar stack) is now the single org
selector: the chat mirrors accounts.activeId and shows a read-only
'Current Org' label where the dropdown was. The popout window keeps
the org handed off with its conversation. The request to the agent
is unchanged (org { id, name }, omitted for Personal).
evanrbowers and others added 27 commits September 1, 2026 18:57
…not persist

The rotation wrote its successor unconditionally, so an in-flight refresh
completing just after sign-out resurrected the signed-out account — tokens
AND registry entry (persist() files accounts, which is load-bearing for
rotation and exactly wrong here). The multi-account e2e caught it on ~50%
of runs at the sign-out-keeps-the-other assertion; the same shape would
clobber a freshly ACTIVATED account with the previous account's rotation.

Persist now only lands on the token set the refresh rotated FROM. The
dropped successor costs nothing: sign-out already ended the AS session
(revoking its refresh family), and activation replaced the family in use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
permitteer docs/desktop-support.md P1. The console's "open as user" lands this tab on the app
with a one-time ?support_ticket; the AS binds the sign-in to the operator's support session and
mints tokens that live exactly as long as it — no refresh token, nothing to renew.

- Boot: the ticket is stashed and the URL scrubbed (a reload never replays a spent ticket); the
  tab's token store stays tab-scoped (the operator's own tabs keep their tokens); auth.init
  starts the authorize with `support_ticket` on it. Replaces `?support_session=1`, which the AS
  no longer sends.
- A support session is "signed in" by its one access token: stored (tab-scoped) so a reload
  survives, served until it expires, never refreshed, '' once gone — that expiry IS the end.
  Never filed as an account, never re-authorized by the grant heal (a plain authorize in this
  tab would sign the operator in as themselves).
- The end is the end: a 401 under a support session ends it (the status now rides into
  checkSession; a 403 is an ordinary refused write and changes nothing); the sign-in screen
  never auto-starts in a support tab and says "Support session ended" instead.
- The banner shows when the view ends, from the token's own expiry; the string is in four
  locales.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…owser

permitteer docs/browser-accounts.md P2. On a fresh portal the menu listed one account — its own
registry, filed per login — while the AS's session set already held the others; each appeared
only after a trip through the chooser. The AS cookie never reaches this origin, so the account
API now serves the set from this token's session (first-party apps only):

- The declaration adds `permitteer_account: accounts.read` (the grant heals once per build, no
  screen for this skip-consent client); the portal fetches /account/api/accounts with its
  account-audience token after each sign-in and when the menu opens.
- Members it holds no tokens for are filed as KNOWN — identity only — and the menu lists them
  beside the saved ones ("Signed in on this browser"). Picking one is a silent selection
  (prompt=none + login_hint), no chooser. A known account the AS no longer lists is dropped;
  saved accounts stay. A support session asks for nothing.
- A refused silent selection (signed out elsewhere meanwhile) restores the stored session and
  says why, instead of stranding a signed-in person on the sign-in screen.

Four locales for the one new string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The admin user page's "view as" deep-links the console with ?launch=remoteit_portal, and the
console fires the launch at once — landing on whichever web redirect URI the client lists first.
With app.dev and the app.evan lane both registered, that was list order deciding the lane. The
link now names this portal's own origin (&origin=…), which the mint validates against the
registration (permitteer docs/support-cookie.md): viewing-as from app.evan opens app.evan, from
app.dev opens app.dev.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n the tab cannot close

Only a script-opened window may close itself. After a step-up on the way into a support session
the console's own tab became the session (the popup had no click behind it), so the banner's X
did nothing. Now: close(); if the tab is still here, end this tab's support state (its tab-scoped
tokens and flag) and go back to the console's users page — where the operator came from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…then the portal as the user

The eye button opens ${issuer}/elevate/launch?user=<email>&client=remoteit_portal&origin=<this
portal> in a new window. The AS runs every launch gate on the operator's own session and either
opens the portal as the user straight away (elevated) or shows its "confirm it's you" page first
— one tap with a factor, or the first factor's set-up in three steps — and opens the portal from
there (permitteer docs/as-elevation.md). No admin console pane in between any more.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… accounts, view-as through the AS

A support session arrives by a one-time ticket at boot and ends with its token (no renewal);
the avatar menu knows the accounts signed in on this browser and switches silently; view-as
opens the AS's own launch lane on this portal's origin — the AS confirms it's you when needed
and opens the portal as the user from there (permitteer docs/desktop-support.md, docs/as-elevation.md).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3.47.2 and the dependency bumps behind it. The only conflict was package-lock.json: main's
lockfile taken and regenerated against the merged manifests (npm install; typecheck clean).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…st proof

The container now ENFORCES the binding the AS stamps (graphql-permitteer
docs/dpop-enforcement.md): a cnf-bound token must arrive as 'DPoP <token>'
with a proof over the exact method+URL, and Bearer presentation of a bound
token is refused. This app's tokens are bound (requireDpop), but every
graphql/REST call flowed through getToken() and went out as Bearer.

apiAuthHeaders(method, url) wraps the existing oidcAuthHeaders machinery
(already doing DPoP + ath proofs for the account API) with the switcher's
api resource; get/post/jobLogs and the two direct axios sites now build the
URL first and take scheme-aware headers. getToken() stays for liveness
probes and the events subscribe envelope — that wire is frozen and exempt on
the container by contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brings the permitteer-login arc to the AI portal build: DPoP-scheme graphql
calls (the container now enforces the binding — this is what un-breaks
app.ai's graphql lane), browser accounts + sub-aware token handover, the
support-session tab/banner, and the desktop-support lanes.

Unions where both sides had built on the same ground:
- oidc declared(): the agent's actor-marked MCP slice (this branch) rides
  beside accounts.read (theirs)
- oidcAccessToken: this branch's per-audience mint QUEUE kept (the docstring
  describes it), theirs' support-session short-circuit added on top
- completeFromUrl: theirs' sub-aware refresh-token handover with this
  branch's typed OidcError; the early persist+fingerprint dropped in favor
  of theirs' act/regular-branch persistence
- SignInApp: this branch's error mapping + auto-start budget AND theirs'
  support-tab gate/ending screen; supportTab joins the auto-start brakes
- get/post/jobLogs: DPoP headers minus getTestHeader (removed on this branch)
- viewAsBanner strings: theirs (adds the session-end time)

package-lock regenerated against the merged manifests; frontend typechecks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The avatar menu was rendering its local cache with the AS's reply as a soft overlay: the
reconcile in oidcRefreshBrowserAccounts pruned only identity-only entries, so an account
this app held tokens for stayed on the menu forever — even after the AS stopped listing it.
Reported from the field as a row that could not be switched to: its session had ended
elsewhere, the click swapped in tokens whose family died with it, the prompt=none recovery
found no live set member, and the person landed on the sign-in screen. The /accounts call
was a clean 200 the whole time, which is why nothing looked wrong.

- The reconcile is authoritative on a SUCCESSFUL refresh: an entry the AS does not list is
  dropped whether or not we hold its tokens. Guarded by `multi` — with multi-account off the
  AS answers "just you" by design, and pruning on that would sign out every saved account.
  The active account and tab-scoped support entries are held out.
- oidcRefreshBrowserAccounts returns its outcome instead of void, and a refused call now
  warns. It used to `return` silently, so a 401/403 left stale accounts on screen with
  nothing in the console — the same picture as a correct empty set.
- oidcAuthHeaders warns when it degrades a cnf-bound token to Bearer (no WebCrypto/IndexedDB,
  typically private browsing). The AS then refuses per RFC 9449, and that 401 arrived with no
  hint the cause was a missing key rather than a dead session.

No Electron special case: the desktop app runs its AS journey in the SYSTEM browser (the main
process bounces issuer-origin navigations out), so its session joins that browser's set and
browserSet is stamped normally — the same reconcile is correct there.

Pinned end to end in r3/e2e-tests multi-account.spec.ts: the offered rows must equal what the
AS reports, before and after a switch, and an account it no longer lists must not be offered
from cache. Verified against the real module across six states incl. a 401 and a 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the browser itself

The prune added in 17a3176 assumed the AS's account list was an authoritative statement about the
browser. It was not: the list came from a snapshot copied onto each session row, so it could be a
copy that predates the others — and a client had no way to tell that from a correct answer. The
failure mode is the worst available one: silently deleting an account the person is still signed
into, whose tokens still work.

The AS now says which it is (permitteer docs/browser-id-plan.md — the browser has an id, and
membership is looked up by it rather than read out of whichever row held the token). Saved accounts
are dropped only when `authoritative` AND `multi` are both true:

- not authoritative — the AS fell back to a snapshot, so the list is a copy, not the membership;
- multi off — the AS answers "just you" by design, not "everyone else is gone";
- an AS that says neither (an older deployment) never prunes saved entries, which is exactly the
  behaviour before that commit.

Identity-only entries keep pruning on any successful answer either way: they exist only because
some earlier answer named them, so nothing of the app's own is lost.

Verified against the real module across seven states, including the one this exists for: a 200 that
is NOT authoritative leaves a saved account alone while still dropping an identity-only stray.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uideBubble survives a persisted null created date

A stage switch that failed the account query wrote `{created: Invalid Date}` into the user model;
redux-persist serialised it as `created: null`, and every later boot threw in GuideBubble's
selector (`state.user.created.getTime()`) during the first render — before the next login could
overwrite it, and clearing localStorage did not help because the persisted store lives in
IndexedDB. Two fixes: parse() returns nothing when the payload carries no account, and stores
`created` only when it is a valid date; GuideBubble reads the date defensively (NaN for anything
that is not a Date), which the existing NaN guard already treats as "unknown". A browser holding
the bad value now boots, logs in, and is healed by the next successful account fetch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…fresh token

The refresh token rotates single-use and lives in localStorage, which every tab of the origin
shares; the single-flight guard next to it is module-scoped, so it stops a tab racing ITSELF and
nothing else. Two tabs therefore read the same token, both redeem it, and the AS — correctly — reads
a double-spend as theft: it revokes the family and mails the person "A sign-in was ended as a
precaution". Dev logged two reuse_detected events 4ms apart on one session and client (2026-09-04),
which is concurrency, not a stolen credential replayed later.

The AS is not what should change. Its own comment is right that racing must answer identically to
replaying, or racing becomes the way to evade detection. The defect is client-side.

So refresh() now holds navigator.locks('oidc.refresh') around the redeem, and the body moves to
refreshOnce() unchanged. That the body reads `stored()` at its top is what makes it correct rather
than merely serialised: the read now happens INSIDE the lock, so a tab that waited redeems the
successor the winner just wrote instead of the token it saw before waiting — which would be the
same double-spend. The waiter pays one extra rotation and nobody pays an alarm.

The wait is bounded at 10s because tokenRequest has no timeout: a hung fetch holds the lock, and
unbounded that would stall EVERY tab where today it stalls only the one. On timeout — or where Web
Locks is absent (older webviews, non-secure contexts) — it proceeds unlocked, which is exactly
today's behaviour, so the fallback is never worse than the status quo. The lock is global rather
than per-resource: one refresh token backs every audience, so two tabs refreshing for DIFFERENT
resources still redeem the same credential.

Proved by driving the real module as two module instances over one shared store, against a fake AS
that enforces the rotation rule. Without the lock: redeems=2, reuse_detected=1, neither tab ends up
with a token. With it: redeems=2, reuse_detected=0, both tabs served.

Does NOT address the ChatGPT Codex connector (183 events on robo+ent@remote.it in two days), which
is a separate client and the dominant source of these alarms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ord is mirrored to localStorage by state

The PKCE flow (verifier, state, nonce, redirect URI) lived only in per-tab sessionStorage. A
sign-in that STARTS in one tab and COMPLETES in another — what every email link does: signup's
set-password link, a password reset — arrived at the callback in a fresh tab with an empty
sessionStorage and a state the first tab minted, and the portal answered "Sign-in state mismatch
— try again." Every self-signup through the portal ended there.

The record now ALSO goes to localStorage keyed by its state (single-use, pruned after 24h — the
set-password link's own life), and the completion takes this tab's record first, then the shared
one. A support tab keeps its flow tab-scoped, like its tokens. No token ever rides the record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Nothing changes: wss://cloud.<stage>.remote.it/api/ws does not match EVENTS_RESOURCE, so the socket
already presents the graphql-resource token — which on that front is the RIGHT audience, because the
socket lives inside the merged /api resource rather than being its own.

The comment said the fallback existed for the legacy shared-domain URL and its dual-accept window.
That is still true and no longer the only reason, and a reader acting on the old comment would widen
the pattern to cover cloud.* — which would make the portal mint for a resource that does not exist
and fail invalid_target. That is precisely how the e2e suite found the same assumption on its own
side today, so the warning is written down rather than left to be rediscovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… destroyed

OAUTH_GRAPHQL_RESOURCE's default named https://graphql.dev.remote.it/graphql, and that host was
destroyed 2026-09-06 with the other three per-stage dev fronts (graphql-permitteer
docs/CLOUD-EDGE.md). app.dev's Amplify variable was repointed at cloud.dev.remote.it/api the same
day, so the deployed portal was fine — but the default is what every build WITHOUT that variable
gets, which is a local `npm start`, an Electron build, and any Amplify branch nobody updated.

The identifier is the TREE, not a graphql URL: /api covers graphql, the user REST surface and the
events socket behind one audience. It is deliberately NOT the socket's own identifier — see the
warning in cloudController.ts, which this does not change.

Timing matters here: https://graphql.dev.remote.it/graphql is being retired from the AS registry,
so a build still falling back to it stops getting a 404 from DNS and starts getting invalid_target
from the mint — a failure that reads like a broken client rather than a stale default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings the two commits this branch was missing since the 09-04 top-up: the cross-tab sign-in fix
(the PKCE flow record mirrored to localStorage by state, so an email link completed in another tab
finishes instead of "Sign-in state mismatch") and the unified-front note on cloudController's
EVENTS_RESOURCE — plus the OAUTH_GRAPHQL_RESOURCE default moving to https://cloud.dev.remote.it/api,
since graphql.dev.remote.it was destroyed 2026-09-06.

Three conflicts, and one of them was load-bearing.

.env.example — kept this branch's richer block (it carries the agent/MCP vars) and repointed the two
dead hosts in it: the graphql resource to the unified front, and VITE_OAUTH_MCP_RESOURCE from
mcp.dev.remote.it to cloud.dev.remote.it/mcp. Both hostnames stopped resolving on 09-06.

oidc.ts — took the incoming takeFlow(state) lookup with THIS branch's typed OidcError. takeFlow
already matches on state (session copy explicitly, shared copy by key), so the old flow.state check
moved inside it; the chat UI still needs 'expired' distinguishable from 'refused'.

constants.ts — the real one. This branch derives GRAPHQL_API and WEBSOCKET_URL from
OAUTH_GRAPHQL_RESOURCE, on the invariant that the audience IS the graphql URL. The unified front
breaks that invariant: the identifier is the TREE (/api) and graphql and the socket are paths inside
it. Taken naively the merge would have made GRAPHQL_API post queries at the tree root — and, worse,
the ws regex cannot match a tree, so its miss falls through to the unlabelled wss://ws.remote.it/v1,
which is PRODUCTION's socket. A dev build that merely stopped setting VITE_WEBSOCKET_URL would have
connected there silently. Both derivations now recognise the tree and append the leaf; the legacy
per-stage path is untouched, and all four shapes were checked by hand.

frontend typecheck clean.

NOT fixed here, because it predates this merge and affects app.dev equally: apiHelper's
getApiResource() mints for the switched URL on the assumption that the graphql URL is the resource
identifier, so Test Settings → API Target pointed at a unified front will ask for
cloud.<stage>.remote.it/api/graphql and get invalid_target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
healGrant re-authorizes when this build declares more than the standing grant carries, bounded to
ONE attempt per browser session so a client whose declaration outruns what the AS will grant cannot
cycle someone through the browser forever. That bound is right for the automatic path and wrong
everywhere else, and 2026-09-06 showed both ways it fails.

app.ai was repointed at a new MCP resource hours before its actor was registered to act toward it.
The boot heal fired into that window, was refused (invalid_authorization_details), and set the
marker. When the registry was fixed the client had no way to learn it: every reload hit the
loop-breaker, and the "Refresh permissions" button the chat shows in exactly that state routes to
the same guard — so the one control offered to the person did nothing, logged a console warning and
gave no feedback. Worse, the boot heal runs first, so that button is dead from the moment the
automatic attempt fails, which is the only moment it is ever shown.

Two changes, plus a hook for the third (in the chat model, which lives on the agent branch):

  * `force` skips the loop-breaker. A person clicking a button IS the loop-breaker — they stop
    clicking. The infinite-cycle hazard the bound exists for is a property of automatic retries.

  * the marker now records WHICH declaration was attempted rather than a bare '1', so a deploy that
    changes what this build asks for gets a fresh attempt instead of inheriting the old refusal.
    (Would not have helped this case — the AS moved, not the declaration — but it is the same class
    and one line.)

  * forgetGrantHealAttempt() lets a resource server's own "this grant does not cover me" clear the
    marker. That is server truth and strictly newer than the fingerprint guess. It deliberately only
    FORGETS: re-authorizing from a failed turn would redirect the person mid-conversation and lose
    what they were typing, when a working button and a clear message are already on screen.

oidcDeclaration() exports the fingerprint rather than recomputing it in the model, so the marker and
the staleness check can never disagree about what "the same request" means.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The grant-heal fix (force, fingerprint-keyed marker, forgetGrantHealAttempt). One conflict, the
auth.ts import line: this branch carries oidcClearAutoStarts / OidcError / OidcErrorCode that
permitteer-login does not, so the resolution is this branch's list plus oidcDeclaration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three call sites, closing the loop the auth model opened.

"Refresh permissions" now forces. That button is shown precisely when health is 'unauthorized', and
the boot heal has by then already spent the session's one automatic attempt — the failure that put
the button on screen is the same failure that disarmed it. Pressing it logged a console warning and
did nothing.

A streamed `reauth_required`, and a thrown AgentAuthError, both forget the recorded attempt. The
agent is reporting something the client could not know: the fingerprint it compares against is a
claim about what this BUILD asks for, and says nothing about whether the registry behind it moved.
On 2026-09-06 it moved twice in half an hour, and the marker written in between outlived its reason.

Forgetting, not re-authorizing. A failed turn is the worst moment to redirect someone to the AS —
they lose what they were typing, to fix a problem the button beside the message already fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
return
}
console.log('AUTH: grant predates this build’s declaration — re-authorizing')
window.sessionStorage.setItem(GRANT_HEAL_KEY, oidcDeclaration())
evanrbowers and others added 2 commits September 6, 2026 22:00
Test Settings offered one row, labelled with a host that no longer resolves, and lit none of them.
Three faults, all the same root: this lane was written when the graphql URL and the RFC 8707
resource were the same string, and the unified front separates them — the identifier is the TREE
(https://cloud.<stage>.remote.it/api) with graphql and the socket as PATHS inside it.

  * The row was MISSING. stagePairs matched only graphql.<stage>…/graphql and wss://ws.<stage>…/v1,
    so the tree identifier fell through the "not a switch target" branch with passport and the
    account APIs. The only row left was the legacy dev pair — a destroyed host presented as the
    option, which is what made the list look like it had lost its contents.

  * NOTHING was lit. The current selection compared the build's RESOURCE against each row's URL.
    Those matched for years; now the resource is …/api and no row's URL is. Compare on the URL the
    app actually calls (getApiURL), which is what a radio in this list means.

  * Selecting it would have 401'd. The mint asked for pair.graphql and then pair.ws — two audiences,
    right for a legacy stage, wrong for a tree where the socket has no identity of its own and
    asking for one answers invalid_target. A pair now carries its RESOURCES apart from its URLs:
    two on a legacy stage, one on the unified front.

Keyed by shape AND stage, never stage alone — a client allowed both, which every dev client is
mid-migration, would otherwise collide "legacy dev" and "cloud dev" into one row describing neither.

Also fixes apiHelper.getApiResource(), flagged three times today and the reason a switched target
would have failed even if the picker had offered it: it returned the switched URL verbatim as the
audience. resourceForApiURL() does the mapping once, derived rather than persisted so a hand-typed
custom URL resolves the same way a picked one does.

Verified against remoteit_portal's live allowlist: two rows, the unified front lit, the legacy row
minting two audiences and the cloud row one. The legacy row is correct to still be there — it is in
the client's allowlist — and disappears on its own when that identifier is retired, which is the
picker's whole contract: it shows what the AS will actually mint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rather than taken

TestPage has diverged: this branch's picker is the better one, and taking permitteer-login's would
have thrown away real work — mintCheck (which reads the AS's refusal reason, since oidcAccessToken
signals by returning '' and a try/catch never fires), targetsStatus (so a refused fetch cannot
masquerade as an empty allowlist), the single "Override default APIs" toggle owning graphql, events
AND the agent together, the radio lit only when the WHOLE pair still matches, and the Agent service
URL field. So the conflict was resolved by keeping this file wholesale and porting the three fixes
into it.

All three come from the same root: the lane was written when the graphql URL and the RFC 8707
resource were the same string, which the unified front separates.

  * stagePairs now recognises https://cloud.<stage>.remote.it/api. It matched only the legacy
    per-stage hosts, so the tree identifier fell through the "not a switch target" branch with
    passport and the account APIs, and the front this stage actually runs on had no row at all.

  * A pair carries its RESOURCES apart from its URLs, and selectStage mints those — two on a legacy
    stage, one on the unified front, where the socket has no identity of its own and asking for one
    is refused. It was minting (graphql, ws) unconditionally.

  * The hand-typed URL field mints resourceForApiURL(url), not the URL. Same reason.

Rows are keyed by shape AND stage. A client allowed both — every dev client, mid-migration — would
otherwise collide "legacy dev" and "cloud dev" into one row describing neither. They render as two
rows of the same stage told apart by their domain, which is what that column already existed to
say, and the legacy row leaves on its own when its identifier retires.

apiHelper came across clean, which also fixes getApiResource() returning a switched URL verbatim as
the audience — the reason a picked target would have failed even once the row existed.

Verified against remoteit_portal_ai's live allowlist: two rows, cloud.dev lit, the cloud row minting
one audience and the legacy row two, and cloud.dev.remote.it/mcp correctly absent (it is not a
graphql target). Frontend typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

4 participants