🚧 feat: AI agent chat - #1176
Draft
thelg4 wants to merge 213 commits into
Draft
Conversation
|
This pull request is automatically being deployed by Amplify Hosting (learn more). |
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).
…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>
… name from .env.example
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()) |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-agentservice. Always on in local dev builds; in deployed builds it soft-launches behind the hidden Test UI.Trying it on the preview build
https://dev-ai-agent.remote.ithttps://mcp.demo.remote.it/mcpWhat's in the branch
Known blockers for a real reply
mainis missing the tool-discipline prompt changes (stranded onfeature/chat-org-scope); a rebuild after that lands improves org-scoped behavior