diff --git a/src/@types/types.d.ts b/src/@types/types.d.ts index 2d29866..483ba52 100644 --- a/src/@types/types.d.ts +++ b/src/@types/types.d.ts @@ -244,6 +244,7 @@ export interface ActiveSession { readonly compliant: boolean; reconnecting?: Promise; skillState: SkillFireState; + secretVisible: boolean; lastUsedAt: number; lastUrl?: string; lastElements?: Map; diff --git a/src/lib/agent-client.ts b/src/lib/agent-client.ts index b93bef5..273e3e3 100644 --- a/src/lib/agent-client.ts +++ b/src/lib/agent-client.ts @@ -226,22 +226,8 @@ export class PersonaConflictError extends Error { // stacks more lingering sessions against the same limit, so stop instead. const NON_RETRYABLE_UPGRADE_STATUSES = new Set([400, 401, 403, 404, 429]); -class SessionReuseError extends Error {} - -const assertCompatibleRecordingMode = ( - session: ActiveSession, - record: boolean | undefined, -): void => { - if (record !== undefined && record !== (session.record ?? false)) { - throw new SessionReuseError( - 'Browser recording mode cannot be changed on an open session. Omit record to reuse it, or close the session before changing the record option.', - ); - } -}; - export const isRetryableUpgradeError = (err: unknown): boolean => { if (err instanceof PersonaConflictError) return false; - if (err instanceof SessionReuseError) return false; if (err instanceof UpgradeError) { // A 2xx UpgradeError is a structurally-bad success response — retrying // can't fix the shape (and may duplicate side effects), so don't. @@ -442,6 +428,7 @@ export const buildAgentWsUrl = ( // guards already reject them, but hard-drop here too so no caller path can // put them on the wire (last line of defense before the upstream connect). if (!compliant) { + if (proxy) ProxyOptionsSchema.parse(proxy); if (proxy?.proxy) url.searchParams.set('proxy', proxy.proxy); if (proxy?.proxyCountry) url.searchParams.set('proxyCountry', proxy.proxyCountry); @@ -487,6 +474,70 @@ export const buildAgentWsUrl = ( return url.toString(); }; +interface AgentCapability { + available?: boolean; + availableAt?: string[]; +} + +interface AgentCapabilityManifest { + version: number; + route: string; + capabilities: Record; +} + +/** Validate declared plan requirements before opening a browser session. */ +export const preflightAgentCapabilities = async ( + agentUrl: string, + required: string[], +): Promise => { + if (required.length === 0) return; + + const url = new URL(agentUrl); + url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:'; + url.pathname += '/capabilities'; + + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) { + await res.body?.cancel().catch(() => {}); + throw new Error( + `Capability discovery failed on /chromium/agent (${res.status}). Verify the token, plan, and route parameters.`, + ); + } + + let manifest: AgentCapabilityManifest; + try { + manifest = (await res.json()) as AgentCapabilityManifest; + } catch { + throw new Error('Capability discovery returned invalid JSON.'); + } + if ( + manifest?.version !== 1 || + typeof manifest.route !== 'string' || + !manifest.capabilities || + typeof manifest.capabilities !== 'object' || + Array.isArray(manifest.capabilities) + ) { + throw new Error('Capability discovery returned an unsupported manifest.'); + } + + const missing = required.filter( + (name) => manifest.capabilities[name]?.available !== true, + ); + if (missing.length === 0) return; + + const details = missing.map((name) => { + const availableAt = manifest.capabilities[name]?.availableAt; + return Array.isArray(availableAt) && + availableAt.length > 0 && + availableAt.every((route) => typeof route === 'string') + ? `${name} (available on ${availableAt.join(', ')})` + : `${name} (not advertised by this endpoint)`; + }); + throw new Error( + `Invalid parameters: required capabilities are unavailable on ${manifest.route}: ${details.join('; ')}.`, + ); +}; + // HTTP-status failures arrive on `unexpected-response` (typed as // UpgradeError), so a 1006 close here only means a transport failure or a // server crash before any HTTP response. @@ -974,7 +1025,6 @@ export const getOrCreateSession = async ( existing.ws.readyState === WebSocket.OPEN && existing.source === source ) { - assertCompatibleRecordingMode(existing, record); existing.lastUsedAt = Date.now(); onSession?.(true, Math.max(0, Date.now() - createdAt.get(existing)!)); return existing; @@ -1071,6 +1121,7 @@ export const getOrCreateSession = async ( persona: effectivePersona, record, skillState: createSkillState(), + secretVisible: false, lastUsedAt: Date.now(), }; createdAt.set(session, Date.now()); diff --git a/src/tools/agent.ts b/src/tools/agent.ts index ff7f2fe..94a6e68 100644 --- a/src/tools/agent.ts +++ b/src/tools/agent.ts @@ -12,6 +12,7 @@ import { type StoredDownload, } from '../lib/download-store.js'; import { + buildAgentWsUrl, getOrCreateSession, send, closeSession, @@ -19,6 +20,7 @@ import { isRetryableUpgradeError, PERSONA_FIELDS, UpgradeError, + preflightAgentCapabilities, } from '../lib/agent-client.js'; import type { AgentParams, @@ -88,6 +90,51 @@ export { const SNAPSHOT_METHOD = 'snapshot'; const FATAL_CODES = new Set(['BROWSER_CRASHED']); +const SECRET_SAFE_METHODS = new Set([ + 'click', + 'type', + 'select', + 'checkbox', + 'hover', + 'scroll', + 'waitForSelector', + 'waitForTimeout', + 'uploadFile', + 'saveProfile', + 'reportSkillOutcome', + 'close', +]); +const TOP_FRAME_NAVIGATION_METHODS = new Set([ + 'goto', + 'back', + 'forward', + 'reload', +]); + +export const validateSecretCaptureOrdering = ( + commands: ReadonlyArray<{ + method: string; + params?: Record; + }>, + initiallyVisible = false, +): void => { + let secretVisible = initiallyVisible; + for (const command of commands) { + if (command.method === 'loadSecret') { + secretVisible = true; + } else if ( + command.method === 'clearSecrets' || + TOP_FRAME_NAVIGATION_METHODS.has(command.method) + ) { + secretVisible = false; + } else if (secretVisible && !SECRET_SAFE_METHODS.has(command.method)) { + throw new Error( + `${command.method} cannot run after loadSecret while a credential may be on-screen. ` + + `Move the capture before loadSecret, or run clearSecrets or a top-frame navigation first.`, + ); + } + } +}; const appendSkills = ( base: string, @@ -883,6 +930,43 @@ export function registerAgentTools( return [{ type: 'text' as const, text: 'Browser session closed.' }]; } + try { + validateSecretCaptureOrdering(commands); + } catch (err) { + lastCategory = 'INVALID_PARAMS'; + sendAnalytics(false, err); + throw new UserError( + err instanceof Error + ? err.message + : 'Secret capture preflight failed.', + ); + } + + try { + await preflightAgentCapabilities( + buildAgentWsUrl( + apiUrl, + token, + proxy, + profile, + attachSessionId, + compliant, + integrationId, + allowedDomains, + emulationOs, + humanlike, + record, + persona, + ), + params.requiredCapabilities ?? [], + ); + } catch (err) { + sendAnalytics(false, err); + throw new UserError( + err instanceof Error ? err.message : 'Capability preflight failed.', + ); + } + // Open-only call: no real command (e.g. `createProfile`/`profile`/`proxy` // set with no method/commands). Dispatching the empty-method default would // make the agent route reject it as `Missing required id/method`, so just @@ -1010,6 +1094,17 @@ export function registerAgentTools( ? { failed_method: cmd.method } : {}), }); + try { + validateSecretCaptureOrdering([cmd], agentSession.secretVisible); + } catch (err) { + lastCategory = 'INVALID_PARAMS'; + throw new UserError( + err instanceof Error + ? err.message + : 'Secret capture preflight failed.', + ); + } + if (cmd.method === 'close') { closeSession( mcpSessionId, @@ -1198,6 +1293,18 @@ export function registerAgentTools( ); } + if (cmd.method === 'loadSecret') { + agentSession.secretVisible = true; + } else if ( + cmd.method === 'clearSecrets' || + (TOP_FRAME_NAVIGATION_METHODS.has(cmd.method) && + resp.result !== null && + typeof resp.result === 'object' && + (resp.result as { rejected?: unknown }).rejected !== true) + ) { + agentSession.secretVisible = false; + } + // Capture the first URL we observe in the batch as a fallback // baseline for the cross-origin notice. if (!crossOriginBaseline) { diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index a6d0522..266c942 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -939,6 +939,16 @@ const withAgentInvariants = >(schema: T) => '`profile` (hydrate an existing profile) and `createProfile` (author a new ' + 'one) cannot both be set', }) + .refine( + ({ method, params, commands }) => + (Array.isArray(commands) && commands.length > 0) || + method !== 'clearSecrets' || + ClearSecretsCommandSchema.safeParse({ method, params }).success, + { + message: '`clearSecrets` does not accept parameters', + path: ['params'], + }, + ) .refine(refineRecordCreateProfile, { message: 'Recording cannot be armed during profile creation. Create and save the profile first, then start a new browser session with `profile` and `record: true`.', @@ -968,6 +978,14 @@ const withAgentInvariants = >(schema: T) => ); const agentParamsObject = z.object({ + requiredCapabilities: z + .array(z.string().trim().min(1)) + .optional() + .describe( + 'Capabilities the planned flow requires (for example "vision", "os-spoofing", ' + + '"datacenter-proxy", or "secret-capture"). Browserless checks the selected ' + + 'route and plan before opening a browser and names an available route on failure.', + ), method: z .string() .optional() @@ -1136,6 +1154,7 @@ const COMPLIANT_COMMANDS_DESCRIPTION = // Shared top-level fields for both compliant schemas (rich + slim projection) // so they can't drift; `.strict()` on each rejects any prohibited/removed key. const compliantParamsObject = z.object({ + requiredCapabilities: agentParamsObject.shape.requiredCapabilities, rationale: z .string() .optional() diff --git a/test/lib/agent-client.spec.ts b/test/lib/agent-client.spec.ts index ea0d025..cc961fb 100644 --- a/test/lib/agent-client.spec.ts +++ b/test/lib/agent-client.spec.ts @@ -8,6 +8,7 @@ import { isRetryableUpgradeError, PersonaConflictError, ProfileNotFoundError, + preflightAgentCapabilities, proxyFingerprint, sessionHandle, dropMcpSession, @@ -47,6 +48,15 @@ describe('agent-client reconnection telemetry', () => { }); describe('agent-client buildAgentWsUrl', () => { + it('rejects a residential preset with a datacenter proxy', () => { + expect(() => + buildAgentWsUrl('https://host', 'tok', { + proxy: 'datacenter', + proxyPreset: 'px_amazon01', + }), + ).to.throw(); + }); + // A base carrying a query used to concatenate into path `/` — the raw CDP // socket — so every agent method came back as -32601 "wasn't found". it('ignores a query string on the configured api url', () => { @@ -476,6 +486,141 @@ describe('agent-client buildAgentWsUrl', () => { }); }); +describe('agent-client capability preflight', () => { + const agentUrl = (proxy?: ProxyOptions, os?: string) => + buildAgentWsUrl( + 'https://production.example.com', + 'tok', + proxy, + undefined, + undefined, + false, + undefined, + undefined, + os, + ); + + afterEach(() => sinon.restore()); + + it('does no discovery request for normal flows without requirements', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch'); + + await preflightAgentCapabilities(agentUrl(), []); + + expect(fetchStub.called).to.equal(false); + }); + + it('passes intended route parameters and accepts supported capabilities', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + version: 1, + route: '/chromium/agent', + capabilities: { vision: { available: true } }, + }), + ), + ); + + await preflightAgentCapabilities( + agentUrl({ proxy: 'datacenter' }, 'macos'), + ['vision'], + ); + + const url = new URL(fetchStub.firstCall.args[0] as string); + expect(url.pathname).to.equal('/chromium/agent/capabilities'); + expect(url.searchParams.get('token')).to.equal('tok'); + expect(url.searchParams.get('proxy')).to.equal('datacenter'); + expect(url.searchParams.get('emulationOs')).to.equal('macos'); + }); + + it('fails with the missing capability and the route where it is available', async () => { + sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + version: 1, + route: '/chromium/agent', + capabilities: { + 'os-spoofing': { + available: false, + availableAt: ['/stealth/bql'], + }, + }, + }), + ), + ); + + try { + await preflightAgentCapabilities(agentUrl(), ['os-spoofing']); + expect.fail('expected capability preflight to fail'); + } catch (err) { + expect((err as Error).message).to.include('os-spoofing'); + expect((err as Error).message).to.include('/chromium/agent'); + expect((err as Error).message).to.include('/stealth/bql'); + } + }); + + it('reports HTTP plan failures without reflecting the response body', async () => { + sinon + .stub(globalThis, 'fetch') + .resolves(new Response('internal upstream detail', { status: 403 })); + + try { + await preflightAgentCapabilities(agentUrl(), ['vision']); + expect.fail('expected capability preflight to fail'); + } catch (err) { + expect((err as Error).message).to.include('403'); + expect((err as Error).message).to.include('plan'); + expect((err as Error).message).to.not.include('internal upstream detail'); + } + }); + + it('rejects an array-valued capability map', async () => { + sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + version: 1, + route: '/chromium/agent', + capabilities: [], + }), + ), + ); + + try { + await preflightAgentCapabilities(agentUrl(), ['vision']); + expect.fail('expected capability preflight to fail'); + } catch (err) { + expect((err as Error).message).to.include('unsupported manifest'); + } + }); + + it('rejects invalid JSON and tolerates malformed route alternatives', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch'); + const rejectsWith = async (expected: string) => { + try { + await preflightAgentCapabilities(agentUrl(), ['vision']); + expect.fail('expected capability preflight to fail'); + } catch (err) { + expect((err as Error).message).to.include(expected); + } + }; + fetchStub.onFirstCall().resolves(new Response('{')); + fetchStub.onSecondCall().resolves( + new Response( + JSON.stringify({ + version: 1, + route: '/chromium/agent', + capabilities: { + vision: { available: false, availableAt: '/stealth/bql' }, + }, + }), + ), + ); + + await rejectsWith('invalid JSON'); + await rejectsWith('not advertised'); + }); +}); + describe('agent-client proxyFingerprint', () => { it('returns empty string for undefined', () => { expect(proxyFingerprint(undefined)).to.equal(''); diff --git a/test/tools/agent.spec.ts b/test/tools/agent.spec.ts index 2553afa..58b05b8 100644 --- a/test/tools/agent.spec.ts +++ b/test/tools/agent.spec.ts @@ -16,6 +16,7 @@ import { buildSkillEventProps, registerAgentTools, sanitizeUpgradeBody, + validateSecretCaptureOrdering, } from '../../src/tools/agent.js'; import { fileTransferModeNote } from '../../src/skills/system-prompt.js'; import { mkdtemp, readFile as fsReadFile, writeFile } from 'node:fs/promises'; @@ -93,6 +94,41 @@ const mockContext = { elicit: sinon.stub().resolves({ action: 'cancel' }), }; +describe('agent secret-capture preflight', () => { + it('rejects a screenshot after loadSecret before any command runs', () => { + try { + validateSecretCaptureOrdering([ + { method: 'loadSecret', params: { ref: 'password' } }, + { method: 'screenshot', params: {} }, + ]); + expect.fail('expected secret-capture preflight to fail'); + } catch (err) { + expect((err as Error).message).to.include('screenshot'); + expect((err as Error).message).to.include('clearSecrets'); + } + }); + + it('allows capture after clearSecrets or navigation', () => { + for (const method of ['clearSecrets', 'goto']) { + expect(() => + validateSecretCaptureOrdering([ + { method: 'loadSecret', params: { ref: 'password' } }, + { method, params: {} }, + { method: 'screenshot', params: {} }, + ]), + ).not.to.throw(); + } + }); + + it('rejects unclassified readbacks after loadSecret', () => { + for (const method of ['getTabs', 'querySelectorAll', 'title', 'url']) { + expect(() => + validateSecretCaptureOrdering([{ method: 'loadSecret' }, { method }]), + ).to.throw(`${method} cannot run after loadSecret`); + } + }); +}); + describe('browserless_skill tool', () => { let server: FastMCP; let addToolSpy: sinon.SinonSpy; @@ -1073,6 +1109,193 @@ describe('browserless_agent recording ownership', () => { describe('browserless_agent integration binding guard', () => { afterEach(() => sinon.restore()); + it('fails a missing declared capability before opening a WebSocket', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + version: 1, + route: '/chromium/agent', + capabilities: { + vision: { + available: false, + availableAt: ['/stealth/bql'], + }, + }, + }), + ), + ); + const execute = getAgentExecute('http://127.0.0.1:1'); + + try { + await execute( + { + method: 'snapshot', + requiredCapabilities: ['vision'], + profile: 'login-profile', + integrationId: 'op_int_a', + allowedDomains: ['example.com'], + emulationOs: 'macos', + screen: '1920x1080', + record: true, + }, + { ...mockContext, sessionId: 'capability-guard' }, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.include('vision'); + expect((err as Error).message).to.include('/stealth/bql'); + } + expect(fetchStub.calledOnce).to.equal(true); + const url = new URL(fetchStub.firstCall.args[0] as string); + expect(url.searchParams.get('profile')).to.equal('login-profile'); + expect(url.searchParams.get('integrationId')).to.equal('op_int_a'); + expect(url.searchParams.get('allowedDomains')).to.equal('["example.com"]'); + expect(url.searchParams.get('emulationOs')).to.equal('macos'); + expect(url.searchParams.get('screen')).to.equal('1920x1080'); + expect(url.searchParams.get('record')).to.equal('true'); + }); + + it('allows closing a session when capability discovery is unavailable', async () => { + const fetchStub = sinon + .stub(globalThis, 'fetch') + .rejects(new Error('down')); + const execute = getAgentExecute('http://127.0.0.1:1'); + + const result = (await execute( + { + method: 'close', + requiredCapabilities: ['vision'], + }, + { ...mockContext, sessionId: 'capability-close' }, + )) as { content: Content[] }; + + expect((result.content[0] as { text: string }).text).to.equal( + 'Browser session closed.', + ); + expect(fetchStub.called).to.equal(false); + }); + + it('rejects proxy commands before capability discovery', async () => { + const fetchStub = sinon + .stub(globalThis, 'fetch') + .rejects(new Error('down')); + const execute = getAgentExecute('http://127.0.0.1:1'); + + try { + await execute( + { + commands: [{ method: 'proxy' }], + requiredCapabilities: ['vision'], + }, + { ...mockContext, sessionId: 'invalid-proxy-command' }, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.include( + '"proxy" is not a BQL mutation', + ); + } + expect(fetchStub.called).to.equal(false); + }); + + it('rejects an unsafe secret-capture batch before opening a WebSocket', async () => { + const srv = await makeRespondingServer(() => ({})); + try { + const execute = getAgentExecute(srv.url); + try { + await execute( + { + commands: [ + { method: 'loadSecret', params: { ref: 'password' } }, + { method: 'screenshot' }, + ], + }, + { ...mockContext, sessionId: 'secret-capture-batch' }, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.include( + 'screenshot cannot run after loadSecret', + ); + } + expect(srv.hits()).to.equal(0); + } finally { + await srv.close(); + } + }); + + it('rejects capture in a later call reusing a secret-bearing session', async () => { + const srv = await makeRespondingServer(() => ({})); + const execute = getAgentExecute(srv.url); + let handle: string | undefined; + try { + const loaded = (await execute( + { method: 'loadSecret', params: { ref: 'password' } }, + { ...mockContext, sessionId: 'secret-cross-call-1' }, + )) as { content: Array<{ text?: string }> }; + handle = /sessionId: (\S+)/.exec(loaded.content[0].text ?? '')?.[1]; + expect(handle).to.match(/^s:/); + + try { + await execute( + { method: 'screenshot', sessionId: handle }, + { ...mockContext, sessionId: 'secret-cross-call-2' }, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.include( + 'screenshot cannot run after loadSecret', + ); + } + } finally { + if (handle) { + await execute( + { method: 'close', sessionId: handle }, + { ...mockContext, sessionId: 'secret-cross-call-2' }, + ); + } + await srv.close(); + } + }); + + it('keeps capture blocked when top-frame navigation does not occur', async () => { + const srv = await makeRespondingServer((method) => + method === 'back' ? null : {}, + ); + const execute = getAgentExecute(srv.url); + let handle: string | undefined; + try { + const loaded = (await execute( + { method: 'loadSecret', params: { ref: 'password' } }, + { ...mockContext, sessionId: 'secret-no-navigation-1' }, + )) as { content: Array<{ text?: string }> }; + handle = /sessionId: (\S+)/.exec(loaded.content[0].text ?? '')?.[1]; + + try { + await execute( + { + commands: [{ method: 'back' }, { method: 'screenshot' }], + sessionId: handle, + }, + { ...mockContext, sessionId: 'secret-no-navigation-2' }, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.include( + 'screenshot cannot run after loadSecret', + ); + } + } finally { + if (handle) { + await execute( + { method: 'close', sessionId: handle }, + { ...mockContext, sessionId: 'secret-no-navigation-2' }, + ); + } + await srv.close(); + } + }); + it('rejects integrationId combined with createProfile before connecting', async () => { // Dummy URL: the guard throws before any WebSocket/connect is attempted. const execute = getAgentExecute('http://127.0.0.1:1'); diff --git a/test/tools/compliance-mode.spec.ts b/test/tools/compliance-mode.spec.ts index 35a80ef..ed060f4 100644 --- a/test/tools/compliance-mode.spec.ts +++ b/test/tools/compliance-mode.spec.ts @@ -174,6 +174,16 @@ describe('compliance mode — compliant tool surface', () => { .be.true; }); + it('accepts capability requirements', () => { + const agent = captureTools(true).byName.get('browserless_agent')!; + expect( + agent.parameters.safeParse({ + commands: [VALID_GOTO], + requiredCapabilities: ['vision'], + }).success, + ).to.be.true; + }); + it('rejects an empty or missing commands array', () => { const agent = captureTools(true).byName.get('browserless_agent')!; expect( @@ -692,7 +702,12 @@ describe('compliance mode — compliant tool surface', () => { const EXPECTED_KEYS: Record = { // `sessionId` re-binds the caller to the browser it already had — a // continuity handle, not a capability. - browserless_agent: ['commands', 'rationale', 'sessionId'], + browserless_agent: [ + 'commands', + 'rationale', + 'requiredCapabilities', + 'sessionId', + ], browserless_export: [ 'bestAttempt', 'gotoOptions', diff --git a/test/tools/schemas.spec.ts b/test/tools/schemas.spec.ts index e2279ba..5e85f67 100644 --- a/test/tools/schemas.spec.ts +++ b/test/tools/schemas.spec.ts @@ -357,6 +357,18 @@ describe('AgentParamsSchema.proxy', () => { }); expect(parsed.proxy).to.be.undefined; }); + + it('accepts explicit plan capability requirements', () => { + const parsed = AgentParamsSchema.parse({ + method: 'snapshot', + requiredCapabilities: ['vision', 'os-spoofing'], + }); + + expect(parsed.requiredCapabilities).to.deep.equal([ + 'vision', + 'os-spoofing', + ]); + }); }); describe('AgentParamsSchema persona', () => { @@ -686,7 +698,11 @@ describe('clearSecrets command', () => { ]) { expect( AgentParamsSchema.safeParse({ commands: [command] }).success, - JSON.stringify(command), + `batch: ${JSON.stringify(command)}`, + ).to.equal(true); + expect( + AgentParamsSchema.safeParse(command).success, + `single: ${JSON.stringify(command)}`, ).to.equal(true); } }); @@ -700,6 +716,19 @@ describe('clearSecrets command', () => { expect(result.success).to.equal(false); }); + it('rejects unexpected clearSecrets params in single-command form', () => { + for (const schema of [AgentParamsSchema, AgentToolParamsSchema]) { + for (const commands of [undefined, []]) { + const result = schema.safeParse({ + method: 'clearSecrets', + params: { unexpected: 'not-allowed' }, + commands, + }); + expect(result.success).to.equal(false); + } + } + }); + it('describes when clearSecrets is needed in the published command schema', () => { const schema = JSON.stringify(AgentCommandSchema.toJSONSchema()); expect(schema).to.include('clearSecrets');