Skip to content

feat(chat): bind mobile tasks to Vertex account profiles - #449

Merged
dimakis merged 6 commits into
mainfrom
feat/mobile-provider-profiles
Sep 7, 2026
Merged

feat(chat): bind mobile tasks to Vertex account profiles#449
dimakis merged 6 commits into
mainfrom
feat/mobile-provider-profiles

Conversation

@dimakis

@dimakis dimakis commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Mobile chat now offers a server-backed account/model picker. Starting a task validates the selected Vertex profile and pins the SDK environment to its project, region and ADC reference. Bound conversations retain their original account/model across follow-ups, reloads and restarts. Unknown selections and changed configuration fail explicitly.

Before starting a bound SDK session, the server assigns a valid UUID and persists account binding plus boot context together. A startup failure therefore cannot turn newly created SDK history into an unbound legacy task. Profiles are loaded once per request and revalidated before startup. Credential contents stay on the server.

The picker validates catalog data, offers retry on errors, and offers an explicit legacy-server-account choice only for an empty catalog. Send stays disabled until an account is ready, preserving typed drafts. Failed quick launches—including those arriving after an error—pause with prompt and task metadata preserved until explicit send. Distinct launches with identical prompt text remain independent. Failed SDK startups are marked ended/inactive while retaining the account binding. Existing task bindings are visible; changing account/model requires a new task. Legacy clients' global model hints intentionally do not override a saved binding.

This is the Vertex mobile profile slice, with #450's dependency fixes included. OpenAI execution and full physical-phone acceptance remain separate work. No deployment is included.

Validation at 90c0796: 207 files / 3,216 tests passed; server/frontend types, lint, formatting and builds passed. Current-head CI run 34099544817 passed. Earlier candidate validation exercised ADC refresh, SDK Read, follow-up, reload and restart in a 390 × 844 browser viewport; that is not physical-phone acceptance of this final head. Existing lint/bundle warnings remain. See docs/features/account-profiles.md for configuration, persistence and remaining acceptance.

Final review resolution evidence:

Round 3 addressed in 8912a39, following the catalog/request-snapshot fixes in f7913b0.

  • Bound sessions now request a preallocated SDK UUID and persist account binding plus boot context before invoking query(). A startup regression test checks the durable record inside the query mock, then simulates failure before any SDK event. This closes the lost-binding window. New-session callback metadata is written together.
  • The repeated profile-file I/O finding describes the earlier implementation: handleSendV2 now loads one AccountProfiles instance and passes it to startChat. Both validation calls use that same snapshot. Startup deliberately revalidates stored metadata; existing bindings are immutable and legacy sessions cannot be upgraded through this route.
  • Bound task models intentionally cannot be changed in place. Model-only hints from old clients are their global preference, so they are ignored for bound tasks; an explicit account/model switch is rejected. The UI shows the saved binding. Start a new task to change account/model. This policy is documented in docs/features/account-profiles.md.
  • GOOGLE_APPLICATION_CREDENTIALS is explicitly removed before setting the chosen reference; missing selected accounts have a defensive UI guard; corrupt saved bindings now produce an explicit corruption error.

Validation: 207 files / 3,213 tests; lint, formatting, server/frontend types and builds passed. Physical phone acceptance and deployment are not claimed.

Round 5 fixes at 90c0796:

  • Failed preallocated SDK startups are marked ENDED/inactive. Their binding is deliberately retained because SDK history may already exist; deleting it would reopen the billing-downgrade risk. A regression test asserts both termination and retained binding.
  • Pending launch deduplication uses launch-object identity, allowing distinct tasks with identical prompt text. The two-launch regression test passes.
  • The validation-only WS gate and credential-environment scrubbing are documented. Raw profile parser errors stay redacted because they may echo invalid inline credentials.
  • The late-launch effect uses an explicit onUnavailable dependency and passes both before-error and after-error tests; the review correctly describes why that behavior works. No speculative dependency removal is assumed.

Full suite: 207 files / 3,216 tests passed, with all required checks.

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 6 issue(s) (1 critical) (3 warning).

frontend/src/pages/ChatView.tsx

The main concern is a critical regression: servers without Vertex configuration have a completely non-functional mobile chat because the send path requires a non-null account selection that can never be populated from an empty catalog. The account binding persistence and SDK env construction look solid otherwise, with a secondary concern around unguarded JSON.parse in the event store.

  • 🔴 regressions (L168): When no accounts are configured (no Vertex env vars, no profile file), loadAccountProfiles() returns an empty catalog, AccountModelPicker sets an error and never calls onChange with a non-null value, so accountSelection stays permanently null. The guard if (!activeSessionId && !accountSelection) return false then blocks every send — the mobile chat is completely unusable. The same guard at line 139 also blocks the pending-session auto-send path. Before this PR, the chat worked without any account configuration; this is a regression for any server not running Vertex AI. [fixable]
  • 🟡 regressions (L228): The hardcoded model <select> dropdown (Opus 4.8, Sonnet 5, etc.) was removed from the header and replaced entirely by AccountModelPicker. When accounts are configured this works, but when they are not (see critical finding above), there is no model selector at all — users lose the ability to switch models even if the send-blocking issue were fixed. The old dropdown should remain as a fallback when no accounts are available. [fixable]

packages/protocol/src/event-store.ts

