Skip to content

Commit 306c40e

Browse files
committed
fix: validate session control RPC results
1 parent bad4d74 commit 306c40e

4 files changed

Lines changed: 508 additions & 19 deletions

File tree

src/__tests__/session-control.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from 'bun:test';
2+
import { RpcResultValidationError } from '../index.js';
23
import { Agent } from '../sdk/agent.js';
34
import { AutohandSDK } from '../sdk/index.js';
45
import { RPCClient } from '../rpc/client.js';
@@ -293,3 +294,145 @@ describe('auto-mode control RPCs', () => {
293294
await expect(agent.getAutomodeLog(params)).resolves.toEqual(log);
294295
});
295296
});
297+
298+
describe('session control RPC result validation', () => {
299+
const malformedResults: Array<{
300+
name: string;
301+
method: string;
302+
result: unknown;
303+
expectedPath: string;
304+
invoke: (client: RPCClient) => Promise<unknown>;
305+
}> = [
306+
{
307+
name: 'reset results with a non-string session ID',
308+
method: 'autohand.reset',
309+
result: { sessionId: 42 },
310+
expectedPath: '$.sessionId',
311+
invoke: (client) => client.reset(),
312+
},
313+
{
314+
name: 'browser handoff creation results with a malformed URL',
315+
method: 'autohand.browserHandoff.create',
316+
result: {
317+
token: 'handoff-token',
318+
sessionId: 'session-browser',
319+
workspaceRoot: '/workspace',
320+
createdAt: '2026-07-20T00:00:00.000Z',
321+
expiresAt: '2026-07-20T00:10:00.000Z',
322+
url: 42,
323+
},
324+
expectedPath: '$.url',
325+
invoke: (client) => client.createBrowserHandoff(),
326+
},
327+
{
328+
name: 'browser handoff attachment results with a malformed optional count',
329+
method: 'autohand.browserHandoff.attach',
330+
result: { success: true, messageCount: 'three' },
331+
expectedPath: '$.messageCount',
332+
invoke: (client) => client.attachBrowserHandoff({ token: 'handoff-token' }),
333+
},
334+
{
335+
name: 'latest browser handoff results with a malformed success flag',
336+
method: 'autohand.browserHandoff.attachLatest',
337+
result: { success: 'yes' },
338+
expectedPath: '$.success',
339+
invoke: (client) => client.attachLatestBrowserHandoff(),
340+
},
341+
{
342+
name: 'auto-mode start results with a malformed optional error',
343+
method: 'autohand.automode.start',
344+
result: { success: false, error: 17 },
345+
expectedPath: '$.error',
346+
invoke: (client) => client.startAutomode({ prompt: 'Ship the SDK' }),
347+
},
348+
{
349+
name: 'auto-mode status results with an unknown nested status',
350+
method: 'autohand.automode.status',
351+
result: {
352+
active: true,
353+
paused: false,
354+
state: {
355+
sessionId: 'automode-session',
356+
status: 'queued',
357+
currentIteration: 1,
358+
maxIterations: 10,
359+
filesCreated: 0,
360+
filesModified: 1,
361+
},
362+
},
363+
expectedPath: '$.state.status',
364+
invoke: (client) => client.getAutomodeStatus(),
365+
},
366+
{
367+
name: 'auto-mode pause results with a malformed success flag',
368+
method: 'autohand.automode.pause',
369+
result: { success: 1 },
370+
expectedPath: '$.success',
371+
invoke: (client) => client.pauseAutomode(),
372+
},
373+
{
374+
name: 'auto-mode resume results with a malformed optional error',
375+
method: 'autohand.automode.resume',
376+
result: { success: false, error: { message: 'not paused' } },
377+
expectedPath: '$.error',
378+
invoke: (client) => client.resumeAutomode(),
379+
},
380+
{
381+
name: 'auto-mode cancellation results that are not objects',
382+
method: 'autohand.automode.cancel',
383+
result: null,
384+
expectedPath: '$',
385+
invoke: (client) => client.cancelAutomode(),
386+
},
387+
{
388+
name: 'auto-mode log results with a malformed nested checkpoint',
389+
method: 'autohand.automode.getLog',
390+
result: {
391+
success: true,
392+
iterations: [{
393+
iteration: 1,
394+
timestamp: '2026-07-20T00:01:00.000Z',
395+
actions: ['edited src/index.ts'],
396+
checkpoint: { commit: 17, message: 'iteration 1' },
397+
}],
398+
},
399+
expectedPath: '$.iterations[0].checkpoint.commit',
400+
invoke: (client) => client.getAutomodeLog({ limit: 1 }),
401+
},
402+
];
403+
404+
for (const malformed of malformedResults) {
405+
it(`rejects ${malformed.name}`, async () => {
406+
const client = new RPCClient();
407+
getTransport(client).request = async () => malformed.result;
408+
409+
try {
410+
await malformed.invoke(client);
411+
throw new Error('Expected the malformed RPC result to be rejected');
412+
} catch (error) {
413+
expect(error).toBeInstanceOf(RpcResultValidationError);
414+
if (error instanceof RpcResultValidationError) {
415+
expect(error.method).toBe(malformed.method);
416+
expect(error.path).toBe(malformed.expectedPath);
417+
}
418+
}
419+
});
420+
}
421+
422+
it('accepts omitted optional result fields', async () => {
423+
const client = new RPCClient();
424+
const results = [
425+
{ success: false },
426+
{ active: false, paused: false },
427+
];
428+
getTransport(client).request = async () => results.shift();
429+
430+
await expect(
431+
client.attachBrowserHandoff({ token: 'missing-handoff' })
432+
).resolves.toEqual({ success: false });
433+
await expect(client.getAutomodeStatus()).resolves.toEqual({
434+
active: false,
435+
paused: false,
436+
});
437+
});
438+
});

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ export { AutohandSDK, formatSlashCommand } from './sdk/index.js';
4848
*/
4949
export { RPCClient } from './rpc/client.js';
5050

