Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/shared/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 });
} });
Expand Down
23 changes: 18 additions & 5 deletions src/shared/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export class MachineSessionService implements SessionService {
private readonly store: CredentialStore | null;
private readonly env: NodeJS.ProcessEnv;
private readonly renewals = new Map<string, Promise<MachineSession>>();
/** Each store read runs two Keychain programs, so one process reads a backend's session once. */
private readonly cached = new Map<string, MachineSession | null>();
private readonly secrets = new Set<string>();
constructor(private readonly host: HostServices, private readonly options: {
store?: CredentialStore | null;
Expand Down Expand Up @@ -93,17 +95,27 @@ 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<MachineSession | null> {
private async saved(backend: string, fresh = false): Promise<MachineSession | null> {
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
? 'The saved entry belongs to an older tool. Sign in to replace it.'
: '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<MachineSession | null> {
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.');
Expand Down Expand Up @@ -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 }); }
Expand All @@ -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.');
Expand Down Expand Up @@ -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<T>(backend, method, apiPath, body, retry.access, options?.timeoutMs); }
catch (retryError) {
Expand Down Expand Up @@ -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<SessionStatus> {
Expand Down
7 changes: 6 additions & 1 deletion tests/agents.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions tests/session.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
Loading