The main concern is a critical regression: servers without Vertex configuration have a completely non-functional mobile chat because the send path requires a non-null account selection that can never be populated from an empty catalog. The account binding persistence and SDK env construction look solid otherwise, with a secondary concern around unguarded JSON.parse in the event store.

  • 🟡 bugs (L794): JSON.parse(row.account_binding) has no try/catch. If the column contains malformed JSON (manual DB edit, partial write, future schema change), this throws an unhandled exception that propagates up through getSession, listSessions, getAttentionSessions, and searchSessions, crashing any operation that touches the corrupt row. Other parse sites in this file (e.g. extractSnippet at line 745) do have try/catch protection. Wrap in try/catch and fall back to null. [fixable]
  • 🔵 style (L336): The account_binding column migration does not log, unlike every other migration in the file (e.g. agent_name at line 333 and boot_context at line 340 both log). Add this.log.info('migrated sessions table: added account_binding') for consistency and operational visibility. [fixable]

server/ws-handler-v2.ts

The main concern is a critical regression: servers without Vertex configuration have a completely non-functional mobile chat because the send path requires a non-null account selection that can never be populated from an empty catalog. The account binding persistence and SDK env construction look solid otherwise, with a secondary concern around unguarded JSON.parse in the event store.

  • 🟡 unsafe_assumptions (L461): resolveAccountSelection is called here for validation (return value discarded), then called again inside _startChatInner (chat.ts:780) for actual use. Both calls invoke loadAccountProfiles() which does a synchronous readFileSync of the profile file. This is a TOCTOU window (profile file could change between the two reads) and doubles the disk I/O per message send. Consider resolving once and passing the result through, or caching the loaded profiles. [fixable]

server/account-profiles.ts

The main concern is a critical regression: servers without Vertex configuration have a completely non-functional mobile chat because the send path requires a non-null account selection that can never be populated from an empty catalog. The account binding persistence and SDK env construction look solid otherwise, with a secondary concern around unguarded JSON.parse in the event store.

  • 🔵 unsafe_assumptions (L107): loadAccountProfiles() calls readFileSync on every invocation with no caching. It is called from /api/accounts (every page load), twice per WS send (ws-handler + chat.ts), and from sdkEnv. For a small local file this is unlikely to cause issues, but a module-level lazy cache with a short TTL or process-lifetime cache would eliminate redundant reads. [fixable]

// ── Actions ──────────────────────────────────────────────────────────────

