Skip to content

fix(auth): stop accumulating authorized-client rows per app relaunch - #7978

Draft
ImBIOS wants to merge 3 commits into
pingdotgg:mainfrom
ImBIOS:fix-auth-stop-accumulating-authorized-client-sessi
Draft

fix(auth): stop accumulating authorized-client rows per app relaunch#7978
ImBIOS wants to merge 3 commits into
pingdotgg:mainfrom
ImBIOS:fix-auth-stop-accumulating-authorized-client-sessi

Conversation

@ImBIOS

@ImBIOS ImBIOS commented Aug 23, 2026

Copy link
Copy Markdown

Fixes #7977

Every bearer bootstrap exchange minted a brand-new auth_sessions row (30-day TTL), so Settings → Authorized clients fills with duplicate "T3 Code Desktop" entries per relaunch.

This patch adds optional client_instance_id to /oauth/token and reuses the existing compatible session (same subject+method+instance_id) instead of inserting a new row. Also prunes expired/revoked rows.

Managed via fh as fix-auth-stop-accumulating-authorized-client-sessi-p5zy3k6c in ImBIOS/.forkhub.

Verify:

  • pnpm exec vp test run apps/server/src/auth/SessionStore.test.ts
  • pnpm exec vp test run apps/server/src/server.test.ts -t "collapses repeated token exchanges"

Closes #7977

Note

Stop accumulating authorized-client rows per app relaunch via client instanceId

  • All clients (desktop, mobile, web) now generate and persist a stable client_instance_id and send it during the DPoP bearer token exchange. The server stores it on auth_sessions (migration 41 adds the column) and threads it through AuthClientMetadata and VerifiedSession.
  • SessionStore.make.issue reuses an existing active session for the same subject/method/instanceId when its scopes cover the requested scopes — extending expiration and issuing a new token for the same sessionId. When requested scopes widen, stale instance sessions are revoked and a fresh session is issued. prune(now) runs after each creation to delete expired or revoked sessions.
  • A new shared stripTerminalEscapes/sanitizeTerminalValue module is applied to opencode CLI output parsers (parseModelsCliOutput, parseAgentListCliOutput, parseSkillsCliOutput, version probe) and model-selection values so agent/variant/model ids no longer include OSC or ANSI escape sequences.
  • Behavioral Change: token exchange now accepts and persists client_instance_id; existing persisted sessions lack the column (nullable, safe). Desktop persists the id under environment.stateDir; if the write fails it logs a warning and proceeds with an in-memory id.
📊 Macroscope summarized d8458d1. 24 files reviewed, 11 issues evaluated, 3 issues filtered, 8 comments posted

🗂️ Filtered Issues

apps/web/src/connection/platform.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 136: clientInstanceId accesses globalThis.localStorage without exception handling. The localStorage getter can throw SecurityError when browser policy blocks persistence (for example when cookies/storage are disabled), and setItem can also fail; because this now runs while capabilitiesLayer is constructed, such users lose the entire connection platform instead of merely operating without a persisted instance ID. Optional chaining does not catch an exception thrown by the property getter. [ Cross-file consolidated ]
docs/internals/environment-auth.md — 0 comments posted, 2 evaluated, 2 filtered
  • line 56: The paragraph still classifies all listed extension parameters as “presentation hints only,” but the newly added client_instance_id is operational input: SessionStore.issue uses it to select a session for reuse and to revoke prior sessions when scopes are incompatible. Clients relying on this contract may treat the value as disposable UI metadata even though changing or colliding it alters authentication-session lifecycle. [ Out of scope (post-validation triage) ]
  • line 68: The statement that issuance prunes expired or revoked sessions is inaccurate for compatible client_instance_id exchanges. SessionStore.issue returns immediately after extending and re-signing a reusable session, while authSessions.prune({ now }) is only called on the new-session path. Thus repeated compatible relaunch exchanges can continue indefinitely without performing the documented pruning. [ Out of scope ]

ImBIOS added 3 commits August 21, 2026 11:05
…tored agent selections

