Skip to content
Merged
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
99 changes: 85 additions & 14 deletions packages/server/__tests__/agent-device-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand All @@ -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'] });
});
},
);
Expand Down Expand Up @@ -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<string, unknown>;
})
: 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<string, unknown>;
})
: null,
}));
const command = await rpc({
jsonrpc: '2.0',
method: 'agent_device.command',
Expand Down Expand Up @@ -529,6 +529,77 @@ 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: { 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: { method: 'agent_device.lease.allocate' },
},
{ 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();
}
Expand Down
67 changes: 56 additions & 11 deletions packages/server/src/agent-device-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -501,8 +502,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);
}

Expand Down Expand Up @@ -546,26 +564,50 @@ 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 = (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 : [];
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: {
Expand All @@ -585,7 +627,10 @@ 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 {
const method = rpc.method.slice(0, MAX_ECHO);
return { id, rule: 'method', message: `Refused method "${method}".`, details: { method } };
}
return Buffer.from(JSON.stringify(rpc));
}

Expand Down
5 changes: 4 additions & 1 deletion packages/stim-cli/src/guide/macos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,10 @@ directories under its own state directory. Start with agent-device open
<bundleId> --remote-config <path>, 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:
Expand Down
4 changes: 4 additions & 0 deletions website/docs/macos.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<path>` 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:

Expand Down
Loading