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..f34e5e7cf 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -292,11 +292,25 @@ 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. 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 0d502499b..2827beeb8 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,43 @@ 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'; + 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); }); }); - 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 +102,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 +153,112 @@ 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('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' }); + 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 +275,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 +326,174 @@ 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":{"command":"snapshot"}}', + { + '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', + 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, + 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 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' }, + }); + 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]) + expect((await rpc({ method: 'agent_device.command', params: { command: denied } })).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' }, + }); + 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 +563,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..e346feeb2 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,45 @@ 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', + '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 +100,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 +110,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 +122,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 +135,23 @@ 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; + renewing: Promise; +} + 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,102 @@ 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}`, + }; + await this.revoke(app.session); + const id = randomBytes(16).toString('hex'); + await putLease(running.admin, id, scope); + 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 }; } - 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); + await lease.renewing; + 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 +513,187 @@ 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 { 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, + 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)); +} -/** 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 { +/** + * 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 + * 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[] = []; + 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(); + }); +} + +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/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/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/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/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/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: 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`.