Skip to content

Permitter Login and AI Agent Combined - #1218

Open
JamieRuderman wants to merge 237 commits into
mainfrom
feat/permitteer-login
Open

JamieRuderman wants to merge 237 commits into
mainfrom
feat/permitteer-login

Conversation

@JamieRuderman

Copy link
Copy Markdown
Member

Changes

thelg4 and others added 30 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).
Test Settings gains an 'Override agent service' toggle with agent URL
and MCP audience fields (persisted like the existing API overrides).
agentURL() and mcpAudience() resolve per request: the override wins
when enabled (https only — CSP blocks plain http), otherwise dev rides
the vite proxy and builds use VITE_AGENT_URL. Changing the audience
busts the OAuth client cache, so a fresh agent sign-in just works.
MODE === 'development' gates become useChatEnabled(): always on in
local dev, and on in deployed builds when the hidden Test UI is
enabled (shift+option on the avatar menu). Also documents the
dev-ai-agent/demo-audience pairing in Test Settings for staging
testers, whose build default is the beta audience.
The /hydra vite proxy only exists in dev; staging/preview builds 404'd
the agent sign-in's register and token calls. Hydra's public CORS
allows any origin, so builds can hit the issuer directly.
evanrbowers and others added 21 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>
…hed-away rotation keeps its successor

Two refresh-rotation edges the AS trail showed on dev (auth.token.replayed):

1. The AS refuses a replay of a spent refresh token whose successor is ALSO spent with
   "…this copy is stale and the session was not ended" — the family rotated on without
   this tab (a response lost to a navigation, a second tab) and this store holds nothing
   newer, but the AS session is alive. We treated it as a dead grant and signed the person
   out. Now: one silent round through the AS, prompt=none + login_hint naming THIS account
   (the lane a just-activated account already uses in models/auth init), so a multi-account
   browser gets the same person back rather than whichever member the AS has active.
   One-shot per account per minute (sessionStorage — rides the same-tab round trip, dies
   with the tab): a refused silent round is a sign-out, never a loop.

2. A rotation that completed after a sign-out or account switch had moved the store dropped
   its successor — leaving the account's registry entry holding the SPENT token, so the next
   activation replayed it (dev: the app.ai replays a minute after a switch). The successor
   is re-filed onto that entry — update only, never insert, so a signed-out account stays gone.

Typecheck clean (frontend + common). Pairs with permitteer review/2026-09 cluster 7, where
the AS keeps the replay grace for DPoP-bound families (this client) and makes a public
client's bearer family strict.

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