51+
/**
52+
* Structured errors raised when a CLI session-control result violates its RPC schema.
53+
*/
54+
export { RpcResultValidationError } from './validation/session-control-rpc-results.js';
55+
export type { SessionControlRpcMethod } from './validation/session-control-rpc-results.js';
56+
5157
/**
5258
* Transport layer for CLI subprocess communication
5359
*/

src/rpc/client.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import type {
9595
McpGetServerConfigsResult,
9696
} from '../types/index.js';
9797
import { detectProviderFromModel, validateProviderConfig, getSkillName, getSkillPath } from '../types/index.js';
98+
import { validateSessionControlRpcResult } from '../validation/session-control-rpc-results.js';
9899

99100
function scopedDecision(
100101
allowed: boolean,
@@ -338,7 +339,8 @@ export class RPCClient {
338339
* Reset the current conversation and begin a new CLI session.
339340
*/
340341
async reset(): Promise<ResetResult> {
341-
return this.transport.request('autohand.reset', {}) as Promise<ResetResult>;
342+
const result = await this.transport.request('autohand.reset', {});
343+
return validateSessionControlRpcResult('autohand.reset', result);
342344
}
343345

344346
/**
@@ -367,10 +369,11 @@ export class RPCClient {
367369
async createBrowserHandoff(
368370
params: BrowserHandoffCreateParams = {}
369371
): Promise<BrowserHandoffCreateResult> {
370-
return this.transport.request(
372+
const result = await this.transport.request(
371373
'autohand.browserHandoff.create',
372374
params
373-
) as Promise<BrowserHandoffCreateResult>;
375+
);
376+
return validateSessionControlRpcResult('autohand.browserHandoff.create', result);
374377
}
375378

376379
/**
@@ -379,60 +382,66 @@ export class RPCClient {
379382
async attachBrowserHandoff(
380383
params: BrowserHandoffAttachParams
381384
): Promise<BrowserHandoffAttachResult> {
382-
return this.transport.request(
385+
const result = await this.transport.request(
383386
'autohand.browserHandoff.attach',
384387
params
385-
) as Promise<BrowserHandoffAttachResult>;
388+
);
389+
return validateSessionControlRpcResult('autohand.browserHandoff.attach', result);
386390
}
387391

388392
/**
389393
* Attach the newest unexpired browser handoff.
390394
*/
391395
async attachLatestBrowserHandoff(): Promise<BrowserHandoffAttachResult> {
392-
return this.transport.request(
396+
const result = await this.transport.request(
393397
'autohand.browserHandoff.attachLatest',
394398
{}
395-
) as Promise<BrowserHandoffAttachResult>;
399+
);
400+
return validateSessionControlRpcResult('autohand.browserHandoff.attachLatest', result);
396401
}
397402

398403
/**
399404
* Start an autonomous auto-mode session.
400405
*/
401406
async startAutomode(params: AutomodeStartParams): Promise<AutomodeStartResult> {
402-
return this.transport.request(
407+
const result = await this.transport.request(
403408
'autohand.automode.start',
404409
params
405-
) as Promise<AutomodeStartResult>;
410+
);
411+
return validateSessionControlRpcResult('autohand.automode.start', result);
406412
}
407413

408414
/**
409415
* Get the current auto-mode runtime and persisted state.
410416
*/
411417
async getAutomodeStatus(): Promise<AutomodeStatusResult> {
412-
return this.transport.request(
418+
const result = await this.transport.request(
413419
'autohand.automode.status',
414420
{}
415-
) as Promise<AutomodeStatusResult>;
421+
);
422+
return validateSessionControlRpcResult('autohand.automode.status', result);
416423
}
417424

418425
/**
419426
* Pause the active auto-mode session.
420427
*/
421428
async pauseAutomode(): Promise<AutomodeOperationResult> {
422-
return this.transport.request(
429+
const result = await this.transport.request(
423430
'autohand.automode.pause',
424431
{}
425-
) as Promise<AutomodeOperationResult>;
432+
);
433+
return validateSessionControlRpcResult('autohand.automode.pause', result);
426434
}
427435

428436
/**
429437
* Resume a paused auto-mode session.
430438
*/
431439
async resumeAutomode(): Promise<AutomodeOperationResult> {
432-
return this.transport.request(
440+
const result = await this.transport.request(
433441
'autohand.automode.resume',
434442
{}
435-
) as Promise<AutomodeOperationResult>;
443+
);
444+
return validateSessionControlRpcResult('autohand.automode.resume', result);
436445
}
437446

438447
/**
@@ -441,10 +450,11 @@ export class RPCClient {
441450
async cancelAutomode(
442451
params: AutomodeCancelParams = {}
443452
): Promise<AutomodeOperationResult> {
444-
return this.transport.request(
453+
const result = await this.transport.request(
445454
'autohand.automode.cancel',
446455
params
447-
) as Promise<AutomodeOperationResult>;
456+
);
457+
return validateSessionControlRpcResult('autohand.automode.cancel', result);
448458
}
449459

450460
/**
@@ -453,10 +463,11 @@ export class RPCClient {
453463
async getAutomodeLog(
454464
params: AutomodeGetLogParams = {}
455465
): Promise<AutomodeGetLogResult> {
456-
return this.transport.request(
466+
const result = await this.transport.request(
457467
'autohand.automode.getLog',
458468
params
459-
) as Promise<AutomodeGetLogResult>;
469+
);
470+
return validateSessionControlRpcResult('autohand.automode.getLog', result);
460471
}
461472

462473
/**

0 commit comments

Comments
 (0)