function handleSend(text: string, images?: ImageAttachment[], ctxBlocks?: string[]): boolean {
if (!activeSessionId && !accountSelection) return false;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔴 regressions: When no accounts are configured (no Vertex env vars, no profile file), loadAccountProfiles() returns an empty catalog, AccountModelPicker sets an error and never calls onChange with a non-null value, so accountSelection stays permanently null. The guard if (!activeSessionId && !accountSelection) return false then blocks every send — the mobile chat is completely unusable. The same guard at line 139 also blocks the pending-session auto-send path. Before this PR, the chat worked without any account configuration; this is a regression for any server not running Vertex AI. [fixable]

@@ -217,20 +228,7 @@ export function ChatView() {
!

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 regressions: The hardcoded model <select> dropdown (Opus 4.8, Sonnet 5, etc.) was removed from the header and replaced entirely by AccountModelPicker. When accounts are configured this works, but when they are not (see critical finding above), there is no model selector at all — users lose the ability to switch models even if the send-blocking issue were fixed. The old dropdown should remain as a fallback when no accounts are available. [fixable]

Comment thread packages/protocol/src/event-store.ts Outdated
lastStateChange: row.last_state_change ?? null,
agentName: row.agent_name ?? null,
bootContext: row.boot_context ?? null,
accountBinding: row.account_binding ? JSON.parse(row.account_binding) : null,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 bugs: JSON.parse(row.account_binding) has no try/catch. If the column contains malformed JSON (manual DB edit, partial write, future schema change), this throws an unhandled exception that propagates up through getSession, listSessions, getAttentionSessions, and searchSessions, crashing any operation that touches the corrupt row. Other parse sites in this file (e.g. extractSnippet at line 745) do have try/catch protection. Wrap in try/catch and fall back to null. [fixable]

this.log.info('migrated sessions table: added agent_name');
}
if (!columnNames.has('account_binding')) {
db.exec('ALTER TABLE sessions ADD COLUMN account_binding TEXT');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The account_binding column migration does not log, unlike every other migration in the file (e.g. agent_name at line 333 and boot_context at line 340 both log). Add this.log.info('migrated sessions table: added account_binding') for consistency and operational visibility. [fixable]

Comment thread server/ws-handler-v2.ts Outdated
{ 'ws.connectionId': connectionId, 'ws.sessionId': msg.sessionId ?? 'new' },
(span) => {
try {
resolveAccountSelection(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 unsafe_assumptions: resolveAccountSelection is called here for validation (return value discarded), then called again inside _startChatInner (chat.ts:780) for actual use. Both calls invoke loadAccountProfiles() which does a synchronous readFileSync of the profile file. This is a TOCTOU window (profile file could change between the two reads) and doubles the disk I/O per message send. Consider resolving once and passing the result through, or caching the loaded profiles. [fixable]


/** A file contains profiles and credential references, never credential values. */
export function loadAccountProfiles(): AccountProfiles {
if (process.env.MITZO_ACCOUNT_PROFILES_FILE) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: loadAccountProfiles() calls readFileSync on every invocation with no caching. It is called from /api/accounts (every page load), twice per WS send (ws-handler + chat.ts), and from sdkEnv. For a small local file this is unlikely to cause issues, but a module-level lazy cache with a short TTL or process-lifetime cache would eliminate redundant reads. [fixable]

* fix(ci): propagate test failures and repair stale test fixtures

* fix(ci): validate stacked pull requests

* test(ci): isolate credential discovery and share store fixtures

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 7 issue(s) (1 critical) (4 warning).

frontend/src/pages/ChatView.tsx

The account binding plumbing is solid, but removing the fallback model selector creates a critical regression: any /api/accounts failure permanently blocks chat with no in-app recovery. The server side has a TOCTOU race from loading the profile file multiple times per request.

  • 🔴 regressions (L168): The old hardcoded model <select> was removed entirely and replaced by AccountModelPicker. If /api/accounts fails or returns an empty catalog, accountSelection stays null permanently, causing handleSend to return false on every call. The user cannot send any message and has no in-app recovery path (no retry button, no fallback selector). This is a single-point-of-failure regression: an account API outage now blocks all chat, whereas previously chat always worked with the hardcoded model list. [fixable]
  • 🟡 bugs (L139): The pendingSession auto-send effect now gates on accountSelection. If the account catalog fails to load, pending sessions (set by 'Start Session' from inbox/todo) silently never fire. clearPendingSession() is never called on the failure path, so the stale prompt sits in the store indefinitely—and could fire unexpectedly if the catalog loads later due to a sessionId change re-triggering the AccountModelPicker effect. [fixable]

server/chat.ts

The account binding plumbing is solid, but removing the fallback model selector creates a critical regression: any /api/accounts failure permanently blocks chat with no in-app recovery. The server side has a TOCTOU race from loading the profile file multiple times per request.

  • 🟡 bugs (L787): TOCTOU race: resolveAccountSelection() at line 780 calls loadAccountProfiles() (reads the profile file from disk), then line 787 calls loadAccountProfiles() again for .sdkEnv(). sdkEnv internally calls this.resume(binding) which re-derives profileRevision and compares it to the binding. If the profile file is modified between the two synchronous reads, resume() throws 'Account configuration changed' even though the binding was just resolved. Fix by capturing the AccountProfiles instance once and reusing it. [fixable]

server/ws-handler-v2.ts

The account binding plumbing is solid, but removing the fallback model selector creates a critical regression: any /api/accounts failure permanently blocks chat with no in-app recovery. The server side has a TOCTOU race from loading the profile file multiple times per request.

  • 🟡 unsafe_assumptions (L461): resolveAccountSelection() is called here as an early validation gate, but its return value is discarded. It is then called again inside _startChatInner. Each call invokes loadAccountProfiles() which does a synchronous readFileSync from disk. Combined with the third read in chat.ts:787, a single send triggers up to 3 synchronous file reads on the event loop. Consider loading profiles once at the WS handler level and passing the instance through. [fixable]

frontend/src/components/AccountModelPicker.tsx

The account binding plumbing is solid, but removing the fallback model selector creates a critical regression: any /api/accounts failure permanently blocks chat with no in-app recovery. The server side has a TOCTOU race from loading the profile file multiple times per request.

  • 🟡 bugs (L56): If an account's models array is empty, first.models[0].id throws a TypeError. The same crash exists in the account-switch handler at line 86 (nextAccount.models[0].id). The server's Zod schema enforces .min(1) on models, but the client has no defensive check—a malformed API response or a future schema relaxation would crash the component. [fixable]
  • 🔵 style (L34): onChange(null) is called unconditionally at the top of the effect before the async fetch begins. This resets the parent's accountSelection to null on every effect run. The chain is currently stable because selectAccount is useCallback-wrapped, but if that wrapper is ever removed, onChange instability would cause an infinite render loop. Consider moving the reset inside the fetch's finally/error path, or documenting the stability requirement. [fixable]

packages/protocol/src/event-store.ts

The account binding plumbing is solid, but removing the fallback model selector creates a critical regression: any /api/accounts failure permanently blocks chat with no in-app recovery. The server side has a TOCTOU race from loading the profile file multiple times per request.

  • 🔵 style (L336): The account_binding migration is missing the this.log.info('migrated sessions table: added account_binding') log line that every other column migration in this method includes. Inconsistent with the established pattern and makes migration issues harder to debug. [fixable]

// ── Actions ──────────────────────────────────────────────────────────────

function handleSend(text: string, images?: ImageAttachment[], ctxBlocks?: string[]): boolean {
if (!activeSessionId && !accountSelection) return false;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔴 regressions: The old hardcoded model <select> was removed entirely and replaced by AccountModelPicker. If /api/accounts fails or returns an empty catalog, accountSelection stays null permanently, causing handleSend to return false on every call. The user cannot send any message and has no in-app recovery path (no retry button, no fallback selector). This is a single-point-of-failure regression: an account API outage now blocks all chat, whereas previously chat always worked with the hardcoded model list. [fixable]

const pendingConsumed = useRef<string | null>(null);
useEffect(() => {
if (!pendingSession) return;
if (!pendingSession || !accountSelection) return;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 bugs: The pendingSession auto-send effect now gates on accountSelection. If the account catalog fails to load, pending sessions (set by 'Start Session' from inbox/todo) silently never fire. clearPendingSession() is never called on the failure path, so the stale prompt sits in the store indefinitely—and could fire unexpectedly if the catalog loads later due to a sessionId change re-triggering the AccountModelPicker effect. [fixable]

Comment thread server/chat.ts Outdated
);
if (accountBinding) {
options = { ...options, model: accountBinding.model };
accountEnv = loadAccountProfiles().sdkEnv(accountBinding, sdkEnv());

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 bugs: TOCTOU race: resolveAccountSelection() at line 780 calls loadAccountProfiles() (reads the profile file from disk), then line 787 calls loadAccountProfiles() again for .sdkEnv(). sdkEnv internally calls this.resume(binding) which re-derives profileRevision and compares it to the binding. If the profile file is modified between the two synchronous reads, resume() throws 'Account configuration changed' even though the binding was just resolved. Fix by capturing the AccountProfiles instance once and reusing it. [fixable]

Comment thread server/ws-handler-v2.ts Outdated
{ 'ws.connectionId': connectionId, 'ws.sessionId': msg.sessionId ?? 'new' },
(span) => {
try {
resolveAccountSelection(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 unsafe_assumptions: resolveAccountSelection() is called here as an early validation gate, but its return value is discarded. It is then called again inside _startChatInner. Each call invokes loadAccountProfiles() which does a synchronous readFileSync from disk. Combined with the third read in chat.ts:787, a single send triggers up to 3 synchronous file reads on the event loop. Consider loading profiles once at the WS handler level and passing the instance through. [fixable]

accountId: first.id,
model: first.models.some((m) => m.id === preferredModel)
? preferredModel
: first.models[0].id,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 bugs: If an account's models array is empty, first.models[0].id throws a TypeError. The same crash exists in the account-switch handler at line 86 (nextAccount.models[0].id). The server's Zod schema enforces .min(1) on models, but the client has no defensive check—a malformed API response or a future schema relaxation would crash the component. [fixable]

setError('');
setBindingLabel('');
setSelection(null);
onChange(null);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: onChange(null) is called unconditionally at the top of the effect before the async fetch begins. This resets the parent's accountSelection to null on every effect run. The chain is currently stable because selectAccount is useCallback-wrapped, but if that wrapper is ever removed, onChange instability would cause an infinite render loop. Consider moving the reset inside the fetch's finally/error path, or documenting the stability requirement. [fixable]

this.log.info('migrated sessions table: added agent_name');
}
if (!columnNames.has('account_binding')) {
db.exec('ALTER TABLE sessions ADD COLUMN account_binding TEXT');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The account_binding migration is missing the this.log.info('migrated sessions table: added account_binding') log line that every other column migration in this method includes. Inconsistent with the established pattern and makes migration issues harder to debug. [fixable]

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 7 issue(s) (3 warning).

server/ws-handler-v2.ts

Solid account profiles implementation with good test coverage and security boundaries. Main concerns: redundant double-validation with disk I/O on every WS send, non-atomic binding persistence in onSessionResolved, and the mobile model selector removal leaves no override path for follow-up messages.

  • 🟡 bugs (L465): Double validation: resolveAccountSelection() is called here (return value discarded) and again inside _startChatInner (chat.ts:793). Both paths also call eventStore.getSession() independently. The ws-handler call is a fail-fast guard, but it introduces a TOCTOU window: between the two reads of the stored binding, a concurrent request could persist a binding, causing divergent validation results. More practically, loadAccountProfiles() does a synchronous readFileSync on every invocation — for bound sessions this means disk I/O on every WS send message, twice. Consider passing the resolved binding from ws-handler into startChat instead of discarding it. [fixable]

frontend/src/pages/ChatView.tsx

Solid account profiles implementation with good test coverage and security boundaries. Main concerns: redundant double-validation with disk I/O on every WS send, non-atomic binding persistence in onSessionResolved, and the mobile model selector removal leaves no override path for follow-up messages.

  • 🟡 regressions (L186): For follow-up messages on existing sessions (activeSessionId is truthy), the spread ...(!activeSessionId && accountSelection ? accountSelection : {}) produces ...{}, so neither model nor accountId is sent in the WS payload. Previously the code explicitly sent model: modelState. The store's sendMessage falls back to config.modelId, which is kept in sync via setModel — so this is functionally equivalent today. However, after a session switch (user navigates to session A, then back to session B), config.modelId may reflect session A's model. The old code used local state modelState which had the same issue, so this is not a new regression, but the removal of the explicit model selector from the mobile header (the hardcoded <select> dropdown was deleted) means the user now has no way to override the model for follow-up messages on an active session. [fixable]

server/chat.ts

Solid account profiles implementation with good test coverage and security boundaries. Main concerns: redundant double-validation with disk I/O on every WS send, non-atomic binding persistence in onSessionResolved, and the mobile model selector removal leaves no override path for follow-up messages.

  • 🟡 unsafe_assumptions (L1088): Account binding is persisted in onSessionResolved (after the SDK assigns a session ID), and boot context is persisted in a separate upsertSession call at line 1092. These two writes are not atomic: a crash between them leaves the session with a binding but no boot context, or vice versa. More importantly, if the server crashes before onSessionResolved fires, the binding is lost entirely — on resume the session would be treated as legacy (unbound), potentially routing to different billing credentials. [fixable]

server/account-profiles.ts

Solid account profiles implementation with good test coverage and security boundaries. Main concerns: redundant double-validation with disk I/O on every WS send, non-atomic binding persistence in onSessionResolved, and the mobile model selector removal leaves no override path for follow-up messages.

  • 🔵 bugs (L152): When a client resumes a bound session and sends { model: 'new-model' } without accountId, the guard at line 153 is skipped (it only checks when selection.accountId is truthy). The function returns the stored binding with the original model, and chat.ts:795 silently overwrites the client's requested model. The client gets no error or warning that its model change was ignored. Consider either rejecting the model change explicitly or documenting this as intentional. [fixable]
  • 🔵 style (L88): The env cleanup regex strips ANTHROPIC_*, OPENAI_*, CLAUDE_CODE_USE_* etc., and the explicit list strips GOOGLE_API_KEY, but GOOGLE_APPLICATION_CREDENTIALS is not explicitly stripped — it's only overwritten by the spread at line 100. This is functionally correct today but inconsistent with the cleanup pattern for other credential variables. If the GOOGLE_APPLICATION_CREDENTIALS assignment were ever made conditional, the old value would leak through. [fixable]

frontend/src/components/AccountModelPicker.tsx

Solid account profiles implementation with good test coverage and security boundaries. Main concerns: redundant double-validation with disk I/O on every WS send, non-atomic binding persistence in onSessionResolved, and the mobile model selector removal leaves no override path for follow-up messages.

  • 🔵 unsafe_assumptions (L123): Non-null assertion accounts.find(...)! is safe under current code paths (accounts and selection are always set together), but provides no runtime protection. If a future change clears accounts independently of selection, this would throw a TypeError when accessing account.models. A defensive null check with fallback would be safer. [fixable]

packages/protocol/src/event-store.ts

Solid account profiles implementation with good test coverage and security boundaries. Main concerns: redundant double-validation with disk I/O on every WS send, non-atomic binding persistence in onSessionResolved, and the mobile model selector removal leaves no override path for follow-up messages.

  • 🔵 style (L813): The parseAccountBinding sentinel ({ accountId: 'unavailable', provider: 'unavailable', ... }) correctly prevents silent downgrade to the legacy route, but produces misleading error messages downstream. When this sentinel flows into resolveAccountSelection, the user sees 'This task is bound to its original account' or 'Account is unavailable' — neither mentions the root cause is corrupt binding data. Consider a distinct error path for corrupt bindings. [fixable]

Comment thread server/ws-handler-v2.ts
? ctx.eventStore.getSession(msg.sessionId)?.accountBinding
: null;
const accountProfiles = msg.accountId || storedBinding ? loadAccountProfiles() : undefined;
resolveAccountSelection(msg, storedBinding, !!msg.sessionId, accountProfiles);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 bugs: Double validation: resolveAccountSelection() is called here (return value discarded) and again inside _startChatInner (chat.ts:793). Both paths also call eventStore.getSession() independently. The ws-handler call is a fail-fast guard, but it introduces a TOCTOU window: between the two reads of the stored binding, a concurrent request could persist a binding, causing divergent validation results. More practically, loadAccountProfiles() does a synchronous readFileSync on every invocation — for bound sessions this means disk I/O on every WS send message, twice. Consider passing the resolved binding from ws-handler into startChat instead of discarding it. [fixable]

@@ -167,7 +186,7 @@ export function ChatView() {
storeSendMessage(text, {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 regressions: For follow-up messages on existing sessions (activeSessionId is truthy), the spread ...(!activeSessionId && accountSelection ? accountSelection : {}) produces ...{}, so neither model nor accountId is sent in the WS payload. Previously the code explicitly sent model: modelState. The store's sendMessage falls back to config.modelId, which is kept in sync via setModel — so this is functionally equivalent today. However, after a session switch (user navigates to session A, then back to session B), config.modelId may reflect session A's model. The old code used local state modelState which had the same issue, so this is not a new regression, but the removal of the explicit model selector from the mobile header (the hardcoded <select> dropdown was deleted) means the user now has no way to override the model for follow-up messages on an active session. [fixable]

Comment thread server/chat.ts
options.resume ? undefined : fullPrompt,
{
connRegistry: _connRegistry ?? undefined,
onSessionResolved: (sessionId: string) => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 unsafe_assumptions: Account binding is persisted in onSessionResolved (after the SDK assigns a session ID), and boot context is persisted in a separate upsertSession call at line 1092. These two writes are not atomic: a crash between them leaves the session with a binding but no boot context, or vice versa. More importantly, if the server crashes before onSessionResolved fires, the binding is lost entirely — on resume the session would be treated as legacy (unbound), potentially routing to different billing credentials. [fixable]

resuming = false,
profiles?: AccountProfiles,
): AccountBinding | undefined {
if (stored) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 bugs: When a client resumes a bound session and sends { model: 'new-model' } without accountId, the guard at line 153 is skipped (it only checks when selection.accountId is truthy). The function returns the stored binding with the original model, and chat.ts:795 silently overwrites the client's requested model. The client gets no error or warning that its model change was ignored. Consider either rejecting the model change explicitly or documenting this as intentional. [fixable]

this.resume(binding);
const profile = this.profiles.find((p) => p.id === binding.accountId)!;
const env = { ...base };
for (const key of Object.keys(env)) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The env cleanup regex strips ANTHROPIC_*, OPENAI_*, CLAUDE_CODE_USE_* etc., and the explicit list strips GOOGLE_API_KEY, but GOOGLE_APPLICATION_CREDENTIALS is not explicitly stripped — it's only overwritten by the spread at line 100. This is functionally correct today but inconsistent with the cleanup pattern for other credential variables. If the GOOGLE_APPLICATION_CREDENTIALS assignment were ever made conditional, the old value would leak through. [fixable]

</>
);
if (!selection) return <span>Loading accounts…</span>;
const account = accounts.find((a) => a.id === (selection.accountId ?? ''))!;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: Non-null assertion accounts.find(...)! is safe under current code paths (accounts and selection are always set together), but provides no runtime protection. If a future change clears accounts independently of selection, this would throw a TypeError when accessing account.models. A defensive null check with fallback would be safer. [fixable]

)
)
return binding;
} catch {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The parseAccountBinding sentinel ({ accountId: 'unavailable', provider: 'unavailable', ... }) correctly prevents silent downgrade to the legacy route, but produces misleading error messages downstream. When this sentinel flows into resolveAccountSelection, the user sees 'This task is bound to its original account' or 'Account is unavailable' — neither mentions the root cause is corrupt binding data. Consider a distinct error path for corrupt bindings. [fixable]

@dimakis
dimakis marked this pull request as ready for review September 7, 2026 07:40

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 6 issue(s) (3 warning).

frontend/src/components/AccountModelPicker.tsx

Well-structured feature with good fail-closed design and test coverage; the main concerns are a sync file read in the WS hot path, a silent send rejection before accounts load, and a minor redundant Zod re-parse.

  • 🟡 bugs (L65): After safeParse validates data (checked on the !parsed.success guard), parse is called a second time on the same data. This double-parse is redundant. Use parsed.data instead of re-calling modelsSchema.parse(data) / catalogSchema.parse(data) to avoid unnecessary re-validation and a second allocation. [fixable]

frontend/src/pages/ChatView.tsx

Well-structured feature with good fail-closed design and test coverage; the main concerns are a sync file read in the WS hot path, a silent send rejection before accounts load, and a minor redundant Zod re-parse.

  • 🟡 regressions (L175): handleSend returns false with no user-visible feedback when accountSelection is null (i.e. accounts are still loading or the picker errored and the user hasn't retried). The AccountModelPicker shows 'Loading accounts…' but ChatInput's send button appears active and silently rejects taps. Consider disabling the input or showing a toast so the user knows why the send was swallowed. [fixable]

server/ws-handler-v2.ts

Well-structured feature with good fail-closed design and test coverage; the main concerns are a sync file read in the WS hot path, a silent send rejection before accounts load, and a minor redundant Zod re-parse.

  • 🟡 unsafe_assumptions (L462): For every follow-up message to a bound session, loadAccountProfiles() calls readFileSync(MITZO_ACCOUNT_PROFILES_FILE) and JSON.parse synchronously in the hot path of the WS handler. The profiles are re-read and the binding is re-validated on every single message even though the session's binding is immutable after creation. Consider caching the AccountProfiles instance per request or globally with file-change invalidation. [fixable]
  • 🔵 style (L463): resolveAccountSelection is called here for early validation (result discarded), then called again inside _startChatInner where the result is captured. Both calls share the same accountProfiles instance so correctness is fine, but the double validation reads the event store twice for the same session and runs profiles.resume() twice for resume cases. Consider letting startChat own validation entirely, or capturing the ws-handler result and passing it through. [fixable]

server/account-profiles.ts

Well-structured feature with good fail-closed design and test coverage; the main concerns are a sync file read in the WS hot path, a silent send rejection before accounts load, and a minor redundant Zod re-parse.

  • 🔵 unsafe_assumptions (L119): loadAccountProfiles() creates a vertex-default profile when ANTHROPIC_VERTEX_PROJECT_ID is set, even without CLAUDE_CODE_USE_VERTEX=1. Operators who set that env var for unrelated tools would silently get a Vertex billing profile in the account catalog. The only way to suppress it is CLAUDE_CODE_USE_VERTEX=0. The design doc acknowledges this, but it's worth a comment or log line since it could lead to unexpected billing. [fixable]

frontend/src/pages/__tests__/ChatViewAccounts.test.tsx

Well-structured feature with good fail-closed design and test coverage; the main concerns are a sync file read in the WS hot path, a silent send rejection before accounts load, and a minor redundant Zod re-parse.

  • 🔵 missing_tests: The test covers the paused-launch recovery flow well, but there's no test for the case where pendingSession arrives after the AccountModelPicker has already entered its error state. In that scenario, accountUnavailable would fire with the latest pendingSession, but only if the component re-renders and the picker re-calls onUnavailable — which it won't because it's already showing the error. The pending session could be silently stuck.

: 'Existing task · legacy account',
);
} else {
const parsed = legacy ? modelsSchema.safeParse(data) : catalogSchema.safeParse(data);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 bugs: After safeParse validates data (checked on the !parsed.success guard), parse is called a second time on the same data. This double-parse is redundant. Use parsed.data instead of re-calling modelsSchema.parse(data) / catalogSchema.parse(data) to avoid unnecessary re-validation and a second allocation. [fixable]

@@ -155,6 +173,7 @@ export function ChatView() {
// ── Actions ──────────────────────────────────────────────────────────────

function handleSend(text: string, images?: ImageAttachment[], ctxBlocks?: string[]): boolean {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 regressions: handleSend returns false with no user-visible feedback when accountSelection is null (i.e. accounts are still loading or the picker errored and the user hasn't retried). The AccountModelPicker shows 'Loading accounts…' but ChatInput's send button appears active and silently rejects taps. Consider disabling the input or showing a toast so the user knows why the send was swallowed. [fixable]

Comment thread server/ws-handler-v2.ts
(span) => {
try {
const storedBinding = msg.sessionId
? ctx.eventStore.getSession(msg.sessionId)?.accountBinding

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 unsafe_assumptions: For every follow-up message to a bound session, loadAccountProfiles() calls readFileSync(MITZO_ACCOUNT_PROFILES_FILE) and JSON.parse synchronously in the hot path of the WS handler. The profiles are re-read and the binding is re-validated on every single message even though the session's binding is immutable after creation. Consider caching the AccountProfiles instance per request or globally with file-change invalidation. [fixable]

Comment thread server/ws-handler-v2.ts
try {
const storedBinding = msg.sessionId
? ctx.eventStore.getSession(msg.sessionId)?.accountBinding
: null;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: resolveAccountSelection is called here for early validation (result discarded), then called again inside _startChatInner where the result is captured. Both calls share the same accountProfiles instance so correctness is fine, but the double validation reads the event store twice for the same session and runs profiles.resume() twice for resume cases. Consider letting startChat own validation entirely, or capturing the ws-handler result and passing it through. [fixable]

);
} catch {
throw new Error(
'Cannot load account profiles. Check MITZO_ACCOUNT_PROFILES_FILE on the Mac.',

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: loadAccountProfiles() creates a vertex-default profile when ANTHROPIC_VERTEX_PROJECT_ID is set, even without CLAUDE_CODE_USE_VERTEX=1. Operators who set that env var for unrelated tools would silently get a Vertex billing profile in the account catalog. The only way to suppress it is CLAUDE_CODE_USE_VERTEX=0. The design doc acknowledges this, but it's worth a comment or log line since it could lead to unexpected billing. [fixable]

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 7 issue(s) (3 warning).

server/chat.ts

Well-structured feature with solid test coverage and correct fail-closed semantics. The main concern is orphaned session metadata in the event store when SDK startup fails after pre-persisting the account binding — needs cleanup in the catch block.

  • 🟡 bugs (L1031): When query() throws before emitting any events, the pre-persisted session (newSdkSessionId with accountBinding) is never cleaned up from the event store. The catch block at line 1100 cleans up worktrees and aborts the registry but does not remove the orphaned session metadata. This leaves a dangling session in the store that appears in session listings, has no events, and never transitions to ENDED. Consider adding eventStore.deleteSession(newSdkSessionId) or marking it ENDED in the catch block. [fixable]

server/ws-handler-v2.ts

Well-structured feature with solid test coverage and correct fail-closed semantics. The main concern is orphaned session metadata in the event store when SDK startup fails after pre-persisting the account binding — needs cleanup in the catch block.

  • 🟡 regressions (L465): The return value of resolveAccountSelection() at line 465 is discarded — only its throw behavior is used for early validation. However, when startChat is called later (lines 607-621 and 630-644), it calls resolveAccountSelection again inside _startChatInner (chat.ts line 793). This means loadAccountProfiles() reads the profile file twice if options.accountProfiles is not passed — but it is passed, so the same instance is reused. The double-validation is intentional (fail-fast guard), but the discarded return value at line 465 is confusing. Consider assigning it to a variable or adding a comment clarifying this is a validation-only call. [fixable]

frontend/src/pages/ChatView.tsx

Well-structured feature with solid test coverage and correct fail-closed semantics. The main concern is orphaned session metadata in the event store when SDK startup fails after pre-persisting the account binding — needs cleanup in the catch block.

  • 🟡 regressions (L140): The accountUnavailable callback captures pendingSession in its closure via useCallback(..., [pendingSession, clearPendingSession]). However, when onUnavailable fires from AccountModelPicker, it's called during render or in a useEffect triggered by error/empty state. If pendingSession arrives after the error has already been set (the 'after' timing in the test), accountUnavailable was created with pendingSession: null. The test works because the component re-renders when pendingSession changes, creating a new accountUnavailable closure, and the useEffect at line 41-43 re-fires when error is already set. But there's a subtle timing dependency: the onUnavailable effect depends on both error and onUnavailable (which changes when pendingSession changes). If onUnavailable changes but error stays the same, the effect re-runs and correctly calls the new closure. This works but is fragile.
  • 🔵 bugs (L150): The pendingConsumed ref guards duplicate sends by keying on pendingSession.prompt. If two successive pending sessions arrive with identical prompt text (e.g., two task launches with the same template), the second will be silently dropped. Consider using a unique key like a combination of prompt + telosTaskId or a counter instead. [fixable]

server/account-profiles.ts

Well-structured feature with solid test coverage and correct fail-closed semantics. The main concern is orphaned session metadata in the event store when SDK startup fails after pre-persisting the account binding — needs cleanup in the catch block.

  • 🔵 style (L90): The regex /^(ANTHROPIC_|OPENAI_|CLAUDE_CODE_USE_|CLAUDE_CODE_SKIP_|VERTEX_REGION_)/ strips env vars that are then re-set on lines 101-106. While correct (delete-then-reassign), the intent is not obvious to a reader. The CLAUDE_CODE_SKIP_* prefix is forward-looking — no such vars exist in the codebase today. Consider documenting the scrubbing intent or using an explicit allowlist of keys to preserve instead of a deletion regex. [fixable]
  • 🔵 style (L117): The catch block in loadAccountProfiles() at line 117 catches all errors (including JSON parse errors, file-not-found, and Zod validation failures) and replaces them with a generic message. For operator debugging, preserving the original error message or logging it server-side would help diagnose misconfigured profile files. [fixable]

server/__tests__/chat-account-startup.test.ts

Well-structured feature with solid test coverage and correct fail-closed semantics. The main concern is orphaned session metadata in the event store when SDK startup fails after pre-persisting the account binding — needs cleanup in the catch block.

  • 🔵 missing_tests: The test verifies pre-persistence of the binding before query() starts, and covers the failure case where query() throws. However, there is no test verifying that the orphaned session is cleaned up after query() throws (because it isn't cleaned up — see the orphan session finding). Adding a test that asserts the session is removed or marked ENDED after a startup failure would codify the expected cleanup behavior. [fixable]

Comment thread server/chat.ts
// Bound sessions have durable routing before the SDK can create history or side effects.
const newSdkSessionId = accountBinding && !resolvedResume ? randomUUID() : undefined;
try {
if (newSdkSessionId) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 bugs: When query() throws before emitting any events, the pre-persisted session (newSdkSessionId with accountBinding) is never cleaned up from the event store. The catch block at line 1100 cleans up worktrees and aborts the registry but does not remove the orphaned session metadata. This leaves a dangling session in the store that appears in session listings, has no events, and never transitions to ENDED. Consider adding eventStore.deleteSession(newSdkSessionId) or marking it ENDED in the catch block. [fixable]

Comment thread server/ws-handler-v2.ts
? ctx.eventStore.getSession(msg.sessionId)?.accountBinding
: null;
const accountProfiles = msg.accountId || storedBinding ? loadAccountProfiles() : undefined;
resolveAccountSelection(msg, storedBinding, !!msg.sessionId, accountProfiles);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 regressions: The return value of resolveAccountSelection() at line 465 is discarded — only its throw behavior is used for early validation. However, when startChat is called later (lines 607-621 and 630-644), it calls resolveAccountSelection again inside _startChatInner (chat.ts line 793). This means loadAccountProfiles() reads the profile file twice if options.accountProfiles is not passed — but it is passed, so the same instance is reused. The double-validation is intentional (fail-fast guard), but the discarded return value at line 465 is confusing. Consider assigning it to a variable or adding a comment clarifying this is a validation-only call. [fixable]

// Auto-send pending session (from "Start Session" on inbox/todo items)
const pendingConsumed = useRef<string | null>(null);
const [pausedLaunch, setPausedLaunch] = useState<typeof pendingSession>(null);
const accountUnavailable = useCallback(() => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 regressions: The accountUnavailable callback captures pendingSession in its closure via useCallback(..., [pendingSession, clearPendingSession]). However, when onUnavailable fires from AccountModelPicker, it's called during render or in a useEffect triggered by error/empty state. If pendingSession arrives after the error has already been set (the 'after' timing in the test), accountUnavailable was created with pendingSession: null. The test works because the component re-renders when pendingSession changes, creating a new accountUnavailable closure, and the useEffect at line 41-43 re-fires when error is already set. But there's a subtle timing dependency: the onUnavailable effect depends on both error and onUnavailable (which changes when pendingSession changes). If onUnavailable changes but error stays the same, the effect re-runs and correctly calls the new closure. This works but is fragile.

if (!pendingSession || !accountSelection) return;
// Guard against double-consumption of the same pending session
const key = pendingSession.prompt;
if (pendingConsumed.current === key) return;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 bugs: The pendingConsumed ref guards duplicate sends by keying on pendingSession.prompt. If two successive pending sessions arrive with identical prompt text (e.g., two task launches with the same template), the second will be silently dropped. Consider using a unique key like a combination of prompt + telosTaskId or a counter instead. [fixable]

const env = { ...base };
for (const key of Object.keys(env)) {
if (
/^(ANTHROPIC_|OPENAI_|CLAUDE_CODE_USE_|CLAUDE_CODE_SKIP_|VERTEX_REGION_)/.test(key) ||

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The regex /^(ANTHROPIC_|OPENAI_|CLAUDE_CODE_USE_|CLAUDE_CODE_SKIP_|VERTEX_REGION_)/ strips env vars that are then re-set on lines 101-106. While correct (delete-then-reassign), the intent is not obvious to a reader. The CLAUDE_CODE_SKIP_* prefix is forward-looking — no such vars exist in the codebase today. Consider documenting the scrubbing intent or using an explicit allowlist of keys to preserve instead of a deletion regex. [fixable]

return new AccountProfiles(
JSON.parse(readFileSync(process.env.MITZO_ACCOUNT_PROFILES_FILE, 'utf8')),
);
} catch {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The catch block in loadAccountProfiles() at line 117 catches all errors (including JSON parse errors, file-not-found, and Zod validation failures) and replaces them with a generic message. For operator debugging, preserving the original error message or logging it server-side would help diagnose misconfigured profile files. [fixable]

@dimakis
dimakis merged commit 6155f8c into main Sep 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant