From df86ac8183921e7f6d8a103b4b93c29b1f918d78 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 5 Oct 2026 13:15:49 -0400 Subject: [PATCH 1/5] feat: let agents drive hosted macOS apps through agent-device macos-app leases The agent-device adapter starts only when the daemon advertises the macos-app lease backend, allocates a host lease per hosted app process over agent-device's loopback admin route, renews and releases it, and pins every forwarded command to that lease. The client writes the lease into its remote config. STIM_AGENT_DEVICE_BIN names the agent-device binary. Refs #2477. --- packages/core/__tests__/hosted-macos.test.ts | 6 +- packages/core/state/hosted-macos.ts | 41 ++- packages/server/README.md | 22 +- .../__tests__/agent-device-driver.test.ts | 241 +++++++++++-- .../server/__tests__/agent-driver.test.ts | 14 +- packages/server/__tests__/device-host.test.ts | 3 + packages/server/src/agent-device-driver.ts | 326 ++++++++++++++++-- packages/server/src/protocol.ts | 14 + .../src/__tests__/hosted-macos-client.test.ts | 12 +- .../stim-cli/src/device-host/hosted-macos.ts | 7 + packages/stim-cli/src/guide/settings.ts | 8 +- website/docs/settings.md | 8 +- 12 files changed, 629 insertions(+), 73 deletions(-) diff --git a/packages/core/__tests__/hosted-macos.test.ts b/packages/core/__tests__/hosted-macos.test.ts index be95da4f9..2ac9e900a 100644 --- a/packages/core/__tests__/hosted-macos.test.ts +++ b/packages/core/__tests__/hosted-macos.test.ts @@ -24,7 +24,8 @@ test('an agent grant is usable only for a known driver, one session route, and a driver: 'agent-device', path: '/device-host/agent/12345678-1234-1234-1234-123456789abc/', token: 'a'.repeat(43), - scope: 'lease-1', + scope: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + lease: { tenant: 'stim.t', runId: 'run-1', clientId: 'agent', deviceKey: 'dev.example.app.hosted1@4242' }, }; expect(parseHostedAgentGrant(grant)).toEqual(grant); expect(parseHostedAgentGrant({ driver: 'none' })).toEqual({ driver: 'none' }); @@ -34,6 +35,9 @@ test('an agent grant is usable only for a known driver, one session route, and a { ...grant, path: '/device-host/agent/../other/' }, { ...grant, token: 'short' }, { ...grant, extra: true }, + { ...grant, lease: undefined }, + { ...grant, lease: { ...grant.lease, deviceKey: 'dev.example.app.hosted1' } }, + { ...grant, lease: { ...grant.lease, tenant: 'a tenant' } }, ]) expect(parseHostedAgentGrant(value)).toBeNull(); }); diff --git a/packages/core/state/hosted-macos.ts b/packages/core/state/hosted-macos.ts index a0262bfc7..c26d66d29 100644 --- a/packages/core/state/hosted-macos.ts +++ b/packages/core/state/hosted-macos.ts @@ -19,14 +19,32 @@ export interface HostedMacosDevice extends HostedMacosChoice { appSlot: number; } +/** + * The rest of an agent-device `macos-app` lease's owner scope, which a client names on every request: + * `deviceKey` is `@` of the hosted app. + */ +export interface HostedAgentLease { + tenant: string; + runId: string; + clientId: string; + deviceKey: string; +} + /** * Agent control the host grants a client for one installed hosted app, sent only over that client's approved * device-host connection and never journaled. `none` means the host's `hosting.agentDriver` starts no driver. - * `path` is the session's base route on the host, `scope` the driver's handle that limits it to this one app. + * `path` is the session's base route on the host, `scope` the driver's handle that limits it to this one app, + * and `lease` the scope the driver's lease was allocated with. */ export type HostedAgentGrant = | { driver: 'none' } - | { driver: Exclude; path: string; token: string; scope: string }; + | { + driver: Exclude; + path: string; + token: string; + scope: string; + lease: HostedAgentLease; + }; /** `app.launch` and `app.attach` results; a host includes `agent` once the app is installed. */ export type HostedAppLaunch = HostedAppDelivery & { agent?: HostedAgentGrant }; @@ -82,6 +100,20 @@ export function parseHostedMacosDevice(value: unknown): HostedMacosDevice | null } const AGENT_PATH = /^\/device-host\/agent\/[a-f0-9-]{36}\/$/; +const LEASE_NAME = /^[A-Za-z0-9._-]{1,128}$/; +const LEASE_DEVICE_KEY = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+@[1-9][0-9]{0,9}$/; + +function parseHostedAgentLease(value: unknown): HostedAgentLease | null { + if ( + !isJsonObject(value) || + Object.keys(value).length !== 4 || + ![value.tenant, value.runId, value.clientId].every((name) => typeof name === 'string' && LEASE_NAME.test(name)) || + typeof value.deviceKey !== 'string' || + !LEASE_DEVICE_KEY.test(value.deviceKey) + ) + return null; + return value as unknown as HostedAgentLease; +} /** A grant naming a driver this client does not know is not usable; it parses as null. */ export function parseHostedAgentGrant(value: unknown): HostedAgentGrant | null { @@ -95,14 +127,17 @@ export function parseHostedAgentGrant(value: unknown): HostedAgentGrant | null { !/^[A-Za-z0-9_-]{32,256}$/.test(value.token) || typeof value.scope !== 'string' || !/^[A-Za-z0-9._:-]{1,256}$/.test(value.scope) || - Object.keys(value).length !== 4 + Object.keys(value).length !== 5 ) return null; + const lease = parseHostedAgentLease(value.lease); + if (!lease) return null; return { driver: value.driver as Exclude, path: value.path, token: value.token, scope: value.scope, + lease: { ...lease }, }; } diff --git a/packages/server/README.md b/packages/server/README.md index 6c3edfcdc..c8d8af264 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -292,11 +292,23 @@ session's approved device-host client, the client still holds `device-host`, and the session's app is running. Loopback requests, browser requests and other sessions' tokens are refused with 403, an unknown or stopped session with 404. -The `agent-device` adapter finds the binary at `~/.local/bin/agent-device`, -`/opt/homebrew/bin/agent-device` or `/usr/local/bin/agent-device` and never -searches `PATH`. agent-device has no remote lease limited to one macOS app, so -the adapter does not start and the grant is `{ "driver": "none" }` with a -notice; Stim never hands out the Mac's desktop. +The `agent-device` adapter runs the binary that `STIM_AGENT_DEVICE_BIN` names +in `stim-server`'s environment, or else the first of +`~/.local/bin/agent-device`, `/opt/homebrew/bin/agent-device` and +`/usr/local/bin/agent-device`; it never searches `PATH`. It starts +`agent-device proxy` with the native macOS app backend and a daemon policy that +admits only requests under a `macos-app` lease and only the commands that drive +one app. It starts only when the daemon's `/health` lists the `macos-app` lease +backend (callstack/agent-device#3229); otherwise the grant is +`{ "driver": "none" }` with a notice, and Stim never hands out the Mac's desktop. + +For each running hosted app, the adapter allocates a `macos-app` lease for +`@` over agent-device's loopback `/admin/leases` route with the +daemon token, renews it while the app runs and releases it when the app stops +or the grant is revoked. The grant carries the lease id and owner scope. The +forward passes only `POST /rpc`, `GET /health` and `GET /artifacts/...`, and it +rewrites every command and lease call to that session's lease, tenant and +tenant session isolation, so a client cannot name another lease or session. ### Hosted iOS session protocol diff --git a/packages/server/__tests__/agent-device-driver.test.ts b/packages/server/__tests__/agent-device-driver.test.ts index 0d502499b..8512e7bd2 100644 --- a/packages/server/__tests__/agent-device-driver.test.ts +++ b/packages/server/__tests__/agent-device-driver.test.ts @@ -1,6 +1,5 @@ import { chmodSync, - existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -19,11 +18,12 @@ import { readClaimSet } from '@stim-cli/core/ownership-claim'; import { inspectProcessIdentity, captureProcessIdentity } from '@stim-cli/core/process-identity'; import { AgentDeviceDriver, resolveAgentDevice } from '../src/agent-device-driver.ts'; import { AgentDriverUnavailable } from '../src/agent-driver.ts'; +import { parseHostedAgentGrant } from '@stim-cli/core/state'; const FAKE_AGENT_DEVICE = `#!/usr/bin/env node import { spawn } from 'node:child_process'; import { createServer } from 'node:http'; -import { readFileSync, writeFileSync } from 'node:fs'; +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const args = process.argv.slice(2); const stateDir = args[args.indexOf('--state-dir') + 1]; @@ -38,17 +38,38 @@ if (args[0] === 'proxy' && process.env.FAKE_PROXY === 'daemon-only') { const code = (process.env.FAKE_DAEMON === 'stubborn' ? "process.on('SIGTERM', () => {});" : '') + 'setInterval(() => {}, 1000)'; const daemon = spawn(process.execPath, ['-e', code], { detached: true, stdio: 'ignore' }); daemon.unref(); - writeFileSync(join(stateDir, 'daemon.json'), JSON.stringify({ pid: daemon.pid, httpPort: 1, token: 'daemon-secret' })); - const server = createServer((request, response) => { + writeFileSync(join(stateDir, 'proxy-env.json'), JSON.stringify({ + policy: readFileSync(process.env.AGENT_DEVICE_DAEMON_POLICY, 'utf8'), + backend: process.env.AGENT_DEVICE_MACOS_APP_BACKEND, + })); + const admin = createServer((request, response) => { const chunks = []; request.on('data', (chunk) => chunks.push(chunk)); request.on('end', () => { - const authorized = request.headers.authorization === 'Bearer ' + process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; - response.writeHead(authorized ? 200 : 401, { 'content-type': 'application/json', connection: 'close' }); - response.end(JSON.stringify({ authorized, method: request.method, url: request.url, body: Buffer.concat(chunks).toString(), seen: Object.keys(request.headers) })); + const ok = request.headers.authorization === 'Bearer daemon-secret' && process.env.FAKE_ADMIN !== 'refuse'; + appendFileSync(join(stateDir, 'admin.log'), JSON.stringify({ method: request.method, url: request.url, body: Buffer.concat(chunks).toString() }) + '\\n'); + response.writeHead(ok ? 200 : 400, { 'content-type': 'application/json', connection: 'close' }); + response.end(JSON.stringify({ ok })); }); }); - server.listen(0, '127.0.0.1', () => console.log('Proxy listening at http://127.0.0.1:' + server.address().port)); + admin.listen(0, '127.0.0.1', () => { + writeFileSync(join(stateDir, 'daemon.json'), JSON.stringify({ pid: daemon.pid, httpPort: admin.address().port, token: 'daemon-secret' })); + const server = createServer((request, response) => { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => { + if (request.url === '/health') { + const leaseBackends = (process.env.FAKE_LEASE_BACKENDS ?? 'ios-instance,macos-app').split(','); + response.writeHead(200, { 'content-type': 'application/json', connection: 'close' }); + return response.end(JSON.stringify({ ok: true, service: 'agent-device-proxy', upstream: { leaseBackends } })); + } + const authorized = request.headers.authorization === 'Bearer ' + process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; + response.writeHead(authorized ? 200 : 401, { 'content-type': 'application/json', connection: 'close' }); + response.end(JSON.stringify({ authorized, method: request.method, url: request.url, body: Buffer.concat(chunks).toString(), seen: Object.keys(request.headers), tenant: request.headers['x-agent-device-tenant'] })); + }); + }); + server.listen(0, '127.0.0.1', () => console.log('Proxy listening at http://127.0.0.1:' + server.address().port)); + }); } else if (args[0] === 'daemon' && args[1] === 'stop') { if (!process.env.FAKE_STOP_NOOP) try { process.kill(JSON.parse(readFileSync(join(stateDir, 'daemon.json'), 'utf8')).pid, 'SIGTERM'); } catch {} console.log('Daemon stopped (graceful).'); @@ -76,7 +97,6 @@ function driverIn( env: { HOME: home, PATH: '/usr/bin:/bin', ...env }, stateDir: join(home, 'state'), claimRoot: join(home, 'agent-device.claims'), - scopedMacosLease: true, watchMs: 50, ...extra, }); @@ -128,19 +148,89 @@ afterEach(() => { }); describe.skipIf(process.platform === 'win32')('agent-device driver', () => { - test('ships disabled until agent-device can lease one macOS app', async () => { + test('refuses to start an agent-device that cannot lease one macOS app, and leaves nothing running', async () => { install(root); - const driver = new AgentDeviceDriver({ - env: { HOME: root, PATH: '/usr/bin:/bin' }, - stateDir: join(root, 'state'), - claimRoot: join(root, 'agent-device.claims'), - }); + const driver = driverIn(root, {}, { FAKE_LEASE_BACKENDS: 'ios-instance,android-instance' }); await expect(driver.start()).rejects.toThrow(AgentDriverUnavailable); await expect(driver.start()).rejects.toThrow(/Stim never hands a client the hosting Mac desktop/); - expect(existsSync(join(root, 'state'))).toBe(false); - await expect(driver.issue({ client: 'c', session: SESSION, bundleId: 'dev.example.app', pid: 5 })).rejects.toThrow( - AgentDriverUnavailable, - ); + const daemonPid = (JSON.parse(readFileSync(join(root, 'state', 'daemon.json'), 'utf8')) as { pid: number }).pid; + await vi.waitFor(() => expect(() => process.kill(daemonPid, 0)).toThrow(/ESRCH/)); + expect(readClaimSet(join(root, 'agent-device.claims')).live).toHaveLength(0); + }); + + test('starts agent-device confined to host-allocated macos-app leases on the native backend', async () => { + install(root); + const driver = driverIn(root); + await driver.start(); + try { + const seen = JSON.parse(readFileSync(join(root, 'state', 'proxy-env.json'), 'utf8')) as { + policy: string; + backend: string; + }; + expect(seen.backend).toBe('native'); + const policy = JSON.parse(seen.policy) as { leases: unknown; commands: { allow: string[] } }; + expect(policy.leases).toEqual({ require: 'macos-app' }); + expect(policy.commands.allow).toContain('snapshot'); + expect(policy.commands.allow).not.toContain('install'); + expect(statSync(join(root, 'state', 'policy.json')).mode & 0o777).toBe(0o600); + } finally { + await driver.stop(); + } + }); + + test('allocates a lease for exactly the hosted app process, renews it and releases it on revoke', async () => { + install(root); + const driver = driverIn(root, { leaseRenewMs: 50 }); + await driver.start(); + try { + const grant = await driver.issue({ + client: 'c', + session: SESSION, + bundleId: 'dev.example.app.hosted1', + pid: 4242, + }); + expect(parseHostedAgentGrant(grant)).toEqual(grant); + if (grant.driver === 'none') throw new Error('expected a grant'); + expect(grant.lease.deviceKey).toBe('dev.example.app.hosted1@4242'); + const calls = () => + readFileSync(join(root, 'state', 'admin.log'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { method: string; url: string; body: string }); + expect(calls()[0]).toMatchObject({ method: 'PUT', url: `/admin/leases/${grant.scope}` }); + expect(JSON.parse(calls()[0]!.body)).toEqual({ + tenantId: grant.lease.tenant, + runId: grant.lease.runId, + clientId: grant.lease.clientId, + leaseBackend: 'macos-app', + leaseProvider: 'proxy', + deviceKey: 'dev.example.app.hosted1@4242', + ttlMs: 600_000, + }); + await vi.waitFor(() => expect(calls().filter((call) => call.method === 'PUT').length).toBeGreaterThan(2)); + await driver.revoke(SESSION); + expect(calls().at(-1)).toMatchObject({ method: 'DELETE', url: `/admin/leases/${grant.scope}` }); + const after = calls().length; + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(calls()).toHaveLength(after); + expect((await through(driver, SESSION, 'GET', '/health')).status).toBe(503); + } finally { + await driver.stop(); + } + }); + + test('issues no grant when agent-device refuses the lease', async () => { + install(root); + const driver = driverIn(root, {}, { FAKE_ADMIN: 'refuse' }); + await driver.start(); + try { + await expect( + driver.issue({ client: 'c', session: SESSION, bundleId: 'dev.example.app.hosted1', pid: 4242 }), + ).rejects.toThrow(/refused the lease/); + expect((await through(driver, SESSION, 'GET', '/health')).status).toBe(503); + } finally { + await driver.stop(); + } }); test('resolves agent-device by explicit path and runs a script under the server Node', () => { @@ -157,6 +247,21 @@ describe.skipIf(process.platform === 'win32')('agent-device driver', () => { ); }); + test('uses only the agent-device that STIM_AGENT_DEVICE_BIN names when it is set', () => { + const real = install(root); + const elsewhere = join(root, 'other', 'agent-device.mjs'); + mkdirSync(join(root, 'other')); + writeFileSync(elsewhere, FAKE_AGENT_DEVICE); + expect(resolveAgentDevice({ HOME: root, STIM_AGENT_DEVICE_BIN: elsewhere })).toEqual({ + command: process.execPath, + args: [realpathSync(elsewhere)], + }); + expect(realpathSync(real)).not.toBe(realpathSync(elsewhere)); + expect(() => resolveAgentDevice({ HOME: root, STIM_AGENT_DEVICE_BIN: join(root, 'missing') })).toThrow( + /STIM_AGENT_DEVICE_BIN names .*missing/, + ); + }); + test('holds the daemon under a claim and removes both on stop', async () => { install(root); const driver = driverIn(root); @@ -193,18 +298,101 @@ describe.skipIf(process.platform === 'win32')('agent-device driver', () => { const driver = driverIn(root); await driver.start(); try { - const answer = await through(driver, SESSION, 'POST', '/rpc?x=1', '{"method":"snapshot"}', { + await driver.issue({ client: 'c', session: SESSION, bundleId: 'dev.example.app.hosted1', pid: 4242 }); + const answer = await through(driver, SESSION, 'GET', '/health?x=1', undefined, { authorization: 'Bearer client-grant-token', 'x-agent-device-token': 'client-grant-token', - 'content-type': 'application/json', cookie: 'a=b', }); expect(answer.status).toBe(200); - const seen = JSON.parse(answer.text) as Record; - expect(seen).toMatchObject({ authorized: true, method: 'POST', url: '/rpc?x=1', body: '{"method":"snapshot"}' }); + const answerRpc = await through( + driver, + SESSION, + 'POST', + '/rpc?x=1', + '{"method":"agent_device.command","params":{}}', + { + 'x-agent-device-tenant': 'stim.other', + authorization: 'Bearer client-grant-token', + 'x-agent-device-token': 'client-grant-token', + 'content-type': 'application/json', + cookie: 'a=b', + }, + ); + const seen = JSON.parse(answerRpc.text) as Record; + expect(seen).toMatchObject({ authorized: true, method: 'POST', url: '/rpc?x=1', tenant: `stim.${SESSION}` }); expect(seen.seen).not.toContain('x-agent-device-token'); expect(seen.seen).not.toContain('cookie'); - expect(answer.text).not.toContain('client-grant-token'); + expect(answerRpc.text).not.toContain('client-grant-token'); + } finally { + await driver.stop(); + } + }); + + test('pins every command and lease call to the session lease, whatever the client names', async () => { + install(root); + const driver = driverIn(root); + await driver.start(); + try { + const grant = await driver.issue({ + client: 'c', + session: SESSION, + bundleId: 'dev.example.app.hosted1', + 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 command = await rpc({ + jsonrpc: '2.0', + method: 'agent_device.command', + params: { + command: 'open', + session: 'other-client', + meta: { tenantId: 'stim.other', leaseId: 'f'.repeat(32), sessionIsolation: 'none', requestId: 'r1' }, + }, + }); + expect(command.upstream?.params.meta).toEqual({ + requestId: 'r1', + tenantId: grant.lease.tenant, + runId: grant.lease.runId, + leaseId: grant.scope, + clientId: grant.lease.clientId, + deviceKey: 'dev.example.app.hosted1@4242', + leaseProvider: 'proxy', + leaseBackend: 'macos-app', + sessionIsolation: 'tenant', + }); + const heartbeat = await rpc({ + method: 'agent_device.lease.heartbeat', + params: { tenant: 'stim.other', leaseId: 'f'.repeat(32), backend: 'ios-instance', provider: 'x' }, + }); + expect(heartbeat.upstream?.params).toEqual({ + tenantId: grant.lease.tenant, + runId: grant.lease.runId, + leaseId: grant.scope, + clientId: grant.lease.clientId, + deviceKey: 'dev.example.app.hosted1@4242', + leaseProvider: 'proxy', + backend: 'macos-app', + }); + for (const body of [ + { method: 'agent_device.lease.allocate', params: {} }, + { method: 'agent_device.install_from_source', params: {} }, + { method: 'agent_device.command' }, + ]) + expect((await rpc(body)).status).toBe(400); + expect((await through(driver, SESSION, 'POST', '/rpc', 'not json')).status).toBe(400); } finally { await driver.stop(); } @@ -274,9 +462,14 @@ describe.skipIf(process.platform === 'win32')('agent-device driver', () => { const driver = driverIn(root); await driver.start(); try { + await driver.issue({ client: 'c', session: SESSION, bundleId: 'dev.example.app.hosted1', pid: 4242 }); expect((await through(driver, SESSION, 'GET', '/health')).status).toBe(200); expect((await through(driver, SESSION, 'GET', '/admin/human-control/holds')).status).toBe(404); + expect((await through(driver, SESSION, 'GET', '/admin/leases')).status).toBe(404); expect((await through(driver, SESSION, 'GET', '/rpc/../admin')).status).toBe(404); + expect((await through(driver, SESSION, 'GET', '/rpc')).status).toBe(404); + expect((await through(driver, SESSION, 'POST', '/upload', 'x')).status).toBe(404); + expect((await through(driver, SESSION, 'GET', `/sessions/${SESSION}/requests/r1/diagnostics`)).status).toBe(404); } finally { await driver.stop(); } diff --git a/packages/server/__tests__/agent-driver.test.ts b/packages/server/__tests__/agent-driver.test.ts index 024e20e70..6fe4475c2 100644 --- a/packages/server/__tests__/agent-driver.test.ts +++ b/packages/server/__tests__/agent-driver.test.ts @@ -47,6 +47,12 @@ class FakeDriver implements HostedAgentDriver { path: agentRoute(app.session), token: newAgentToken(), scope: `lease-${app.session.slice(0, 8)}-${app.pid}`, + lease: { + tenant: `stim.${app.session}`, + runId: app.session, + clientId: 'agent', + deviceKey: `${app.bundleId}@${app.pid}`, + }, }); } revoke(session: string): Promise { @@ -265,7 +271,13 @@ describe('hosted agent grants', () => { test('are 256-bit tokens the client parser accepts', () => { const token = newAgentToken(); expect( - parseHostedAgentGrant({ driver: 'agent-device', path: agentRoute(A), token, scope: 'lease-1' }), + parseHostedAgentGrant({ + driver: 'agent-device', + path: agentRoute(A), + token, + scope: 'lease-1', + lease: { tenant: `stim.${A}`, runId: A, clientId: 'agent', deviceKey: 'dev.example.app@5' }, + }), ).not.toBeNull(); expect(newAgentToken()).not.toBe(token); }); diff --git a/packages/server/__tests__/device-host.test.ts b/packages/server/__tests__/device-host.test.ts index b9e05bb20..b1e2c8d31 100644 --- a/packages/server/__tests__/device-host.test.ts +++ b/packages/server/__tests__/device-host.test.ts @@ -995,6 +995,7 @@ describe('hosted agent control', () => { path: `/device-host/agent/${randomUUID()}/`, token: 'a'.repeat(43), scope: 'lease-1', + lease: { tenant: 'stim.s', runId: 'run-1', clientId: 'agent', deviceKey: 'dev.fixture.app.hosted1@4242' }, }; test('starts macOS agent control for the installed process before the receipt reads installed and hands out its grant', async () => { @@ -1321,6 +1322,7 @@ test('app deliveries carry an agent grant for any driver name but only one sessi path: '/device-host/agent/12345678-1234-1234-1234-123456789abc/', token: 'a'.repeat(43), scope: 'lease-1', + lease: { tenant: 'stim.s', runId: 'run-1', clientId: 'agent', deviceKey: 'dev.fixture.app.hosted1@4242' }, }; expect(acceptsDelivery({ ...delivery, agent: { driver: 'none' } })).toBe(true); expect(acceptsDelivery({ ...delivery, agent: grant })).toBe(true); @@ -1329,6 +1331,7 @@ test('app deliveries carry an agent grant for any driver name but only one sessi { ...grant, driver: 'none' }, { ...grant, path: '/device-host/agent/../12345678-1234-1234-1234-123456789abc/' }, { ...grant, token: 'short' }, + { ...grant, lease: { ...grant.lease, deviceKey: 'dev.fixture.app.hosted1' } }, ]) expect(acceptsDelivery({ ...delivery, agent })).toBe(false); }); diff --git a/packages/server/src/agent-device-driver.ts b/packages/server/src/agent-device-driver.ts index 7b5a6d32c..9478998e4 100644 --- a/packages/server/src/agent-device-driver.ts +++ b/packages/server/src/agent-device-driver.ts @@ -1,5 +1,15 @@ import { execFile, spawn, type ChildProcess } from 'node:child_process'; -import { accessSync, chmodSync, constants, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { + accessSync, + chmodSync, + constants, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { request as httpRequest, type IncomingMessage, type ServerResponse } from 'node:http'; import { homedir } from 'node:os'; import { delimiter, dirname, join } from 'node:path'; @@ -18,24 +28,21 @@ import { waitForProcessExit, type ProcessRecord, } from '@stim-cli/core/process-identity'; -import { isJsonObject, type HostedAgentGrant } from '@stim-cli/core/state'; +import { isJsonObject, type HostedAgentGrant, type HostedAgentLease } from '@stim-cli/core/state'; import { AGENT_ROUTE_PREFIX, AgentDriverUnavailable, + agentRoute, newAgentToken, type HostedAgentApp, type HostedAgentDriver, } from './agent-driver.ts'; -/** - * agent-device (0.21.x, upstream main) maps no remote lease backend to the macOS desktop host, so a proxy - * client that opens an app on a Mac gets the whole desktop or nothing. Until agent-device can lease one - * macOS app, this adapter refuses to start and to issue, and never hands out unscoped desktop access. - */ -const AGENT_DEVICE_SCOPED_MACOS_LEASE = false; - const UNSCOPED = - 'Agent control is unavailable: agent-device has no remote lease limited to one macOS app yet, and Stim never hands a client the hosting Mac desktop.'; + 'Agent control is unavailable: the agent-device on the hosting Mac has no macos-app lease to limit a client to one app, and Stim never hands a client the hosting Mac desktop.'; + +/** Names an agent-device binary to use instead of the fixed install locations. */ +const AGENT_DEVICE_BIN_ENV = 'STIM_AGENT_DEVICE_BIN'; const CANDIDATES = (home: string): string[] => [ join(home, '.local', 'bin', 'agent-device'), @@ -43,6 +50,46 @@ const CANDIDATES = (home: string): string[] => [ '/usr/local/bin/agent-device', ]; +/** + * The daemon policy Stim starts agent-device with (agent-device ADR 0029): every request must run under a + * host-allocated `macos-app` lease, and only these commands run at all. + */ +const POLICY = { + version: 1, + leases: { require: 'macos-app' }, + commands: { + allow: [ + 'open', + 'close', + 'snapshot', + 'diff', + 'wait', + 'find', + 'get', + 'is', + 'click', + 'fill', + 'press', + 'type', + 'focus', + 'scroll', + 'screenshot', + 'batch', + ], + }, +}; + +/** 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 COMMAND_METHODS = new Set(['agent_device.command', 'agent-device.command']); +const LEASE_METHODS = new Set([ + 'agent_device.lease.heartbeat', + 'agent-device.lease.heartbeat', + 'agent_device.lease.release', + 'agent-device.lease.release', +]); + export interface AgentDeviceInvocation { command: string; args: string[]; @@ -54,7 +101,8 @@ export interface AgentDeviceInvocation { * server's own Node. */ export function resolveAgentDevice(env: NodeJS.ProcessEnv): AgentDeviceInvocation { - const candidates = CANDIDATES(env.HOME || homedir()); + const explicit = env[AGENT_DEVICE_BIN_ENV]?.trim(); + const candidates = explicit ? [explicit] : CANDIDATES(env.HOME || homedir()); for (const candidate of candidates) { try { const real = realpathSync(candidate); @@ -63,7 +111,9 @@ export function resolveAgentDevice(env: NodeJS.ProcessEnv): AgentDeviceInvocatio } catch {} } throw new AgentDriverUnavailable( - `agent-device is not installed on the hosting Mac (looked in ${candidates.join(', ')}). Install it with: npm install --global --prefix "$HOME/.local" agent-device`, + explicit + ? `${AGENT_DEVICE_BIN_ENV} names ${explicit}, which is not a readable agent-device on the hosting Mac.` + : `agent-device is not installed on the hosting Mac (looked in ${candidates.join(', ')}). Install it with: npm install --global --prefix "$HOME/.local" agent-device`, ); } @@ -73,10 +123,10 @@ export interface AgentDeviceDriverOptions { stateDir: string; /** Ownership claim root held for as long as the daemon runs. */ claimRoot: string; - scopedMacosLease?: boolean; startTimeoutMs?: number; stopTimeoutMs?: number; watchMs?: number; + leaseRenewMs?: number; } interface Running { @@ -86,9 +136,22 @@ interface Running { daemon: ProcessRecord; url: URL; token: string; + admin: DaemonAdmin; watch: NodeJS.Timeout; } +/** The daemon's own loopback listener and token, which serve agent-device's host-only `/admin` routes. */ +interface DaemonAdmin { + port: number; + token: string; +} + +interface Lease { + id: string; + scope: HostedAgentLease; + renew: NodeJS.Timeout; +} + const FORWARDED = [ 'content-type', 'content-length', @@ -113,14 +176,15 @@ export class AgentDeviceDriver implements HostedAgentDriver { private stopping = false; private daemonRecord: ProcessRecord | null = null; private listener: (() => void) | null = null; + private readonly leases = new Map(); constructor(options: AgentDeviceDriverOptions) { this.options = { startTimeoutMs: 60_000, stopTimeoutMs: 20_000, watchMs: 5000, + leaseRenewMs: 120_000, ...options, - scopedMacosLease: options.scopedMacosLease ?? AGENT_DEVICE_SCOPED_MACOS_LEASE, }; } @@ -129,7 +193,6 @@ export class AgentDeviceDriver implements HostedAgentDriver { } start(): Promise { - if (!this.options.scopedMacosLease) return Promise.reject(new AgentDriverUnavailable(UNSCOPED)); if (this.running) return Promise.resolve(); this.starting ??= this.launch().finally(() => { this.starting = null; @@ -158,6 +221,8 @@ export class AgentDeviceDriver implements HostedAgentDriver { this.claim = claim; this.stopping = false; rmSync(join(this.options.stateDir, 'daemon.json'), { force: true }); + const policy = join(this.options.stateDir, 'policy.json'); + writeFileSync(policy, `${JSON.stringify(POLICY)}\n`, { mode: 0o600 }); markClaimChildPending(claim); const token = newAgentToken(); let proxy: ChildProcess | undefined; @@ -170,6 +235,8 @@ export class AgentDeviceDriver implements HostedAgentDriver { ...this.options.env, PATH: [dirname(process.execPath), this.options.env.PATH].filter(Boolean).join(delimiter), AGENT_DEVICE_DAEMON_AUTH_TOKEN: token, + AGENT_DEVICE_DAEMON_POLICY: policy, + AGENT_DEVICE_MACOS_APP_BACKEND: 'native', AGENT_DEVICE_NO_UPDATE_NOTIFIER: '1', }, detached: true, @@ -177,11 +244,12 @@ export class AgentDeviceDriver implements HostedAgentDriver { }, ); const url = await this.listening(proxy); - const daemon = this.readDaemon(); + const { record: daemon, admin } = this.readDaemon(); this.daemonRecord = daemon; const proxyIdentity = proxy.pid === undefined ? null : captureProcessIdentity(proxy.pid); if (!proxyIdentity?.ok) throw new Error('The agent-device proxy identity could not be captured.'); setClaimChild(claim, daemon); + if (!(await leasesMacosApps(url))) throw new AgentDriverUnavailable(UNSCOPED); const watch = setInterval(() => this.watchDaemon(), this.options.watchMs); watch.unref(); const running: Running = { @@ -191,6 +259,7 @@ export class AgentDeviceDriver implements HostedAgentDriver { daemon, url, token, + admin, watch, }; this.running = running; @@ -201,7 +270,7 @@ export class AgentDeviceDriver implements HostedAgentDriver { this.running = null; if (!this.daemonRecord) { try { - this.daemonRecord = this.readDaemon(); + this.daemonRecord = this.readDaemon().record; } catch {} } await this.teardown(proxy).catch(() => undefined); @@ -238,13 +307,23 @@ export class AgentDeviceDriver implements HostedAgentDriver { }); } - private readDaemon(): ProcessRecord { + private readDaemon(): { record: ProcessRecord; admin: DaemonAdmin } { const value: unknown = JSON.parse(readFileSync(join(this.options.stateDir, 'daemon.json'), 'utf8')); if (!isJsonObject(value) || typeof value.pid !== 'number' || !Number.isInteger(value.pid) || value.pid < 2) throw new Error('The agent-device daemon record has no pid.'); const identity = captureProcessIdentity(value.pid); if (!identity.ok) throw new Error('The agent-device daemon identity could not be captured.'); - return { pid: value.pid, processToken: identity.token }; + const record = { pid: value.pid, processToken: identity.token }; + if ( + typeof value.httpPort !== 'number' || + !Number.isInteger(value.httpPort) || + typeof value.token !== 'string' || + !value.token + ) { + this.daemonRecord = record; + throw new Error('The agent-device daemon record has no HTTP listener.'); + } + return { record, admin: { port: value.httpPort, token: value.token } }; } private watchDaemon(): void { @@ -263,6 +342,8 @@ export class AgentDeviceDriver implements HostedAgentDriver { async stop(): Promise { this.stopping = true; await this.starting?.catch(() => undefined); + for (const lease of this.leases.values()) clearInterval(lease.renew); + this.leases.clear(); const running = this.running; if (!running && !this.claim) return; this.running = null; @@ -318,30 +399,96 @@ export class AgentDeviceDriver implements HostedAgentDriver { return false; } - issue(_app: HostedAgentApp): Promise { - return Promise.reject(new AgentDriverUnavailable(UNSCOPED)); + /** + * Allocates a `macos-app` lease for exactly this app process over agent-device's host-only admin route, and + * renews it for as long as the app is hosted. The client names the lease; the forward pins it. + */ + async issue(app: HostedAgentApp): Promise { + const running = this.running; + if (!running) throw new Error('The agent-device daemon is not running.'); + const scope: HostedAgentLease = { + tenant: `stim.${app.session}`, + runId: app.session, + clientId: 'agent', + deviceKey: `${app.bundleId}@${app.pid}`, + }; + const id = randomBytes(16).toString('hex'); + await putLease(running.admin, id, scope); + await this.revoke(app.session); + const renew = setInterval(() => { + if (this.running !== running) return; + putLease(running.admin, id, scope).catch((error: unknown) => + process.stderr.write(`Hosted agent lease renewal failed: ${(error as Error).message}\n`), + ); + }, this.options.leaseRenewMs); + renew.unref(); + this.leases.set(app.session, { id, scope, renew }); + return { driver: 'agent-device', path: agentRoute(app.session), token: newAgentToken(), scope: id, lease: scope }; } - revoke(): Promise { - return Promise.resolve(); + async revoke(session: string): Promise { + const lease = this.leases.get(session); + if (!lease) return; + this.leases.delete(session); + clearInterval(lease.renew); + if (this.running) await adminRequest(this.running.admin, 'DELETE', lease.id); } forward(session: string, request: IncomingMessage, response: ServerResponse): void { const running = this.running; - if (!running) { + const lease = this.leases.get(session); + if (!running || !lease) { response.writeHead(503, { 'content-type': 'text/plain' }).end('Agent control is not running.\n'); return; } - const target = daemonPath(session, request.url ?? ''); + const target = daemonPath(session, request.url ?? '', request.method); if (!target) { response.writeHead(404, { 'content-type': 'text/plain' }).end('Not found.\n'); return; } - const headers: Record = { authorization: `Bearer ${running.token}` }; + if (!target.startsWith('/rpc')) { + this.relay(running, lease, request, response, target); + return; + } + void this.relayPinned(running, lease, request, response, target); + } + + private async relayPinned( + running: Running, + lease: Lease, + request: IncomingMessage, + response: ServerResponse, + target: string, + ): Promise { + let body: Buffer | null; + try { + body = await readBody(request, MAX_RPC_BYTES); + } catch { + 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'); + else this.relay(running, lease, request, response, target, pinned); + } + + private relay( + running: Running, + lease: Lease, + request: IncomingMessage, + response: ServerResponse, + target: string, + body?: Buffer, + ): void { + const headers: Record = { + authorization: `Bearer ${running.token}`, + 'x-agent-device-tenant': lease.scope.tenant, + }; for (const name of FORWARDED) { const value = request.headers[name]; if (typeof value === 'string') headers[name] = value; } + if (body) headers['content-length'] = String(body.length); const upstream = httpRequest( { host: running.url.hostname, port: running.url.port, method: request.method, path: target, headers }, (answer) => { @@ -360,18 +507,135 @@ export class AgentDeviceDriver implements HostedAgentDriver { response.once('close', () => { if (!response.writableFinished) upstream.destroy(); }); - request.pipe(upstream); + if (body) upstream.end(body); + else request.pipe(upstream); } } -const DAEMON_PATHS = /^\/(?:rpc|health|upload|artifacts|sessions)(?:\/[\w.~-]+)*\/?$/; +/** + * 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 { + 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; + const { tenant, runId, clientId, deviceKey } = lease.scope; + const owner = { runId, leaseId: lease.id, clientId, deviceKey, leaseProvider: 'proxy' }; + if (COMMAND_METHODS.has(rpc.method)) { + const meta = isJsonObject(rpc.params.meta) ? rpc.params.meta : {}; + rpc.params = { + ...rpc.params, + meta: { ...meta, ...owner, tenantId: tenant, leaseBackend: 'macos-app', sessionIsolation: 'tenant' }, + }; + } 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; + return Buffer.from(JSON.stringify(rpc)); +} + +function readBody(request: IncomingMessage, limit: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + request.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size <= limit) chunks.push(chunk); + }); + request.once('end', () => resolve(size > limit ? null : Buffer.concat(chunks))); + request.once('error', reject); + }); +} + +/** Whether the daemon behind a proxy advertises agent-device's `macos-app` lease backend in `/health`. */ +function leasesMacosApps(url: URL): Promise { + return new Promise((resolve) => { + const probe = httpRequest( + { host: url.hostname, port: url.port, method: 'GET', path: '/health', timeout: 10_000 }, + (answer) => { + let text = ''; + answer.on('data', (chunk: Buffer) => (text += chunk.toString())); + answer.once('end', () => { + try { + const health: unknown = JSON.parse(text); + const backends = + isJsonObject(health) && isJsonObject(health.upstream) ? health.upstream.leaseBackends : null; + resolve(Array.isArray(backends) && backends.includes('macos-app')); + } catch { + resolve(false); + } + }); + }, + ); + probe.once('timeout', () => probe.destroy()); + probe.once('error', () => resolve(false)); + probe.end(); + }); +} -/** The proxy path behind a session route, only for the routes agent-device's proxy serves; null otherwise. */ -function daemonPath(session: string, url: string): string | null { +function putLease(admin: DaemonAdmin, id: string, scope: HostedAgentLease): Promise { + return adminRequest(admin, 'PUT', id, { + tenantId: scope.tenant, + runId: scope.runId, + clientId: scope.clientId, + leaseBackend: 'macos-app', + leaseProvider: 'proxy', + deviceKey: scope.deviceKey, + ttlMs: LEASE_TTL_MS, + }); +} + +/** One call to agent-device's host-only `/admin/leases` route on the daemon's loopback listener. */ +function adminRequest(admin: DaemonAdmin, method: 'PUT' | 'DELETE', id: string, body?: object): Promise { + const payload = body ? Buffer.from(JSON.stringify(body)) : undefined; + return new Promise((resolve, reject) => { + const call = httpRequest( + { + host: '127.0.0.1', + port: admin.port, + method, + path: `/admin/leases/${id}`, + timeout: 10_000, + headers: { + authorization: `Bearer ${admin.token}`, + ...(payload ? { 'content-type': 'application/json', 'content-length': String(payload.length) } : {}), + }, + }, + (answer) => { + let text = ''; + answer.on('data', (chunk: Buffer) => (text += chunk.toString())); + answer.once('end', () => + answer.statusCode === 200 + ? resolve() + : reject(new Error(`agent-device refused the lease (${String(answer.statusCode)}): ${text.slice(0, 200)}`)), + ); + }, + ); + call.once('timeout', () => call.destroy(new Error('agent-device did not answer the lease request.'))); + call.once('error', reject); + call.end(payload); + }); +} + +const DAEMON_PATHS = /^\/(?:rpc|health|artifacts)(?:\/[\w.~-]+)*\/?$/; + +/** + * The proxy path behind a session route, only for commands (`/rpc`), health and artifact downloads; null + * otherwise. Uploads serve installs, which a macos-app lease refuses, and session diagnostics are not scoped + * to one client. + */ +function daemonPath(session: string, url: string, method: string | undefined): string | null { const prefix = `${AGENT_ROUTE_PREFIX}${session}`; if (!url.startsWith(prefix)) return null; const rest = url.slice(prefix.length); const query = rest.indexOf('?'); const path = query < 0 ? rest : rest.slice(0, query); - return DAEMON_PATHS.test(path) && !path.includes('..') ? rest : null; + if (!DAEMON_PATHS.test(path) || path.includes('..')) return null; + return (path.startsWith('/rpc') ? method === 'POST' : method === 'GET') ? rest : null; } diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 02ffe265e..fc71715ec 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -126,6 +126,20 @@ export function protocolJsonSchema(): JsonSchema { path: { type: 'string', pattern: '^/device-host/agent/[a-f0-9-]{36}/$' }, token: { type: 'string', pattern: '^[A-Za-z0-9_-]{32,256}$' }, scope: { type: 'string', pattern: '^[A-Za-z0-9._:-]{1,256}$' }, + lease: { + type: 'object', + required: ['tenant', 'runId', 'clientId', 'deviceKey'], + additionalProperties: false, + properties: { + tenant: { type: 'string', pattern: '^[A-Za-z0-9._-]{1,128}$' }, + runId: { type: 'string', pattern: '^[A-Za-z0-9._-]{1,128}$' }, + clientId: { type: 'string', pattern: '^[A-Za-z0-9._-]{1,128}$' }, + deviceKey: { + type: 'string', + pattern: '^[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)+@[1-9][0-9]{0,9}$', + }, + }, + }, }, }, ], diff --git a/packages/stim-cli/src/__tests__/hosted-macos-client.test.ts b/packages/stim-cli/src/__tests__/hosted-macos-client.test.ts index 97ffdcdd1..ce890dfda 100644 --- a/packages/stim-cli/src/__tests__/hosted-macos-client.test.ts +++ b/packages/stim-cli/src/__tests__/hosted-macos-client.test.ts @@ -50,7 +50,8 @@ const GRANT = { driver: 'agent-device', path: '/device-host/agent/12345678-1234-1234-1234-123456789abc/', token: GRANT_TOKEN, - scope: 'lease-1', + scope: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + lease: { tenant: 'stim.s', runId: 'run-1', clientId: 'agent', deviceKey: 'dev.fixture.app.hosted3@4242' }, }; const sha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex'); @@ -244,7 +245,14 @@ describe.skipIf(process.platform !== 'darwin')('stim macos --host (SwiftPM and c expect(JSON.parse(readFileSync(config, 'utf8'))).toEqual({ daemonBaseUrl: `https://mini.tail1.ts.net:7443${GRANT.path}`, daemonAuthToken: GRANT_TOKEN, - leaseId: 'lease-1', + tenant: 'stim.s', + sessionIsolation: 'tenant', + runId: 'run-1', + leaseId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + leaseBackend: 'macos-app', + leaseProvider: 'proxy', + clientId: 'agent', + deviceKey: 'dev.fixture.app.hosted3@4242', platform: 'macos', }); expect(workspaceInUse(root, { managedLocks: false, nativeRun: false })).toEqual(['its macOS app runs on mini']); diff --git a/packages/stim-cli/src/device-host/hosted-macos.ts b/packages/stim-cli/src/device-host/hosted-macos.ts index 5448b7834..f46ec2b5e 100644 --- a/packages/stim-cli/src/device-host/hosted-macos.ts +++ b/packages/stim-cli/src/device-host/hosted-macos.ts @@ -222,7 +222,14 @@ function agentAccess( const config = { daemonBaseUrl: `https://${credential.dnsName}${port === 443 ? '' : `:${port}`}${parsed.path}`, daemonAuthToken: parsed.token, + tenant: parsed.lease.tenant, + sessionIsolation: 'tenant', + runId: parsed.lease.runId, leaseId: parsed.scope, + leaseBackend: 'macos-app', + leaseProvider: 'proxy', + clientId: parsed.lease.clientId, + deviceKey: parsed.lease.deviceKey, platform: 'macos', }; mkdirSync(macosDir(root), { recursive: true }); diff --git a/packages/stim-cli/src/guide/settings.ts b/packages/stim-cli/src/guide/settings.ts index 198c096b5..73fe2775d 100644 --- a/packages/stim-cli/src/guide/settings.ts +++ b/packages/stim-cli/src/guide/settings.ts @@ -560,9 +560,11 @@ Access (Accessibility on macOS 26 and earlier) for Stim Host, the app On a hosting Mac, \`hosting.agentDriver\` names the tool it starts so a client's coding agent can drive the macOS apps it hosts for that client. The default, \`none\`, starts nothing. \`agent-device\` starts its -daemon only once agent-device can lease a single macOS app. Until then -agent control reports \`none\` with a notice, and no client is handed the -Mac's desktop. \`doctor\` on that Mac notes a hosted macOS app that runs while +daemon only when that agent-device can lease a single macOS app (its +\`macos-app\` lease backend); otherwise agent control reports \`none\` with a +notice, and no client is handed the Mac's desktop. STIM_AGENT_DEVICE_BIN in +stim-server's environment names an agent-device binary to use instead of +~/.local/bin/agent-device. \`doctor\` on that Mac notes a hosted macOS app that runs while the setting is \`none\`. BUILD MACHINES ARE MACHINE-LEVEL diff --git a/website/docs/settings.md b/website/docs/settings.md index d309bd183..b0eae055e 100644 --- a/website/docs/settings.md +++ b/website/docs/settings.md @@ -329,9 +329,11 @@ reports `busy` instead of rotating a pending token. On a hosting Mac, `hosting.agentDriver` names the tool it starts so a client's coding agent can drive the macOS apps it hosts for that client. The default, -`none`, starts nothing. `agent-device` starts its daemon -only once agent-device can lease a single macOS app. Until then agent control -reports `none` with a notice, and no client is handed the Mac's desktop. +`none`, starts nothing. `agent-device` starts its daemon only when that +agent-device can lease a single macOS app (its `macos-app` lease backend); +otherwise agent control reports `none` with a notice, and no client is handed +the Mac's desktop. `STIM_AGENT_DEVICE_BIN` in `stim-server`'s environment names +an agent-device binary to use instead of `~/.local/bin/agent-device`. `stim doctor` on that Mac notes a hosted macOS app that runs while the setting is `none`. From da8b2b7e8948a494bd58524f5d0fe1109fc4dfc3 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 5 Oct 2026 13:31:59 -0400 Subject: [PATCH 2/5] fix: keep host paths and runtime hints out of forwarded agent commands The forward keeps only reporting metadata from the client, drops runtime hints, revokes a session's old lease before allocating a new one, and drops diff from the allowed commands. The doctor note, guide and website say how an agent starts and what the lease allows. --- .../__tests__/agent-device-driver.test.ts | 13 ++++++++- packages/server/src/agent-device-driver.ts | 27 +++++++++++++++---- .../stim-cli/src/device-host/agent-driver.ts | 4 +-- packages/stim-cli/src/guide/macos.ts | 6 ++++- website/docs/macos.md | 7 ++++- 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/packages/server/__tests__/agent-device-driver.test.ts b/packages/server/__tests__/agent-device-driver.test.ts index 8512e7bd2..35a03e8f8 100644 --- a/packages/server/__tests__/agent-device-driver.test.ts +++ b/packages/server/__tests__/agent-device-driver.test.ts @@ -359,9 +359,20 @@ describe.skipIf(process.platform === 'win32')('agent-device driver', () => { params: { command: 'open', session: 'other-client', - meta: { tenantId: 'stim.other', leaseId: 'f'.repeat(32), sessionIsolation: 'none', requestId: 'r1' }, + runtime: { launchUrl: 'other://open' }, + meta: { + tenantId: 'stim.other', + leaseId: 'f'.repeat(32), + sessionIsolation: 'none', + requestId: 'r1', + cwd: '/Users/someone', + developerDir: '/Applications/Xcode.app', + installSource: { kind: 'path', path: '/etc' }, + lockPolicy: 'strip', + }, }, }); + expect(command.upstream?.params.runtime).toBeUndefined(); expect(command.upstream?.params.meta).toEqual({ requestId: 'r1', tenantId: grant.lease.tenant, diff --git a/packages/server/src/agent-device-driver.ts b/packages/server/src/agent-device-driver.ts index 9478998e4..b3adbbb5c 100644 --- a/packages/server/src/agent-device-driver.ts +++ b/packages/server/src/agent-device-driver.ts @@ -62,7 +62,6 @@ const POLICY = { 'open', 'close', 'snapshot', - 'diff', 'wait', 'find', 'get', @@ -412,9 +411,9 @@ export class AgentDeviceDriver implements HostedAgentDriver { clientId: 'agent', deviceKey: `${app.bundleId}@${app.pid}`, }; + await this.revoke(app.session); const id = randomBytes(16).toString('hex'); await putLease(running.admin, id, scope); - await this.revoke(app.session); const renew = setInterval(() => { if (this.running !== running) return; putLease(running.admin, id, scope).catch((error: unknown) => @@ -528,10 +527,16 @@ function pinLease(body: Buffer, lease: Lease): Buffer | null { const { tenant, runId, clientId, deviceKey } = lease.scope; const owner = { runId, leaseId: lease.id, clientId, deviceKey, leaseProvider: 'proxy' }; if (COMMAND_METHODS.has(rpc.method)) { - const meta = isJsonObject(rpc.params.meta) ? rpc.params.meta : {}; + const { runtime: _runtime, meta, ...params } = rpc.params; rpc.params = { - ...rpc.params, - meta: { ...meta, ...owner, tenantId: tenant, leaseBackend: 'macos-app', sessionIsolation: 'tenant' }, + ...params, + meta: { + ...pickClientMeta(meta), + ...owner, + tenantId: tenant, + leaseBackend: 'macos-app', + sessionIsolation: 'tenant', + }, }; } else if (LEASE_METHODS.has(rpc.method)) { const { tenant: _tenant, provider: _provider, ...params } = rpc.params; @@ -540,6 +545,18 @@ function pinLease(body: Buffer, lease: Lease): Buffer | null { return Buffer.from(JSON.stringify(rpc)); } +/** + * The request metadata a client may set: reporting preferences and the local paths its own artifacts land + * at. Host paths (`cwd`, `developerDir`), install sources, lock and lease options and runtime hints stay with + * the host. + */ +const CLIENT_META = ['requestId', 'debug', 'includeCost', 'responseLevel', 'sessionExplicit', 'clientArtifactPaths']; + +function pickClientMeta(meta: unknown): Record { + if (!isJsonObject(meta)) return {}; + return Object.fromEntries(CLIENT_META.filter((key) => meta[key] !== undefined).map((key) => [key, meta[key]])); +} + function readBody(request: IncomingMessage, limit: number): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; diff --git a/packages/stim-cli/src/device-host/agent-driver.ts b/packages/stim-cli/src/device-host/agent-driver.ts index b00292609..59b9c4eec 100644 --- a/packages/stim-cli/src/device-host/agent-driver.ts +++ b/packages/stim-cli/src/device-host/agent-driver.ts @@ -9,8 +9,8 @@ export function hostedAgentDriverFinding(driver: unknown, apps: number): Finding level: 'note', title: `${apps === 1 ? 'A hosted macOS app runs' : `${apps} hosted macOS apps run`} with no agent driver`, detail: - "The client's coding agent cannot drive these apps because `hosting.agentDriver` is none, so stim-server starts no driver. agent-device starts only once it can lease a single macOS app.", - fix: 'stim settings set hosting.agentDriver agent-device, once agent-device can lease one macOS app', + "The client's coding agent cannot drive these apps because `hosting.agentDriver` is none, so stim-server starts no driver. agent-device starts only when its daemon offers the macos-app lease, which limits a client to one app.", + fix: 'stim settings set hosting.agentDriver agent-device, with an agent-device that offers the macos-app lease', }; } diff --git a/packages/stim-cli/src/guide/macos.ts b/packages/stim-cli/src/guide/macos.ts index a7aefcad7..f84b1be82 100644 --- a/packages/stim-cli/src/guide/macos.ts +++ b/packages/stim-cli/src/guide/macos.ts @@ -191,7 +191,11 @@ how a coding agent drives the app: { driver: "none", setting: "hosting.agentDriver" } until the hosting Mac's owner sets that setting there, or { driver: "agent-device", remoteConfig, command }. remoteConfig is a mode 0600 file in the workspace directory that holds the credential; run the command -it names and never print the file. stop deletes it. +it names and never print the file. stop deletes it. 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. 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 f0f64ba4e..f3fabcbcd 100644 --- a/website/docs/macos.md +++ b/website/docs/macos.md @@ -203,7 +203,12 @@ confirm a live app) and does not contact the host. `agent` is owner turns on a driver with that setting. With `agent-device`, it names a `remoteConfig` file (mode 0600, in the workspace directory) and the `command` to run, such as `agent-device screenshot --remote-config `. The credential stays -in that file and never appears in command output. +in that file and never appears in command output. Start with +`agent-device open --remote-config `; the lease allows only +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. Copy this prompt: From cf0745fa56c85ff6c02f378310756e6fec7d0111 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 5 Oct 2026 13:53:52 -0400 Subject: [PATCH 3/5] fix: confine forwarded agent commands to the policy list and the macOS platform --- packages/server/README.md | 4 +- .../__tests__/agent-device-driver.test.ts | 57 +++++++++++++++++- packages/server/src/agent-device-driver.ts | 59 ++++++++++++++++--- 3 files changed, 109 insertions(+), 11 deletions(-) diff --git a/packages/server/README.md b/packages/server/README.md index c8d8af264..f34e5e7cf 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -308,7 +308,9 @@ daemon token, renews it while the app runs and releases it when the app stops or the grant is revoked. The grant carries the lease id and owner scope. The forward passes only `POST /rpc`, `GET /health` and `GET /artifacts/...`, and it rewrites every command and lease call to that session's lease, tenant and -tenant session isolation, so a client cannot name another lease or session. +tenant session isolation, so a client cannot name another lease or session. It +refuses commands outside the daemon policy's list (also inside a `batch`), +drops device selectors and runtime hints, and sets `platform` to `macos`. ### Hosted iOS session protocol diff --git a/packages/server/__tests__/agent-device-driver.test.ts b/packages/server/__tests__/agent-device-driver.test.ts index 35a03e8f8..6e3f658b9 100644 --- a/packages/server/__tests__/agent-device-driver.test.ts +++ b/packages/server/__tests__/agent-device-driver.test.ts @@ -310,7 +310,7 @@ describe.skipIf(process.platform === 'win32')('agent-device driver', () => { SESSION, 'POST', '/rpc?x=1', - '{"method":"agent_device.command","params":{}}', + '{"method":"agent_device.command","params":{"command":"snapshot"}}', { 'x-agent-device-tenant': 'stim.other', authorization: 'Bearer client-grant-token', @@ -384,6 +384,61 @@ describe.skipIf(process.platform === 'win32')('agent-device driver', () => { leaseBackend: 'macos-app', sessionIsolation: 'tenant', }); + const selected = await rpc({ + method: 'agent_device.command', + params: { + command: 'batch', + flags: { + udid: 'SIM-1', + serial: 'emulator-5554', + device: 'iPhone', + target: 'mobile', + iosSimulatorDeviceSet: '/tmp/set', + androidDeviceAllowlist: 'emulator-5554', + platform: 'ios', + surface: 'app', + batchSteps: [ + { + command: 'open', + positionals: ['dev.example.app.hosted1'], + flags: { udid: 'SIM-2', platform: 'android' }, + input: { serial: 's' }, + runtime: { launchUrl: 'x://y' }, + }, + { command: 'snapshot' }, + ], + }, + input: { udid: 'SIM-3', text: 'hi' }, + }, + }); + expect(selected.upstream?.params).toMatchObject({ + flags: { + platform: 'macos', + surface: 'app', + batchSteps: [ + { command: 'open', positionals: ['dev.example.app.hosted1'], flags: { platform: 'macos' }, input: {} }, + { command: 'snapshot', flags: { platform: 'macos' } }, + ], + }, + input: { text: 'hi' }, + }); + expect(Object.keys((selected.upstream?.params.flags as object) ?? {}).sort()).toEqual([ + 'batchSteps', + 'platform', + 'surface', + ]); + const bareOpen = await rpc({ method: 'agent_device.command', params: { command: 'open' } }); + expect(bareOpen.upstream?.params.flags).toEqual({ platform: 'macos' }); + for (const command of ['session_list', 'lease_release', 'install', 'devices', 'diff', undefined]) + expect((await rpc({ method: 'agent_device.command', params: { command } })).status).toBe(400); + expect( + ( + await rpc({ + method: 'agent_device.command', + params: { command: 'batch', flags: { batchSteps: [{ command: 'snapshot' }, { command: 'session_list' }] } }, + }) + ).status, + ).toBe(400); const heartbeat = await rpc({ method: 'agent_device.lease.heartbeat', params: { tenant: 'stim.other', leaseId: 'f'.repeat(32), backend: 'ios-instance', provider: 'x' }, diff --git a/packages/server/src/agent-device-driver.ts b/packages/server/src/agent-device-driver.ts index b3adbbb5c..e346feeb2 100644 --- a/packages/server/src/agent-device-driver.ts +++ b/packages/server/src/agent-device-driver.ts @@ -149,6 +149,7 @@ interface Lease { id: string; scope: HostedAgentLease; renew: NodeJS.Timeout; + renewing: Promise; } const FORWARDED = [ @@ -414,14 +415,19 @@ export class AgentDeviceDriver implements HostedAgentDriver { await this.revoke(app.session); const id = randomBytes(16).toString('hex'); await putLease(running.admin, id, scope); - const renew = setInterval(() => { - if (this.running !== running) return; - putLease(running.admin, id, scope).catch((error: unknown) => - process.stderr.write(`Hosted agent lease renewal failed: ${(error as Error).message}\n`), - ); - }, this.options.leaseRenewMs); - renew.unref(); - this.leases.set(app.session, { id, scope, renew }); + const lease: Lease = { + id, + scope, + renewing: Promise.resolve(), + renew: setInterval(() => { + if (this.running !== running) return; + lease.renewing = putLease(running.admin, id, scope).catch((error: unknown) => + process.stderr.write(`Hosted agent lease renewal failed: ${(error as Error).message}\n`), + ); + }, this.options.leaseRenewMs), + }; + lease.renew.unref(); + this.leases.set(app.session, lease); return { driver: 'agent-device', path: agentRoute(app.session), token: newAgentToken(), scope: id, lease: scope }; } @@ -430,6 +436,7 @@ export class AgentDeviceDriver implements HostedAgentDriver { if (!lease) return; this.leases.delete(session); clearInterval(lease.renew); + await lease.renewing; if (this.running) await adminRequest(this.running.admin, 'DELETE', lease.id); } @@ -527,9 +534,18 @@ function pinLease(body: Buffer, lease: Lease): Buffer | null { 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, ...params } = rpc.params; + const { runtime: _runtime, meta, flags, input, ...params } = rpc.params; + if (!isAllowedCommand(params.command)) return null; + const steps = isJsonObject(flags) && Array.isArray(flags.batchSteps) ? flags.batchSteps : []; + if (steps.some((step) => !isJsonObject(step) || !isAllowedCommand(step.command))) return null; rpc.params = { ...params, + flags: { + ...withoutDeviceSelectors(flags), + platform: 'macos', + ...(steps.length ? { batchSteps: steps.map(pinStep) } : {}), + }, + ...(isJsonObject(input) ? { input: withoutDeviceSelectors(input) } : {}), meta: { ...pickClientMeta(meta), ...owner, @@ -545,6 +561,31 @@ function pinLease(body: Buffer, lease: Lease): Buffer | null { return Buffer.from(JSON.stringify(rpc)); } +/** + * Flags that pick a device. The host forces `platform: macos` so a first `open` cannot resolve to a host + * simulator, emulator or phone; agent-device's `macos-app` admission enforces the same rule. + */ +const DEVICE_SELECTORS = ['device', 'udid', 'serial', 'target', 'iosSimulatorDeviceSet', 'androidDeviceAllowlist']; + +function withoutDeviceSelectors(fields: unknown): Record { + if (!isJsonObject(fields)) return {}; + return Object.fromEntries(Object.entries(fields).filter(([key]) => !DEVICE_SELECTORS.includes(key))); +} + +function pinStep(step: unknown): unknown { + if (!isJsonObject(step)) return step; + const { runtime: _runtime, flags, input, ...rest } = step; + return { + ...rest, + flags: { ...withoutDeviceSelectors(flags), platform: 'macos' }, + ...(isJsonObject(input) ? { input: withoutDeviceSelectors(input) } : {}), + }; +} + +function isAllowedCommand(command: unknown): boolean { + return typeof command === 'string' && POLICY.commands.allow.includes(command); +} + /** * The request metadata a client may set: reporting preferences and the local paths its own artifacts land * at. Host paths (`cwd`, `developerDir`), install sources, lock and lease options and runtime hints stay with From c700ce291f0a1c7e38f700101ae68a8a217a2042 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 5 Oct 2026 13:56:33 -0400 Subject: [PATCH 4/5] test: satisfy lint in the agent-device driver test --- packages/server/__tests__/agent-device-driver.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/server/__tests__/agent-device-driver.test.ts b/packages/server/__tests__/agent-device-driver.test.ts index 6e3f658b9..3839b0095 100644 --- a/packages/server/__tests__/agent-device-driver.test.ts +++ b/packages/server/__tests__/agent-device-driver.test.ts @@ -422,15 +422,15 @@ describe.skipIf(process.platform === 'win32')('agent-device driver', () => { }, input: { text: 'hi' }, }); - expect(Object.keys((selected.upstream?.params.flags as object) ?? {}).sort()).toEqual([ + expect(Object.keys((selected.upstream?.params.flags as object) ?? {}).toSorted()).toEqual([ 'batchSteps', 'platform', 'surface', ]); const bareOpen = await rpc({ method: 'agent_device.command', params: { command: 'open' } }); expect(bareOpen.upstream?.params.flags).toEqual({ platform: 'macos' }); - for (const command of ['session_list', 'lease_release', 'install', 'devices', 'diff', undefined]) - expect((await rpc({ method: 'agent_device.command', params: { command } })).status).toBe(400); + for (const denied of ['session_list', 'lease_release', 'install', 'devices', 'diff', undefined]) + expect((await rpc({ method: 'agent_device.command', params: { command: denied } })).status).toBe(400); expect( ( await rpc({ From 917ad85d8684bb58f8bf085a336ff0d15d699593 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 5 Oct 2026 14:06:18 -0400 Subject: [PATCH 5/5] test: cover revoke ordering and selector stripping in forwarded agent commands --- .../__tests__/agent-device-driver.test.ts | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/server/__tests__/agent-device-driver.test.ts b/packages/server/__tests__/agent-device-driver.test.ts index 3839b0095..2827beeb8 100644 --- a/packages/server/__tests__/agent-device-driver.test.ts +++ b/packages/server/__tests__/agent-device-driver.test.ts @@ -47,9 +47,14 @@ if (args[0] === 'proxy' && process.env.FAKE_PROXY === 'daemon-only') { request.on('data', (chunk) => chunks.push(chunk)); request.on('end', () => { const ok = request.headers.authorization === 'Bearer daemon-secret' && process.env.FAKE_ADMIN !== 'refuse'; - appendFileSync(join(stateDir, 'admin.log'), JSON.stringify({ method: request.method, url: request.url, body: Buffer.concat(chunks).toString() }) + '\\n'); - response.writeHead(ok ? 200 : 400, { 'content-type': 'application/json', connection: 'close' }); - response.end(JSON.stringify({ ok })); + const entry = JSON.stringify({ method: request.method, url: request.url, body: Buffer.concat(chunks).toString() }) + '\\n'; + const slow = request.method === 'PUT' ? Number(process.env.FAKE_ADMIN_PUT_MS ?? 0) : 0; + appendFileSync(join(stateDir, 'admin-started.log'), entry); + setTimeout(() => { + appendFileSync(join(stateDir, 'admin.log'), entry); + response.writeHead(ok ? 200 : 400, { 'content-type': 'application/json', connection: 'close' }); + response.end(JSON.stringify({ ok })); + }, slow); }); }); admin.listen(0, '127.0.0.1', () => { @@ -219,6 +224,29 @@ describe.skipIf(process.platform === 'win32')('agent-device driver', () => { } }); + test('revoke waits for a renewal in flight so the DELETE is the last admin call', async () => { + install(root); + const driver = driverIn(root, { leaseRenewMs: 300 }, { FAKE_ADMIN_PUT_MS: '200' }); + await driver.start(); + try { + await driver.issue({ client: 'c', session: SESSION, bundleId: 'dev.example.app.hosted1', pid: 4242 }); + const started = () => + readFileSync(join(root, 'state', 'admin-started.log'), 'utf8') + .trim() + .split('\n'); + await vi.waitFor(() => expect(started().length).toBeGreaterThan(1)); + await driver.revoke(SESSION); + const calls = readFileSync(join(root, 'state', 'admin.log'), 'utf8') + .trim() + .split('\n') + .map((line) => (JSON.parse(line) as { method: string }).method); + expect(calls.at(-1)).toBe('DELETE'); + expect(calls.filter((method) => method === 'PUT')).toHaveLength(2); + } finally { + await driver.stop(); + } + }); + test('issues no grant when agent-device refuses the lease', async () => { install(root); const driver = driverIn(root, {}, { FAKE_ADMIN: 'refuse' }); @@ -422,11 +450,18 @@ describe.skipIf(process.platform === 'win32')('agent-device driver', () => { }, input: { text: 'hi' }, }); - expect(Object.keys((selected.upstream?.params.flags as object) ?? {}).toSorted()).toEqual([ - 'batchSteps', - 'platform', - 'surface', - ]); + const sent = selected.upstream?.params as { + flags: { batchSteps: Record[] } & Record; + input: unknown; + }; + expect(Object.keys(sent.flags).toSorted()).toEqual(['batchSteps', 'platform', 'surface']); + expect(sent.input).toEqual({ text: 'hi' }); + expect(sent.flags.batchSteps[0]).toEqual({ + command: 'open', + positionals: ['dev.example.app.hosted1'], + flags: { platform: 'macos' }, + input: {}, + }); const bareOpen = await rpc({ method: 'agent_device.command', params: { command: 'open' } }); expect(bareOpen.upstream?.params.flags).toEqual({ platform: 'macos' }); for (const denied of ['session_list', 'lease_release', 'install', 'devices', 'diff', undefined])