opencode <=1.18 writes ESC ]0;<cwd>: ready BEL to stdout for every
non-help command even when stdout is a pipe (agent list, models
--verbose, debug skill). T3's ChildProcessSpawner captures that stdout
via collectStreamAsString and the parsers stored a polluted agent id
like "\x1b]0;imbios: ready\x07build" in model_selection_json.

Later sendTurn used that polluted id and opencode rejected it with
"Agent not found: \"\x1b]0;imbios: ready\x07build\"" which was
surfaced as session.error UnknownError + a generic SessionPrompt
UnknownError wrapper (the stack the user pasted).

Fix:
- packages/shared/src/stripTerminalEscapes.ts: shared OSC/CSI sanitizer
- apps/server/src/provider/opencodeRuntime.ts: strip before
  parseModels/Agent/Skills and via parse* entry points; keeps skills
  from silently degrading to [] when polluted
- apps/server/src/provider/Layers/OpenCodeProvider.ts: sanitize
  inventory agent names/variants and --version parsing; build clean
  capability option ids
- apps/server/src/provider/Layers/OpenCodeAdapter.ts &
  textGeneration/OpenCodeTextGeneration.ts: sanitize stored
  getModelSelectionStringOptionValue values before promptAsync
- packages/shared/src/model.ts: sanitize persisted option values and
  model slugs on read (repairs 3 polluted threads without DB migration)
- tests: add OSC/ANSI regression cases for both parsers

Polluted threads still read as clean via model.ts sanitizer; no
migration needed but DB can be cleaned with stripTerminalEscapes.

Fixes the reported UnknownError at SessionPrompt.createUserMessage
and the earlier "Agent not found" session.error.
Every bearer bootstrap exchange minted a brand-new auth session row and
sessions live for 30 days, so the Settings authorized-clients list filled
with duplicate "T3 Code Desktop" entries from repeated desktop launches,
window reloads, and dev restarts — all from a single machine.

Clients now present a stable per-install client_instance_id on token
exchange (desktop persists one in its state dir, web/desktop renderer in
localStorage, mobile in secure storage). When an exchange carries an
instance id, the server reuses the client's existing compatible session:
it extends the expiry and re-signs a token against it instead of creating
a new row. Incompatible sessions (e.g. widened scopes) are revoked before
the replacement is issued; DPoP exchanges are exempt since their key
thumbprint is not persisted. Issuance also prunes expired and revoked
session rows so the table no longer grows unbounded.
@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 26ac72a2-29d4-411c-916b-49e288910222

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Aug 23, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect service conventions review of the changed service code. One finding: a reused error class whose message no longer matches the failure it now reports. The rest of the touched services (namespace imports, Context.Service + make/layer shape, environment-based dependency acquisition in DesktopLocalEnvironmentAuth, AuthSessionRepository additions, SessionStore.issue error mapping) follow the conventions.

Posted via Macroscope — Effect Service Conventions

