From b662912b6a4d443157b78504103f64b3b8450e37 Mon Sep 17 00:00:00 2001 From: Jose Antonio Alatorre Date: Thu, 22 Oct 2026 10:39:57 +0200 Subject: [PATCH] Use the saved account for agent actions and read the Keychain once per process Agent actions checked the account with GET /api/v1/users/me/ on every call, although sign-in already saves the account name with the session. They now take the saved name and ask the backend only when an older session has none. Each session read ran two /usr/bin/security programs, and one agent search read the session eight times. MachineSessionService now reads a backend's session once per process and rereads it only to renew or to retry a rejected token. A reread that finds a different account fails instead of switching accounts under the running action. Measured against the local backend: an agent search drops from 16 Keychain programs and a users/me round trip (about 1.2 s) to 2 programs and none. Co-Authored-By: Claude Opus 5.5 --- src/shared/agents.ts | 6 ++++-- src/shared/session.ts | 23 ++++++++++++++++++----- tests/agents.test.cjs | 7 ++++++- tests/session.test.cjs | 16 ++++++++++++++++ 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/shared/agents.ts b/src/shared/agents.ts index 74d7be8..99a636b 100644 --- a/src/shared/agents.ts +++ b/src/shared/agents.ts @@ -53,7 +53,9 @@ export class AgentClient { const backend = requireBackend(selectedBackend), environmentUid = uuid(input.environmentUid); this.options.signal?.throwIfAborted(); if (!isAgentAction(input.action)) throw new ToolingError('INVALID_REQUEST', 'Unknown agent action.'); - const identity = await this.session.status(backend, true); + // Sign-in saved the account with the session; asking the backend again costs a round trip on every action. + let identity = await this.session.status(backend); + if (identity.authenticated && !identity.username) identity = await this.session.status(backend, true); if (!identity.authenticated || !identity.username) throw new ToolingError('NOT_AUTHENTICATED', 'Sign in to MainSequence to use agents.'); if (input.expectedAccount !== undefined && input.expectedAccount !== identity.username) throw new ToolingError('AUTH_REJECTED', 'The MainSequence account changed. Refresh agents and start a new conversation.'); if (['agent-session', 'agent-readiness', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(input.action) && !input.expectedAccount) { @@ -125,7 +127,7 @@ export class AgentClient { await waitForAgentReadiness({ signal: this.options.signal, progress: this.options.progress, timeoutMs, resolve: async deadline => { readinessDeadline = deadline; - const account = await this.session.status(backend, true); + const account = await this.session.status(backend); if (!account.authenticated || account.username !== identity.username) throw new ToolingError('AUTH_REJECTED', 'The MainSequence account changed while checking the agent. Choose the agent again.'); return call('agent_session.resolve_runtime_access', { agent_session_uid: sessionUid }); } }); diff --git a/src/shared/session.ts b/src/shared/session.ts index 21772d7..d732858 100644 --- a/src/shared/session.ts +++ b/src/shared/session.ts @@ -56,6 +56,8 @@ export class MachineSessionService implements SessionService { private readonly store: CredentialStore | null; private readonly env: NodeJS.ProcessEnv; private readonly renewals = new Map>(); + /** Each store read runs two Keychain programs, so one process reads a backend's session once. */ + private readonly cached = new Map(); private readonly secrets = new Set(); constructor(private readonly host: HostServices, private readonly options: { store?: CredentialStore | null; @@ -93,10 +95,13 @@ export class MachineSessionService implements SessionService { if (!this.store) throw new ToolingError('STORE_UNAVAILABLE', 'A machine credential store is required: macOS Keychain or Linux Secret Service with secret-tool.'); return this.store; } - private async saved(backend: string): Promise { + private async saved(backend: string, fresh = false): Promise { + if (!fresh && this.cached.has(backend)) return this.cached.get(backend)!; try { const value = this.options.persistence ? await this.options.persistence.read(backend) : readSession(this.requireStore(), backend); - return value ? this.remember(value) : null; + const session = value ? this.remember(value) : null; + this.cached.set(backend, session); + return session; } catch (error) { if (error instanceof ToolingError) throw error; throw new ToolingError('STORE_ERROR', error instanceof ForeignEntryError @@ -104,6 +109,13 @@ export class MachineSessionService implements SessionService { : 'The machine session could not be read. Check that the credential store is unlocked and available.'); } } + /** Another process may have rotated the pair since this one read it, but never switches the account under it. */ + private async reread(backend: string): Promise { + const known = this.cached.get(backend)?.username; + const latest = await this.saved(backend, true); + if (known && latest?.username && latest.username !== known) throw new ToolingError('AUTH_REJECTED', 'The MainSequence account changed in another process. Run the action again.'); + return latest; + } private environment(backend: string): MachineSession | null { if (this.env.MAINSEQUENCE_RUNTIME_CREDENTIAL_ID || this.env.MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET) { throw new ToolingError('UNSUPPORTED_ACTION', 'Runtime credentials are not a developer session. Use a separate developer process.'); @@ -154,7 +166,7 @@ export class MachineSessionService implements SessionService { if (running) return running; const task = (async () => { // Bypass cached state: another SDK process may have rotated the refresh token. - const previous = await this.saved(backend); + const previous = await this.reread(backend); if (!previous?.refresh) throw new ToolingError('NOT_AUTHENTICATED', 'No refreshable machine session is saved for this backend.'); let data: unknown; try { data = await this.http(backend, 'POST', '/auth/jwt-token/token/refresh/', { refresh: previous.refresh }); } @@ -174,7 +186,7 @@ export class MachineSessionService implements SessionService { try { if (this.options.persistence) await this.options.persistence.write(backend, session); else saveSession(this.requireStore(), backend, session); - const saved = await this.saved(backend); + const saved = await this.saved(backend, true); if (!saved || saved.access !== session.access || saved.refresh !== session.refresh) throw new Error('verification failed'); } catch { throw new ToolingError('PERSISTENCE_FAILED', 'Authorization completed but the machine session could not be saved and verified.'); @@ -218,7 +230,7 @@ export class MachineSessionService implements SessionService { if (!(error instanceof ToolingError) || error.code !== 'AUTH_REJECTED') throw error; if (source === 'environment') throw refusedCredentials('environment', backend, session.access); if (!retrySafe) throw error; - const latest = await this.saved(backend); + const latest = await this.reread(backend); const retry = latest?.access && latest.access !== session.access ? latest : await this.renew(backend); try { return await this.http(backend, method, apiPath, body, retry.access, options?.timeoutMs); } catch (retryError) { @@ -246,6 +258,7 @@ export class MachineSessionService implements SessionService { if (this.options.signal?.aborted) throw new ToolingError('CANCELLED', 'The action was cancelled.'); try { if (this.options.persistence) await this.options.persistence.remove(backend); else clearSession(this.requireStore(), backend); } catch { throw new ToolingError('STORE_ERROR', 'The machine session could not be removed.'); } + finally { this.cached.delete(backend); } return { revoked }; } async login(input: string): Promise { diff --git a/tests/agents.test.cjs b/tests/agents.test.cjs index 3c973cd..b5dbfe4 100644 --- a/tests/agents.test.cjs +++ b/tests/agents.test.cjs @@ -10,7 +10,7 @@ const row = () => ({ uid: agentUid, name: 'Analyst', description: 'Analyze risk' function fixture(options = {}) { const calls = [], state = {account:'person@example.test',row:row(),catalog:['agent.list','agent.search','agent.get','agent.get_or_create_session','agent_session.resolve_runtime_access','a2a.send_message','a2a.get_task','a2a.wait_task','a2a.cancel_task']}; const task = () => ({agent_uid:agentUid,agent_session_uid:sessionUid,task_uid:taskUid,status:'working',terminal:false,event_cursor:2,outputs:[],cancellation_requested:false}); - const session = {status:async (url,check)=>{assert.equal(url,backend);assert.equal(check,true);return {authenticated:true,username:state.account}},sanitize:s=>s.replaceAll('known-secret','[redacted]'), + const session = {status:async (url,check)=>{assert.equal(url,backend);state.checks=(state.checks||0)+(check?1:0);return {authenticated:true,username:check||!state.unsavedUsername?state.account:undefined}},sanitize:s=>s.replaceAll('known-secret','[redacted]'), request:async(url,method,path,body,retrySafe,options)=>{ calls.push({url,method,path,body,retrySafe,options});assert.equal(path,'/mcp');assert.equal(url,backend); const reply = result=>({jsonrpc:'2.0',id:body.id,result}); @@ -47,6 +47,11 @@ test('agent discovery sends environment and pagination and projects only public const search=await f.dispatch('agents-list',{search:'exposure'});assert.equal(search.data.semantic,true);assert.equal(search.data.count,1); assert.equal(f.calls.find(c=>c.body.params?.name==='agent.search').body.params.arguments.organization_environment_uid,environmentUid); }); +test('discovery takes the account saved at sign-in and checks the backend only when none was saved',async()=>{ + const f=fixture();await f.dispatch('agents-list');await f.dispatch('agents-list',{search:'exposure'});assert.equal(f.state.checks||0,0); + const legacy=fixture();legacy.state.unsavedUsername=true;const result=await legacy.dispatch('agents-list'); + assert.equal(result.data.account,'person@example.test');assert.equal(legacy.state.checks,1); +}); test('mixed-environment results are refused instead of displayed',async()=>{ const f=fixture();f.state.row.organization_environment_uid=id(9);assert.equal((await f.dispatch('agents-list')).status,'failure'); assert.equal((await f.send()).status,'failure');assert.equal(f.calls.filter(c=>c.body.params?.name==='a2a.send_message').length,0); diff --git a/tests/session.test.cjs b/tests/session.test.cjs index 7b385e3..09d369f 100644 --- a/tests/session.test.cjs +++ b/tests/session.test.cjs @@ -62,6 +62,22 @@ test('renewal rereads a session written by another SDK and deduplicates concurre assert.equal(readSession(store, backend).username, 'shared'); }); +test('one process reads the saved session once and never switches account on a reread', async t => { + const { backend, calls } = await fixtureBackend(t); + const store = memoryStore(); let reads = 0; const read = store.read; store.read = (...args) => { reads++; return read(...args); }; + saveSession(store, backend, { username: 'José', access: 'fixture-access-1', refresh: 'fixture-refresh-1' }); + const service = new MachineSessionService(createNodeHost(), { env: {}, store }); + assert.equal((await service.status(backend)).username, 'José'); + for (let i = 0; i < 3; i++) await service.request(backend, 'GET', '/api/v1/code-repositories/'); + assert.equal(reads, 1); + const switched = new MachineSessionService(createNodeHost(), { env: {}, store }); + saveSession(store, backend, { username: 'José', access: 'stale', refresh: 'fixture-refresh-1' }); + await switched.status(backend); + saveSession(store, backend, { username: 'someone-else', access: 'fixture-access-1', refresh: 'other-refresh' }); + await assert.rejects(switched.request(backend, 'GET', '/api/v1/code-repositories/'), error => error.code === 'AUTH_REJECTED' && /account changed/.test(error.message)); + assert.equal(calls.filter(call => call.authorization === 'Bearer fixture-access-1' && call.path === '/api/v1/code-repositories/').length, 3); +}); + test('401 rereads a newer machine access token and does not unnecessarily renew', async t => { const fixture = await fixtureBackend(t); const store = memoryStore(); saveSession(store, fixture.backend, { username: '', access: 'stale', refresh: 'r' });