Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/core/__tests__/hosted-macos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand All @@ -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();
});
Expand Down
41 changes: 38 additions & 3 deletions packages/core/state/hosted-macos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<bundleId>@<pid>` 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<HostedAgentDriverName, 'none'>; path: string; token: string; scope: string };
| {
driver: Exclude<HostedAgentDriverName, 'none'>;
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 };
Expand Down Expand Up @@ -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 {
Expand All @@ -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<HostedAgentDriverName, 'none'>,
path: value.path,
token: value.token,
scope: value.scope,
lease: { ...lease },
};
}

Expand Down
24 changes: 19 additions & 5 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<bundleId>@<pid>` 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

Expand Down
Loading
Loading