if (existing?.trim()) return existing;
const instanceId = yield* Effect.tryPromise({
try: () => import("../lib/uuid").then(({ uuidv4 }) => uuidv4()),
catch: (cause) => new MobileDeviceIdGenerationError({ cause }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MobileDeviceIdGenerationError renders "Failed to generate the mobile agent-awareness device id.", which is inaccurate for a client-instance-id failure, and the error carries no attribute distinguishing the two cases. Consider either a distinct error class for this failure, or adding a structural discriminator (e.g. key) and deriving the message from it.

Suggested change
catch: (cause) => new MobileDeviceIdGenerationError({ cause }),
catch: (cause) => new MobileClientInstanceIdGenerationError({ cause }),

Posted via Macroscope — Effect Service Conventions

// DPoP credentials are excluded because their key thumbprint is not
// persisted, so a reused session could not be safely re-signed.
if (client.instanceId !== undefined && input?.proofKeyThumbprint === undefined) {
const candidates = yield* authSessions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium auth/SessionStore.ts:593

Concurrent bootstrap exchanges for the same new instanceId create multiple active sessions, so duplicate authorized-client rows still accumulate. The listActiveForIdentity check at line 593 is not atomic with the later create; serialize this lookup-and-create sequence or enforce uniqueness with a database upsert/constraint.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/auth/SessionStore.ts around line 593:

Concurrent bootstrap exchanges for the same new `instanceId` create multiple active sessions, so duplicate authorized-client rows still accumulate. The `listActiveForIdentity` check at line 593 is not atomic with the later `create`; serialize this lookup-and-create sequence or enforce uniqueness with a database upsert/constraint.

`,
});

const updateSessionExpirationRow = SqlSchema.void({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High persistence/AuthSessions.ts:308

When a session is revoked or pruned after listActiveForIdentity selects it, updateSessionExpirationRow still reports success without updating a row, so SessionStore.issue signs and returns a token for an invalid session. Use an update operation that returns or counts affected rows and fail when none matched, or make the selection and update atomic.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/persistence/AuthSessions.ts around line 308:

When a session is revoked or pruned after `listActiveForIdentity` selects it, `updateSessionExpirationRow` still reports success without updating a row, so `SessionStore.issue` signs and returns a token for an invalid session. Use an update operation that returns or counts affected rows and fail when none matched, or make the selection and update atomic.

Comment on lines +13 to +19
const stored = input.read(CLIENT_INSTANCE_ID_STORAGE_KEY);
if (typeof stored === "string" && stored.trim() !== "") {
return stored;
}
const created = input.createId();
input.write(CLIENT_INSTANCE_ID_STORAGE_KEY, created);
return created;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High authorization/clientInstanceId.ts:13

readOrCreateClientInstanceId lets storage exceptions escape, so blocked or full localStorage makes clientMetadata() throw during capabilitiesLayer construction and prevents the client from connecting. Catch failures from both input.read and input.write, then return the generated ID as an in-memory fallback.

-  const stored = input.read(CLIENT_INSTANCE_ID_STORAGE_KEY);
+  let stored: string | null | undefined;
+  try {
+    stored = input.read(CLIENT_INSTANCE_ID_STORAGE_KEY);
+  } catch {
+    stored = undefined;
+  }
   if (typeof stored === "string" && stored.trim() !== "") {
     return stored;
   }
   const created = input.createId();
-  input.write(CLIENT_INSTANCE_ID_STORAGE_KEY, created);
+  try {
+    input.write(CLIENT_INSTANCE_ID_STORAGE_KEY, created);
+  } catch {
+    // Keep the generated ID in memory when persistence is unavailable.
+  }
   return created;
Also found in 1 other location(s)

apps/web/src/connection/platform.ts:136

clientInstanceId accesses globalThis.localStorage without exception handling. The localStorage getter can throw SecurityError when browser policy blocks persistence (for example when cookies/storage are disabled), and setItem can also fail; because this now runs while capabilitiesLayer is constructed, such users lose the entire connection platform instead of merely operating without a persisted instance ID. Optional chaining does not catch an exception thrown by the property getter.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/authorization/clientInstanceId.ts around lines 13-19:

`readOrCreateClientInstanceId` lets storage exceptions escape, so blocked or full `localStorage` makes `clientMetadata()` throw during `capabilitiesLayer` construction and prevents the client from connecting. Catch failures from both `input.read` and `input.write`, then return the generated ID as an in-memory fallback.

Also found in 1 other location(s):
- apps/web/src/connection/platform.ts:136 -- `clientInstanceId` accesses `globalThis.localStorage` without exception handling. The `localStorage` getter can throw `SecurityError` when browser policy blocks persistence (for example when cookies/storage are disabled), and `setItem` can also fail; because this now runs while `capabilitiesLayer` is constructed, such users lose the entire connection platform instead of merely operating without a persisted instance ID. Optional chaining does not catch an exception thrown by the property getter.

kind: "session",
sid: reusable.sessionId,
sub: reusable.subject,
scopes: reusable.scopes,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High auth/SessionStore.ts:618

The reuse path issues a token with reusable.scopes, so a client that previously had broad scopes keeps those permissions after requesting a reduced scope set. Because scopes.every(...) accepts stored supersets, the token's authorization exceeds the current request; use the requested scopes for the reused claims (or only reuse on exact scope equality).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/auth/SessionStore.ts around line 618:

The reuse path issues a token with `reusable.scopes`, so a client that previously had broad scopes keeps those permissions after requesting a reduced scope set. Because `scopes.every(...)` accepts stored supersets, the token's authorization exceeds the current request; use the requested `scopes` for the reused claims (or only reuse on exact scope equality).

* through `shell: true` spawns.
*/
const OSC_RE = /\x1b\].*?(?:\x07|\x1b\\)/g;
const CSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High src/stripTerminalEscapes.ts:17

CSI_RE leaves valid colon-delimited SGR sequences such as \x1b[38:2::255:0:0m in the output, so those escapes can pollute parsed identifiers or break JSON/version parsing. Match the full CSI parameter-byte range, including :, instead of restricting it to digits, ;, and ?.

Suggested change
const CSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
const CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/shared/src/stripTerminalEscapes.ts around line 17:

`CSI_RE` leaves valid colon-delimited SGR sequences such as `\x1b[38:2::255:0:0m` in the output, so those escapes can pollute parsed identifiers or break JSON/version parsing. Match the full CSI parameter-byte range, including `:`, instead of restricting it to digits, `;`, and `?`.

(cause) => new DesktopLocalEnvironmentAuthSessionBootstrapError({ cause }),
),
);
if (Option.isNone(stored)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium backend/DesktopLocalEnvironmentAuth.ts:77

A whitespace-only client-instance-id file causes a new instanceId to be generated on every desktop launch, so each bearer bootstrap creates a distinct authorized-client session. The persistence branch checks only Option.isNone(stored), so it skips writing the replacement for present-but-empty content; persist whenever the stored value is missing or blank.

Suggested change
if (Option.isNone(stored)) {
if (Option.isNone(stored) || stored.value.trim() === "") {
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts around line 77:

A whitespace-only `client-instance-id` file causes a new `instanceId` to be generated on every desktop launch, so each bearer bootstrap creates a distinct authorized-client session. The persistence branch checks only `Option.isNone(stored)`, so it skips writing the replacement for present-but-empty content; persist whenever the stored value is missing or blank.

credential: connect.credential,
dpopProof: bootstrapDpop,
clientMetadata: authClientMetadata(),
clientMetadata: authClientMetadata({ instanceId: yield* loadClientInstanceId() }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High cloud/linkEnvironment.ts:567

Cloud connect and refresh now fail before exchangeRemoteDpopAccessToken whenever secure storage cannot load the client instance ID, even though instanceId is optional. Handle that storage failure by omitting the metadata (or using an in-memory ID) so a transient or corrupt secure-storage read does not block authentication.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/cloud/linkEnvironment.ts around line 567:

Cloud connect and refresh now fail before `exchangeRemoteDpopAccessToken` whenever secure storage cannot load the client instance ID, even though `instanceId` is optional. Handle that storage failure by omitting the metadata (or using an in-memory ID) so a transient or corrupt secure-storage read does not block authentication.

} satisfies IssuedSession;
}
for (const stale of candidates) {
yield* authSessions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High auth/SessionStore.ts:663

When requested scopes widen and replacement issuance fails in crypto.randomUUIDv4, claims encoding, or authSessions.create, every existing session for the instance has already been revoked, so the exchange returns an error while leaving the client with no usable session. The loop at 663 runs before replacement creation; create the replacement and revoke stale rows atomically, or defer revocation until after successful creation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/auth/SessionStore.ts around line 663:

When requested scopes widen and replacement issuance fails in `crypto.randomUUIDv4`, claims encoding, or `authSessions.create`, every existing session for the instance has already been revoked, so the exchange returns an error while leaving the client with no usable session. The loop at `663` runs before replacement creation; create the replacement and revoke stale rows atomically, or defer revocation until after successful creation.

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

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Authorized clients accumulates duplicate 'T3 Code Desktop' entries per relaunch

1 participant