# Conflicts:
#	frontend/src/components/OrganizationSelect.tsx
#	frontend/src/pages/DevicesPage.tsx
The NEXT portal is this branch built for production's AS and container (Amplify branch `next`:
VITE_OAUTH_ISSUER=https://login.remote.it, cloud.remote.it for the API — app.dev's six
branch-level variables with prod values). Amplify deploys a branch once, so `next` is a git
branch that mirrors this one; this workflow pushes every commit through and Amplify's webhook
builds it. Nobody commits to `next`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed front (#1216)

- signOut is LOCAL to this app (drops local session; leaves the AS session to the user); signIn always authorizes with prompt=select_account, so sign-out + reload lands on the AS chooser instead of a silent SSO. The dead in-memory promptLogin guard is removed.
- globalSignOut ("Sign Out Everywhere") ends the AS session BEFORE local teardown (single-session RP-logout; all-device /logout/all remains Phase 2b — tracked on the PR).
- Default API endpoint moves to the unified front: GRAPHQL_API -> cloud.remote.it/api/graphql, OAUTH_GRAPHQL_RESOURCE -> cloud.remote.it/api (local dev overrides via .env.local).
- Frontend test harness (vitest + jsdom) with sign-out effect tests; `npm test` now runs the electron + frontend suites.

Verified: frontend (3) + electron (44) suites green, typecheck clean, e2e test:dev 80 passed / 1 skipped against the dev deploy.
…keep permitteer-login's API Target picker

Brings the sign-out-local work and prod unified-front defaults into the agent branch, and
reconciles the diverged Test-UI API picker back to permitteer-login (the screenshot layout:
a radio list from permitteer's bindable-resources + Custom GraphQL/WebSocket URL fields, no
override toggle). The agent feature (chat, agent/MCP token mint) is kept and runs on
VITE_AGENT_URL (Amplify=dev) / the prod default.

- constants.ts: PROD defaults (cloud.remote.it, agent.remote.it, cloud.remote.it/mcp); kept
  ai-agent's agent constants + the GRAPHQL_API-from-resource derivation; restored TEST_HEADER.
- oidc.ts: permitteer-login's sign-out (promptLogin guard dropped) + stale-copy recovery;
  kept ai-agent's agent/MCP mint logic + mintErrors.
- TestPage.tsx / apiHelper.ts / permitteerAccount.ts: permitteer-login's picker verbatim.
- ui.ts: kept switchAgent/agentURL (read by services/agent).
- Dropped ai-agent's Test-UI agent-override control (the unified switchAgent toggle).

Verified: frontend typecheck clean; frontend 3/3 + electron 44/44 tests; vite build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-adds a way to repoint the AI chat at a deployed agent from Test Settings, in
permitteer-login's style — a standalone advanced field, no switchAgent override toggle.
agentURL() now takes the override whenever it is a valid https URL (the switchAgent gate
is gone); ui.ts drops the now-unused switchAgent flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- package-lock.json: restore the frontend test deps (vitest, jsdom). The merge committed the
  pre-merge lock before `npm install` reconciled it, so `npm ci` (the typecheck CI) would fail
  on the lock/manifest mismatch. (Codex P1)
- TestPage Features: list features via selectFeatures (API limits + soft-launching
  PENDING_FEATURES, incl. `ai-agent`) with the pending sub-label, toggling the effective lookup —
  restoring what taking permitteer-login's TestPage wholesale had dropped, so the chat is
  toggleable before licensing returns the limit. (Codex P2)
- TestPage "Agent service URL": Reset now CLEARS the override (persist '') instead of pinning
  OAUTH_AGENT_RESOURCE, so agentURL() falls back to the /agent proxy (dev) / VITE_AGENT_URL
  (build) rather than turning the OAuth audience into the transport. (Codex P2)

Frontend + electron suites green; typecheck + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Covers the change to the agent service URL resolution: agentURL() honors a valid https
override and otherwise falls back to the built-in (proxy/VITE_AGENT_URL); isSecureAgentURL
accepts https only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The remaining Codex findings, root-caused where they clustered.

Chat popout persistence (store.ts):
- Popout is a 2nd app instance on the shared 'app' key; whitelist:[] still wrote _persist and
  clobbered the main window. It now uses a no-op storage adapter (adopts via BroadcastChannel,
  persists nothing). (P1)
- Persist chat ownerId so the identity guard preserves same-user chats across reload. (P2)

Chat turn lifecycle (chat.ts, ChatHeader):
- clearConversation (a reducer) can't abort the in-flight streamChat, so New Chat / identity
  change / deleting the open conversation orphaned a running turn. New effect newConversation
  (stop + clear) now backs all three. (P1)

Chat popout handoff (ChatPanel, useChatSync):
- Disable Pop out while streaming or an approval is pending (the handoff can't carry/resume it,
  and popping out stop()s the source, stranding the server turn). (P1)
- syncTranscript on popout close/loss so the server's journaled remainder replaces the partial
  handback. (P2)

Agent resilience (oidc.ts, agent.ts, chat.ts):
- Bound the MCP protected-resource lookup (AbortSignal.timeout) so a slow endpoint can't block
  sign-in/switch/heal; the cached/fallback name stands. (P1)
- fetchConversation returns null only on 404; other errors throw and openConversation preserves
  the transcript + reports instead of clearing as "vanished". (P2)

Devices (DevicesPage):
- Default-account-selection effect triggers on the inputs it reads (empty-list, default account)
  with a per-account guard, so late memberships / a switch to a cached empty account re-decide. (P2)

i18n (locales, typecheck.yml):
- Extract the untracked chat.*/signIn.* + backlog keys into the catalogs, and make CI run
  extraction + clean-diff before the parity check so a source key missing from every catalog
  can't pass unnoticed. (P2) ja/de/es values are placeholders for translation.

Frontend + electron suites green; typecheck, i18n:check, vite build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Sign-out background revocation (P1): auth.signedOut now AWAITS chat.signOut, which awaits a
  BOUNDED backgroundDisable — the unawaited revoke raced oidcClearLocal(), minted no token, and
  left background AI access alive after sign-out.
- Test header: restore getTestHeader() in get/post/jobLogs/cloudController (WS) — the Test
  Settings "Add query header" control was visible but never sent.
- listConversations: throw on non-OK (was returning [] and overwriting cached history on a
  transient 401/503); loadConversations' catch keeps the last-known list.
- removeConversation: honor a failed DELETE — keep the conversation + report instead of clearing
  it as if it had been deleted.
- syncTranscript: adopt the server copy when it DIFFERS (last-message text), not only when it is
  longer — so a same-length completed reply replaces the popout's partial — and apply the title.
- store.ts: persist chat `title` too, so a reload restores the header instead of "New chat".
- DevicesPage: key the empty-list guard by the ACTIVE account, not defaultAccountId (undefined on
  a membership-less personal account never redirected to /add).

Frontend + electron suites green; typecheck, i18n:check, vite build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…transcript races

Four round-3 findings on 58cf220, plus the rest of the class two of them belong to.

P1 App.tsx — enforce the chat entitlement in popout mode. ?chatPopout is user-controlled:
an authenticated user without the ai-agent license could land on /?chatPopout=x and mount
ChatWindow unconditionally, whose sync hook fires agent health/conversation/usage requests
and exposes the composer — bypassing the gate the dock and header obey. ChatWindow now mounts
only behind chatEnabled. The not-yet-enabled state is deliberately NOT the else-branch (that
flashes the full app into the popup, the original comment's concern): the window waits on a
spinner until organization.initialized — which flips exactly when the license limits are
parsed — and once resolved-and-absent shows "Remote.It AI is not available for this account"
(new chat.notLicensed key; chat.unavailable is a transient-outage message and would imply
the feature is coming back). Both web (appReady via signedIn) and Electron (own Controller
socket → backendAuthenticated → appReady) popouts run cloudSync.all → organization.fetch,
so the gate resolves for entitled users in either shell.

P2 auth.ts — preserve local-backend sign-in failures across sign-out. signedOut()
deliberately clears signInFailed/signInError, and SignInApp renders its message ONLY while
signInFailed is true — so backendSignInError's set-then-signedOut left Electron users on a
bare sign-in screen with no word of the rejection. Fixed the CLASS, not the instance: every
writer now records through signInFailure (signInFailed=true) and on the far side of the
teardown —
  • backendSignInError: teardown first, then signInFailure.
  • disconnect: same defect (bare signInError after signedOut, and it read the STALE
    invocation snapshot). It fires right behind backendSignInError when the rejected socket
    drops, so it now reads the LIVE store and carries an already-recorded failure through the
    teardown instead of wiping it, falling back to the generic message otherwise.
  • signInError effect: dead (no callers) but carried the same defect; routed through
    signInFailure so it is correct if ever wired.
This makes signInFailure's own doc comment ("every sign-in failure lands here") true again.

P2 chat.ts — syncTranscript discards responses for conversations no longer active. A slow
fetch outlived by a New Chat or history pick was applied against the invocation-time
snapshot, landing the OLD transcript in the new conversation under its newer conversationId
(or repopulating one just cleared). Re-reads the live store after the await, drops the
response once the id moved on or a turn started, and compares against what is actually
current — the same generation check logs.ts keys on requestId.

P2 chatPopout.ts — distinct popout window name per owning tab. A constant WINDOW_NAME let a
second main tab's window.open reuse and navigate the first tab's popup, orphaning the first
tab's handle (dock never restored, ID ping never sent). Name is now `${WINDOW_NAME}-${id}`.

Also repairs a CI i18n drift: 58cf220 introduced notices:chat.deleteFailed but its
extraction was never committed, so the typecheck workflow's `i18n:extract && git diff
--exit-code` step would have failed on the current head. Both new keys are extracted into
all four locales.

Tests: 7 new behavioral tests (4 auth, 3 chat) pinning every changed path — all 7 fail
against the pre-fix models (verified by stashing auth.ts/chat.ts) and pass with the fixes.
Frontend 14/14, Electron 44/44, typecheck, i18n:check, extract-idempotency and the vite
production build all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… stop, chat request races

Five findings on 418639c. One is a regression from round 3's own popout gate; three are the
remaining instances of the stale-snapshot class that round closed for syncTranscript.

P2 App.tsx — gate popouts using the handed-off account. Round 3's entitlement gate reads
useChatEnabled → selectOrganization → accounts.activeId, which the popout's no-op persistence
leaves unset — so it evaluated the PERSONAL account's license, and a chat licensed only for an
organization would be refused in its own popout (every Pop out stuck on "not licensed"). The
handoff does carry orgId, but only after ChatWindow mounts — behind that very gate. Fixed by
bootstrapping the owning account scope through the popout URL (Codex's first suggestion):
  • chatPopout.ts: openChatPopout(scope) adds &chatPopoutScope=<id>; popoutScopeId exposes it
    at boot. Frontend-only param (Electron's window-open handler keys on CHAT_POPOUT_PARAM).
  • chat.ts popOut: hands over state.chat.orgId.
  • useChatSync.ts useChatPopoutScope(): sets accounts.activeId from it on mount; App calls it
    UNCONDITIONALLY, outside the gate. No-op in the main window.
Precise and safe: the gate now evaluates the org the popout is actually for, and a hand-edited
scope the user is no member of is cleared again by accounts.parse's stale check — landing on
the personal account the gate would have read anyway. Rejected the alternative ("entitled on
ANY account" selector): it would re-implement PENDING_FEATURES/override handling that
useChatEnabled's own comment says nothing may bypass.

P1 App.tsx:194 — stop active turns when entitlement removes the panel. ChatPanel unmounts
ONLY when chatEnabled drops (closing it renders null but stays mounted; popping out stops
explicitly). A turn left streaming behind that ran headless: the remount's resetTransient()
cleared streaming/pendingConfirmation while the old request was live, so the next send
orphaned its AbortController and two turns' events interleaved, and a pending write approval
was stranded with no card to answer it. useChatMainSync's mount effect now stops the turn in
its cleanup — which, given the above, fires on exactly the entitlement-loss path. Same
cleanup on useChatPopoutSync (its only unmount is the same gate; window close runs no React
cleanup) so a turn never outlives its surface.

P2 chat.ts openConversation — discard out-of-order loads. Pick A then B, A lands last → A
replaced B. A module-level selection ticket (the requestId pattern logs.ts uses) is taken per
pick and by newConversation; a load applies only while it holds the latest AND no turn has
started meanwhile (the composer stays enabled). A superseded failure is dropped as noise; a
superseded 404 still refreshes the list but no longer clears the conversation now on screen.

P2 chat.ts removeConversation — handle transport failures. deleteConversation returns
response.ok but fetch/agentHeaders can REJECT (network, DNS, CORS): the effect then rejected
unhandled after the confirm dialog had closed, and the user heard nothing. Caught and routed
through the same deleteFailed path.

P2 chat.ts removeConversation — recheck the active conversation after deletion. Re-reads the
LIVE id: a slow delete of open A followed by opening B no longer clears B; deleting A from the
picker then opening A before it lands now does clear the deleted transcript.

Audited every chat effect for the stale-snapshot pattern (read `state.chat` after an await):
openConversation and removeConversation were the last two — `confirm` is a false positive
(its read sits inside the confirmTool argument, evaluated before suspension). Class closed.

Tests: 9 new behavioral tests (8 chat model, 1 chatPopout service — the latter also pins
round 3's per-owner window name) plus 4 controls; all 9 fail against the pre-fix
chat.ts/chatPopout.ts (verified by stashing) and pass with the fixes. The unmount-stop hook
wiring is two lines calling existing stop() and is covered by typecheck/build — RTL is not a
dependency here and was not worth adding for it. Frontend 27/27, Electron 44/44, typecheck,
i18n:check, extract-idempotency and the vite production build all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ks, SSE line endings

Four findings on aa724b8. Three fixed; the fourth is pre-existing base-branch behaviour
that the code's own comment misdescribed, so the comment is corrected and the behaviour is
deliberately kept (see below).

P1 chat.ts popIn — preserve pending approvals when popping back in. popIn() stop()s the
window before handing back, and the handoff carries neither turnId nor the pending approval,
so mid-turn it aborted the stream and stranded a confirmation_required turn on the server
with no window left able to answer it. This is the exact mirror of round 1's Pop OUT gate,
and takes the same product decision: ChatWindow's Pop back in is disabled while a turn is
streaming or an approval is pending (turnActive), rather than growing the handoff protocol
to carry a resumable turn.

P2 chat.ts openConversation — invalidate loads when a send starts. Round 4's superseded()
checked the streaming flag, which is a snapshot: a turn that starts AND finishes before a
slow history pick lands leaves streaming false again, and the stale load replaced the
completed turn. send() now takes a selection ticket — a send commits the user to the
conversation on screen, so any pick still in flight is no longer wanted.

P2 agent.ts — parse standard SSE line endings. The parser recognised only '\n\n'; a
CRLF server would never produce it and every turn would finish silently empty. Line endings
are normalised to LF before the blank-line search (a trailing CR is held back — it may be
half of a CRLF torn across reads), and the parser is split into deliver()/drain() so EOF can
flush an event the server closed on without a trailing blank line. EventSource discards such
a tail because it cannot know whether it is complete; our payloads are JSON, so a successful
parse IS that check, and a torn tail is dropped rather than surfaced as a parse error over a
turn the user already watched finish. Hardening — the agent emits LF today — but cheap and
spec-correct.

P2 DevicesPage — "arm redirects for already-initialized account models": comment corrected,
behaviour intentionally unchanged. The initLoad latch is on the base branch verbatim, and its
suppression is by design: `devices` is persisted, so a page mounting already-initialized from
storage must not bounce a reload to /add on STALE emptiness, and (with #1209's default-org
selection) a membership landing mid-session must not yank the user to that org. Round 1's
dep expansion serves the fresh-sign-in path — where the latch IS armed and memberships arrive
after the list. The old comment claimed the deps also handled "switching to an already-loaded
empty account", which the latch correctly prevents (and which base never did either); it now
states the real contract. Arming on reload would be a UX change (zero-device users bounced to
/add on every reload) that belongs to a product decision, not a review fix.

Tests: 4 new behavior-changing cases (3 SSE framing in agent.test.ts, 1 send-invalidation in
chat.test.ts) plus 2 controls; all 4 fail against the pre-fix agent.ts/chat.ts (verified by
stashing) and pass with the fixes. Frontend 33/33, Electron 44/44, typecheck, i18n:check,
extract-idempotency and the vite production build all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, deny-on-abandon, stream cut-offs

Seven findings on 843b9b5. Two are primary-path defects — one a privacy issue, one a
regression this branch's own reconciliation introduced — the rest robustness.

P1 useChatSync.ts — compare chat ownership with the AUTHENTICATED user. The identity guard
read the persisted `user` model, which is restored from storage and only catches up when the
cloud sync lands; auth.user is fetched for the CURRENT tokens at sign-in. Activating a saved
account swaps tokens and reloads WITHOUT purging persisted models, so for that interval
(indefinitely, if the sync stalled) syncIdentity() accepted the previous owner's transcript and
the newly activated user saw another account's chat. Both sync hooks now read auth.user.id via
one useChatIdentity() ('' when signed out, which syncIdentity treats as a no-op). Same id space
as before, so existing persisted ownerIds still match.

P2 agent.ts — expose the background-work enrollment control. A REGRESSION from the merge
reconciliation (53746ec): the pre-merge agent TestPage carried an "AI background work" toggle
(enroll ceremony via backgroundConnectUrl, backgroundStatus polling, backgroundDisable); taking
permitteer-login's TestPage and re-adding only the Agent URL field dropped it, leaving
backgroundConnectUrl/backgroundStatus with no caller and the workflow impossible to enable.
Restored in the AI Agent section, in permitteer-login's ListItemSetting style; the extraction
re-adding its four testPage.backgroundWork* keys confirms round 1 had removed them.

P1 ChatHeader.tsx — New Chat stranded a pending approval: newConversation() → stop() cleared
the card without a decision, leaving the server-side turn waiting on a card no window showed.
Stop has the identical strand (the stream stays open while an approval is pending, so the Stop
button is shown). Fixed at the ONE place every abandonment path runs through: stop() now sends
an explicit DENY for a pending tool (best-effort, not awaited) before clearing — the safe
answer for a write the user never approved, and the one that lets the server resolve. Covers
Stop, New Chat, deleting the open conversation, an identity change and the unmount paths. Pop
out / Pop back in stay GATED rather than routed here: a handoff means to continue the turn.

P2 agent.ts — reject SSE EOF before a terminal event. A clean close after partial text but
before done/error (a proxy idle timeout on a long turn, say) resolved streamChat() normally;
the model's finally then marked the turn idle and a truncated answer looked complete with the
composer open. streamChat tracks whether a terminal event arrived and throws
AgentStreamEndedError otherwise (a Stop never lands there — aborting rejects reader.read()
with an AbortError); send() maps it to an error event (new notices:chat.streamEnded), which
marks the reply Interrupted and ends the turn.

P2 constants.ts — derive the socket fallback from the EFFECTIVE GraphQL URL. This fallback is
agent-branch code (base had none): it branched on the tree parsed from the OAuth RESOURCE, so
a legacy-stage VITE_GRAPHQL_API beside a cloud resource paired the API with the cloud tree's
socket, splitting API and event traffic across stages. Both shapes are now read off
GRAPHQL_API itself; the default case is unchanged.

P2 oidc.ts — AbortSignal.timeout feature-detected for the PRM fetch (missing on older mobile
WebViews; the refresh-lock code already detects it). The bound is not optional here — dropping
it would let a half-open MCP endpoint block sign-in — so where the static is missing the same
signal is built from an AbortController, rather than throwing and skipping the very lookup
that discovers a renamed detail type.

P2 chatPopout.ts — refuse the popout where BroadcastChannel is missing. Without the channel a
popout opened, never said hello, was never adopted, and neither window could hand back.
chatPopoutSupported hides the Pop out control; openChatPopout() returns false as the backstop.

Tests: 7 new behavior-changing cases (2 SSE cut-off, 2 WS pairing via vi.stubEnv +
re-import, 1 no-channel via stubbed BroadcastChannel + re-import, 1 stop()-denies, 1 send()
cut-off mapping) plus 4 controls; all 7 fail against the pre-fix sources (verified by
stashing) and pass with the fixes. The identity-guard selector swap and the restored TestPage
control are hook/component wiring covered by typecheck and the build (no RTL in this repo).
Frontend 44/44, Electron 44/44, typecheck, i18n:check, extract-idempotency and the vite
production build all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ad; boot heal waits for MCP metadata

Four findings on f574f2c. Three are the event-invalidation sibling of the stale-snapshot class
closed in round 4 — a load in flight not invalidated by a later EVENT (a send, a sign-out, a
newer probe) — which had produced a finding every round since. Closed as a class this time.

P1 chat.ts — invalidate pending loads on sign-out. chat.signOut aborted only the STREAM; a slow
history pick started under one account passed its own guard after the reset (its ticket
unchanged, nothing streaming) and wrote that account's transcript into the store the NEXT
account boots from — persisted, and on the next account's screen if it landed late (same family
as round 6's identity finding). Sign-out now advances the generation.

P2 chat.ts — syncTranscript invalidated when a turn starts. A sync outliving a turn that started
AND finished meanwhile saw the same id and streaming false again, applied the older server
snapshot, and removed the just-completed turn from view. A sync now reads the generation (without
advancing it — a background reconcile must not out-rank a pick in flight) and drops its result
if anything advanced it meanwhile.

P2 chat.ts — stale agent health responses. Overlapping probes (a slow one started while
connectivity failed, then the reconnect-triggered one) let the older land last and flip a fresh
`ok` back to `unreachable`, disabling the composer with the agent reachable. Latest-wins ticket.

The design, in one place: `generation` advances on every event that makes an in-flight load
unwanted — a pick (which takes the new ticket), New Chat, send, sign-out — and every effect that
writes fetched conversation content (openConversation, syncTranscript) applies only while its
ticket is current; the `streaming` flag alone was never enough, since a turn can start and
finish inside a fetch. Probes that are not conversation-scoped (health, history list, usage
meter) get their own latest-wins tickets rather than cross-invalidation. `selection` is renamed
`generation` to say what it now is. Audit: every await-then-write effect in chat.ts is guarded.

P2 oidc.ts — await MCP metadata before the boot grant-freshness check. The boot
refreshMcpDetailType() was fire-and-forget while healGrant() called the synchronous
oidcGrantStale(): on the first load after a detail-type rename the cached name was still the
OLD one, the fingerprint matched, the grant was called current, and the discovery that followed
updated only the cache — nothing re-ran the heal, so agent authorization stayed broken until a
reload. The boot refresh is now captured as a promise (oidcMcpDetailReady; bounded by the
fetch timeout, never rejects) and healGrant awaits it before the check — at most one bound,
once, and instant thereafter. No extra fetch.

Tests: 5 new behavior-changing cases (sign-out invalidates a pick, send invalidates a sync,
latest-wins health and list, healGrant ordering); all 5 fail against the pre-fix
chat.ts/auth.ts (verified by stashing) and pass with the fixes. Frontend 49/49, Electron 44/44,
typecheck, i18n:check, extract-idempotency and the vite production build all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s, the agent lane (#1217)

Lands the AI-agent chat surface on the permitteer-login line: the docked/popout chat, the
conversation history and usage meter, the agent OAuth lane (tokens addressed to the agent
service, the MCP detail delegated onward to the agent actor), Connected Apps, the ai-agent
license gate (PENDING_FEATURES / CHAT_ALWAYS_ON for the AI portal), and Test Settings' AI
Agent section. The API Target picker is permitteer-login's, unchanged.

Eight Codex review rounds (~40 findings) are in the branch history; the notable ones were two
cross-account transcript exposures (a stale persisted user model on saved-account switch; a
slow history pick surviving sign-out into the next account's store), the entitlement gate on
the user-controlled popout URL, deny-on-abandon for pending write approvals, and one
generation guard for every chat load. Codex clean on 5fd120b; full suite (Electron 44/44,
frontend 49/49) and e2e test:dev (80/80) green against a local build of that commit.

Known follow-up (Evan's): the agent's CORS allowlist covers app.ai.remote.it only, so
ai-agent-licensed accounts on app.dev.remote.it will see "temporarily unavailable" until the
allowlist (or an Amplify /agent rewrite) covers that origin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread frontend/src/models/auth.ts Dismissed
@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-1218.d20k671nqqv4kl.amplifyapp.com

evanrbowers and others added 5 commits September 14, 2026 13:28
…at" (#1219)

IconButton wrapped its Tooltip around a <span> around the real button, so MUI's aria-label landed on the
span and every icon-only button in the app was nameless to assistive tech (and to getByRole). The button
now carries aria-label from a string title, with a `label` prop for the cases a title cannot name: a
React-node title (ServiceKeySetting), a title that swaps in a disabled-state explanation (RegisterMenu,
ProductsActionBar), and copy controls whose title flashes "Copied!" (CopyIconButton, ScriptEditPage).
The chat panel's close is titled "Close chat" (chat.closeChat) — the name the e2e suite uses to dismiss a
docked chat on a dev build (e2e-tests#17).

Codex clean on b02f220; typecheck, frontend 49/49, i18n, vite build green; e2e auth + multi-account pass
against a local build and against app.dev.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…is the chat's only switch (#1220)

The desktop half of the ai-agent licence (graphql-api docs/AI-AGENT-LICENSE.md):

* Admin → Add-ons (/admin/add-ons/:productId): a system-admin page that lists an add-on's holders
  and grants / revokes it by email, with an optional expiration. Generic over add-on products —
  ai-agent is the first. The model carries load statuses and one `refresh` entry point; every
  request takes a latest-wins ticket, and the list's identity is (product, API target).
* The licence card says what it grants ("AI agent is available"; "Expires", not "Renews", for a
  licence billing does not own) and wears the remote-ai mark.
* The licence is the chat's ONLY switch: PENDING_FEATURES / CHAT_ALWAYS_ON / VITE_CHAT_ALWAYS_ON
  retired; a dev build and app.ai.remote.it no longer default it on. The Test page's AI Agent
  section is behind the same gate (a standing background grant is ended from Connected Apps).

Codex: 8 rounds, clean on 3b18042. Desktop suite, typecheck, i18n green; e2e test:dev against a
local build of this head: 80 passed, 1 skipped (admin key), 0 failed.

Plan note: docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Security page's button now makes the one call permitteer#19 added — POST {issuer}/account/api/devices/sign-out-all: every session of the account, this one included, refresh families swept, resource servers told, and on a bridged stage the legacy pool's tokens revoked — then the local teardown. devices.write joins the permitteer_account declaration (the heal path re-authorizes silently). The background grant is revoked once per identity, before the call; the call is bounded at 10 s; a support session skips it (control hidden). The silent /session/end lane goes. Codex: 2 rounds. 78/78; e2e test:dev vs localhost 81/81 with e2e-tests#20's new spec.
@JamieRuderman

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 83b91bc56b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +261 to +262
const redirectUri = () =>
browser.isElectron ? PROTOCOL + 'authCallback' : window.location.origin + '/authCallback'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the custom-scheme callback for native mobile

In a Capacitor iOS or Android build, browser.isElectron is false, so authorization now requests the local WebView origin (typically capacitor://localhost or https://localhost) instead of the app callback. The native callback wiring in useCapacitor.ts only accepts PROTOCOL URLs and specifically handles remoteit://authCallback, so the registered desktop/mobile client cannot return through this new URI and native users cannot complete sign-in. Treat native mobile like Electron here, or add a separately registered mobile redirect and matching callback handling.

Useful? React with 👍 / 👎.

Comment on lines +97 to +101
const submitCode = async () => {
const current = step as Extract<Step, { at: 'relay' }>
setBusy(true)
const r = await selfChallenge(current.challenge, current.isSelect ? { choice: code as MfaMethod } : { code })
setBusy(false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Render the returned MFA choices instead of accepting free text

When passkey registration or removal receives a status: 'select' continuation, the API supplies the allowed factors in current.options, but this path submits arbitrary text from the code field by casting it to MfaMethod. Users must guess the protocol values (totp or sms), and a typo or human-readable method name is rejected and restarts the password flow. Render a radio/select control from the returned options, as MFASettings already does, and submit the selected enum.

Useful? React with 👍 / 👎.

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