From a2d8310744c0bbfbc1644cbb19ada5e3acc958a6 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 5 Oct 2026 20:59:17 -0400 Subject: [PATCH 1/2] fix(server): answer hosted agent relay refusals as agent-device errors --- .../__tests__/agent-device-driver.test.ts | 87 ++++++++++++++++--- packages/server/src/agent-device-driver.ts | 60 ++++++++++--- packages/stim-cli/src/guide/macos.ts | 5 +- website/docs/macos.md | 4 + 4 files changed, 130 insertions(+), 26 deletions(-) diff --git a/packages/server/__tests__/agent-device-driver.test.ts b/packages/server/__tests__/agent-device-driver.test.ts index 439444297..0eb2c3e07 100644 --- a/packages/server/__tests__/agent-device-driver.test.ts +++ b/packages/server/__tests__/agent-device-driver.test.ts @@ -117,7 +117,7 @@ function through( headers = {}, ) { const front = createServer((request, response) => driver.forward(session, request, response)); - return new Promise<{ status: number; text: string }>((resolve, reject) => { + return new Promise<{ status: number; text: string; contentType: string | undefined }>((resolve, reject) => { front.listen(0, '127.0.0.1', () => { const outgoing = httpRequest( { @@ -133,7 +133,7 @@ function through( answer.on('end', () => { front.closeAllConnections(); front.close(); - resolve({ status: answer.statusCode ?? 0, text }); + resolve({ status: answer.statusCode ?? 0, text, contentType: answer.headers['content-type'] }); }); }, ); @@ -404,18 +404,18 @@ describe.skipIf(process.platform === 'win32')('agent-device driver', () => { pid: 4242, }); if (grant.driver === 'none') throw new Error('expected a grant'); - const rpc = (body: object) => - through(driver, SESSION, 'POST', '/rpc', JSON.stringify(body), { 'content-type': 'application/json' }).then( - (answer) => ({ - status: answer.status, - upstream: - answer.status === 200 - ? (JSON.parse((JSON.parse(answer.text) as { body: string }).body) as { - params: Record; - }) - : null, - }), - ); + const rpc = (body: object | string) => + through(driver, SESSION, 'POST', '/rpc', typeof body === 'string' ? body : JSON.stringify(body), { + 'content-type': 'application/json', + }).then((answer) => ({ + ...answer, + upstream: + answer.status === 200 + ? (JSON.parse((JSON.parse(answer.text) as { body: string }).body) as { + params: Record; + }) + : null, + })); const command = await rpc({ jsonrpc: '2.0', method: 'agent_device.command', @@ -529,6 +529,65 @@ describe.skipIf(process.platform === 'win32')('agent-device driver', () => { ]) expect((await rpc(body)).status).toBe(400); expect((await through(driver, SESSION, 'POST', '/rpc', 'not json')).status).toBe(400); + for (const { body, id, rule, details } of [ + { + body: { + id: 'doctor-request', + method: 'agent_device.command', + params: { command: 'doctor', flags: { platform: 'macos' } }, + }, + id: 'doctor-request', + rule: 'command', + details: { command: 'doctor' }, + }, + { + body: { + id: 0, + method: 'agent_device.command', + params: { command: 'batch', flags: { batchSteps: [{ command: 'snapshot' }, { command: 'doctor' }] } }, + }, + id: 0, + rule: 'command', + details: { command: 'doctor' }, + }, + { + body: { id: 2, method: 'agent_device.lease.allocate', params: {} }, + id: 2, + rule: 'method', + details: { method: 'agent_device.lease.allocate' }, + }, + { body: 'not json', id: null, rule: 'request', details: {} }, + { body: { id: 'malformed', method: 'agent_device.command' }, id: 'malformed', rule: 'request', details: {} }, + { + body: { id: true, method: 'agent_device.lease.allocate', params: {} }, + id: null, + rule: 'method', + details: {}, + }, + { body: 'x'.repeat(1024 * 1024 + 1), id: null, rule: 'request', details: {} }, + ]) { + const answer = await rpc(body); + expect(answer.status).toBe(400); + expect(answer.contentType).toBe('application/json'); + const envelope = JSON.parse(answer.text); + expect(envelope).toMatchObject({ + jsonrpc: '2.0', + id, + error: { + code: -32000, + message: expect.any(String), + data: { + code: 'UNAUTHORIZED', + message: envelope.error.message, + hint: expect.any(String), + retriable: false, + details: { reason: 'STIM_AGENT_REQUEST_REFUSED', rule, ...details }, + }, + }, + }); + for (const name of [...Object.values(details), ...(rule === 'command' ? ['snapshot', 'batch'] : [])]) + expect(envelope.error.message).toContain(name); + } } finally { await driver.stop(); } diff --git a/packages/server/src/agent-device-driver.ts b/packages/server/src/agent-device-driver.ts index f5c7a3600..601af8742 100644 --- a/packages/server/src/agent-device-driver.ts +++ b/packages/server/src/agent-device-driver.ts @@ -501,8 +501,25 @@ export class AgentDeviceDriver implements HostedAgentDriver { response.destroy(); return; } - const pinned = body === null ? null : pinLease(body, lease); - if (pinned === null) response.writeHead(400, { 'content-type': 'text/plain' }).end('Unsupported request.\n'); + const pinned = pinLease(body, lease); + if (!Buffer.isBuffer(pinned)) + response.writeHead(400, { 'content-type': 'application/json' }).end( + JSON.stringify({ + jsonrpc: '2.0', + id: pinned.id, + error: { + code: -32000, + message: pinned.message, + data: { + code: 'UNAUTHORIZED', + message: pinned.message, + hint: `The hosted agent connection allows only ${POLICY.commands.allow.join(', ')} on the one leased macOS app; retrying will not help.`, + retriable: false, + details: { reason: 'STIM_AGENT_REQUEST_REFUSED', rule: pinned.rule, ...pinned.details }, + }, + }, + }), + ); else this.relay(running, lease, request, response, target, pinned); } @@ -546,26 +563,47 @@ export class AgentDeviceDriver implements HostedAgentDriver { } } +interface Refusal { + id: string | number | null; + rule: 'command' | 'method' | 'request'; + message: string; + details: { command?: string; method?: string }; +} + /** * Rewrites one JSON-RPC body so it can act only under the session's lease. The proxy token is one credential * for every client, so the lease, its owner scope and tenant session isolation come from the host, never from * the client. Methods other than commands and lease renewal or release are refused. */ -function pinLease(body: Buffer, lease: Lease): Buffer | null { +function pinLease(body: Buffer | null, lease: Lease): Buffer | Refusal { let rpc: unknown; try { - rpc = JSON.parse(body.toString('utf8')); - } catch { - return null; - } - if (!isJsonObject(rpc) || typeof rpc.method !== 'string' || !isJsonObject(rpc.params)) return null; + rpc = body === null ? undefined : JSON.parse(body.toString('utf8')); + } catch {} + const id = isJsonObject(rpc) && (typeof rpc.id === 'string' || typeof rpc.id === 'number') ? rpc.id : null; + if (!isJsonObject(rpc) || typeof rpc.method !== 'string' || !isJsonObject(rpc.params)) + return { + id, + rule: 'request', + message: + 'Refused request: expected a JSON object with a string method and object params within the body size limit.', + details: {}, + }; const { tenant, runId, clientId, deviceKey } = lease.scope; const owner = { runId, leaseId: lease.id, clientId, deviceKey, leaseProvider: 'proxy' }; if (COMMAND_METHODS.has(rpc.method)) { const { runtime: _runtime, meta, flags, input, ...params } = rpc.params; - if (!isAllowedCommand(params.command)) return null; + const refuseCommand = (command: unknown) => ({ + id, + rule: 'command' as const, + message: `Refused command${typeof command === 'string' ? ` "${command}"` : ''}. Allowed commands: ${POLICY.commands.allow.join(', ')}.`, + details: typeof command === 'string' ? { command } : {}, + }); + if (!isAllowedCommand(params.command)) return refuseCommand(params.command); const steps = isJsonObject(flags) && Array.isArray(flags.batchSteps) ? flags.batchSteps : []; - if (steps.some((step) => !isJsonObject(step) || !isAllowedCommand(step.command))) return null; + for (const step of steps) + if (!isJsonObject(step) || !isAllowedCommand(step.command)) + return refuseCommand(isJsonObject(step) ? step.command : undefined); rpc.params = { ...params, flags: { @@ -585,7 +623,7 @@ function pinLease(body: Buffer, lease: Lease): Buffer | null { } else if (LEASE_METHODS.has(rpc.method)) { const { tenant: _tenant, provider: _provider, ...params } = rpc.params; rpc.params = { ...params, ...owner, tenantId: tenant, backend: 'macos-app' }; - } else return null; + } else return { id, rule: 'method', message: `Refused method "${rpc.method}".`, details: { method: rpc.method } }; return Buffer.from(JSON.stringify(rpc)); } diff --git a/packages/stim-cli/src/guide/macos.ts b/packages/stim-cli/src/guide/macos.ts index 1cec3aee1..777bff4a0 100644 --- a/packages/stim-cli/src/guide/macos.ts +++ b/packages/stim-cli/src/guide/macos.ts @@ -295,7 +295,10 @@ directories under its own state directory. Start with agent-device open --remote-config , using macos.host.bundleId; the lease allows open, close, snapshot, wait, find, get, is, click, fill, press, type, focus, scroll, screenshot and batch on that app only. The agent-device on this Mac must -know the macos-app lease backend. +know the macos-app lease backend. A request outside that command list, or a +method the relay does not forward, is refused with an agent-device UNAUTHORIZED +error whose details.reason is STIM_AGENT_REQUEST_REFUSED and whose message names +the refused command or method; do not retry it. Agents that built, launched and drove test copies on another Mac with an SSH script such as mini-desktop.sh use these instead: diff --git a/website/docs/macos.md b/website/docs/macos.md index 2464514c8..709774bc2 100644 --- a/website/docs/macos.md +++ b/website/docs/macos.md @@ -326,6 +326,10 @@ commands that drive that one app (`snapshot`, `click`, `fill`, `type`, `press`, `scroll`, `screenshot` and similar), and the agent-device on the client needs the `macos-app` lease backend. On the hosting Mac, `stim-server service install --env STIM_AGENT_DEVICE_BIN=` points stim-server at a specific agent-device. +A request outside the allowed commands, or a method the relay does not forward, +is refused with an agent-device `UNAUTHORIZED` error whose `details.reason` is +`STIM_AGENT_REQUEST_REFUSED` and whose message names the refused command or +method; do not retry it. Copy this prompt: From 60a90776d9061528c3622f14dbd95ef0185f6fae Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 5 Oct 2026 21:03:12 -0400 Subject: [PATCH 2/2] fix(server): bound the names echoed in a relay refusal --- .../__tests__/agent-device-driver.test.ts | 14 ++++++++++++- packages/server/src/agent-device-driver.ts | 21 ++++++++++++------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/server/__tests__/agent-device-driver.test.ts b/packages/server/__tests__/agent-device-driver.test.ts index 0eb2c3e07..5c6d80a10 100644 --- a/packages/server/__tests__/agent-device-driver.test.ts +++ b/packages/server/__tests__/agent-device-driver.test.ts @@ -556,13 +556,25 @@ describe.skipIf(process.platform === 'win32')('agent-device driver', () => { rule: 'method', details: { method: 'agent_device.lease.allocate' }, }, + { + body: { id: 4, method: 'agent_device.command', params: { command: 'batch', flags: { batchSteps: [null] } } }, + id: 4, + rule: 'command', + details: {}, + }, + { + body: { id: 5, method: 'x'.repeat(200), params: {} }, + id: 5, + rule: 'method', + details: { method: 'x'.repeat(64) }, + }, { body: 'not json', id: null, rule: 'request', details: {} }, { body: { id: 'malformed', method: 'agent_device.command' }, id: 'malformed', rule: 'request', details: {} }, { body: { id: true, method: 'agent_device.lease.allocate', params: {} }, id: null, rule: 'method', - details: {}, + details: { method: 'agent_device.lease.allocate' }, }, { body: 'x'.repeat(1024 * 1024 + 1), id: null, rule: 'request', details: {} }, ]) { diff --git a/packages/server/src/agent-device-driver.ts b/packages/server/src/agent-device-driver.ts index 601af8742..2b1c1887e 100644 --- a/packages/server/src/agent-device-driver.ts +++ b/packages/server/src/agent-device-driver.ts @@ -82,6 +82,7 @@ const POLICY = { /** agent-device caps a lease's inactivity window at ten minutes; the host renews well inside it. */ const LEASE_TTL_MS = 600_000; const MAX_RPC_BYTES = 1024 * 1024; +const MAX_ECHO = 64; const COMMAND_METHODS = new Set(['agent_device.command', 'agent-device.command']); const LEASE_METHODS = new Set([ 'agent_device.lease.heartbeat', @@ -593,12 +594,15 @@ function pinLease(body: Buffer | null, lease: Lease): Buffer | Refusal { const owner = { runId, leaseId: lease.id, clientId, deviceKey, leaseProvider: 'proxy' }; if (COMMAND_METHODS.has(rpc.method)) { const { runtime: _runtime, meta, flags, input, ...params } = rpc.params; - const refuseCommand = (command: unknown) => ({ - id, - rule: 'command' as const, - message: `Refused command${typeof command === 'string' ? ` "${command}"` : ''}. Allowed commands: ${POLICY.commands.allow.join(', ')}.`, - details: typeof command === 'string' ? { command } : {}, - }); + const refuseCommand = (value: unknown) => { + const command = typeof value === 'string' ? value.slice(0, MAX_ECHO) : undefined; + return { + id, + rule: 'command' as const, + message: `Refused command${command === undefined ? '' : ` "${command}"`}. Allowed commands: ${POLICY.commands.allow.join(', ')}.`, + details: command === undefined ? {} : { command }, + }; + }; if (!isAllowedCommand(params.command)) return refuseCommand(params.command); const steps = isJsonObject(flags) && Array.isArray(flags.batchSteps) ? flags.batchSteps : []; for (const step of steps) @@ -623,7 +627,10 @@ function pinLease(body: Buffer | null, lease: Lease): Buffer | Refusal { } else if (LEASE_METHODS.has(rpc.method)) { const { tenant: _tenant, provider: _provider, ...params } = rpc.params; rpc.params = { ...params, ...owner, tenantId: tenant, backend: 'macos-app' }; - } else return { id, rule: 'method', message: `Refused method "${rpc.method}".`, details: { method: rpc.method } }; + } else { + const method = rpc.method.slice(0, MAX_ECHO); + return { id, rule: 'method', message: `Refused method "${method}".`, details: { method } }; + } return Buffer.from(JSON.stringify(rpc)); }