Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fe45fbb
test: a device.exec caller's simctl device verb runs with the lease's…
V3RON Oct 24, 2026
a3e990f
feat(ios): simctl fills the lease's UDID into a device verb (#958)
V3RON Oct 24, 2026
2e3b36e
test: a device.exec caller's simctl command names another device and …
V3RON Oct 24, 2026
af7745c
feat(ios): simctl refuses another device and another platform (#958)
V3RON Oct 24, 2026
6550f0a
test: a device.exec caller's emulator adb command runs with -s <seria…
V3RON Oct 24, 2026
1c4c432
feat(android): adb fills the emulator's serial into a command that na…
V3RON Oct 24, 2026
9847286
test: a device.exec caller's emulator adb command names another devic…
V3RON Oct 24, 2026
8930904
feat(android): adb refuses another device and another platform (#958)
V3RON Oct 24, 2026
2db7551
test: a device.exec caller's devicectl command names another device a…
V3RON Oct 24, 2026
caf3a5b
feat(ios): devicectl refuses a --device that is not the lease's (#958)
V3RON Oct 24, 2026
4d1ac19
docs(drivers): device.exec fills in the lease's device and refuses an…
V3RON Oct 24, 2026
8886426
refactor(drivers): split the device checks to stay under the complexi…
V3RON Oct 24, 2026
bc9e0ba
docs(exec): the flow says the driver, not the daemon, fills in and ch…
V3RON Oct 24, 2026
9fc753d
fix(drivers): an emulator adb command naming its own serial with -t i…
V3RON Oct 24, 2026
c0d3117
docs(exec): -t with the lease's own serial is accepted like -s (#958)
V3RON Oct 24, 2026
ac9187b
fix(drivers): an emulator adb command refuses -t whatever it names; o…
V3RON Oct 24, 2026
473d52b
docs(exec): own-device naming is true per lease kind; driver manuals …
V3RON Oct 24, 2026
58c8899
Merge remote-tracking branch 'origin/feature/951-adr' into HEAD
V3RON Oct 24, 2026
8f968d8
Merge remote-tracking branch 'origin/feature/951-adr' into HEAD
V3RON Oct 24, 2026
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
10 changes: 6 additions & 4 deletions docs/internal/flows/device-exec.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ Runs the command on the machine that owns the device and streams its output back
4. Resolve — `DriverCatalog.passthrough(tool, device, args, { hasTerminal: false, platform })`.
The first driver whose `passthroughTools` lists the tool answers; none →
`UNKNOWN_PASSTHROUGH_TOOL`. A refusal → `PASSTHROUGH_REFUSED`.
The driver fills the lease's device into the command, and refuses one that names
another device (ADR 0047), see "Resolution per platform".
5. Env — the daemon's `process.env` + the command's `env`, minus its `unsetEnv`.
6. Spawn — `ProcessRunner.spawnStreaming`, pipes only. `stdin` is written once,
then closed.
Expand All @@ -43,14 +45,14 @@ Runs the command on the machine that owns the device and streams its output back

| Tool | Device | Command | Refused |
|---|---|---|---|
| `simctl` | iOS simulator | `xcrun simctl --set <device root> <args>` | `create`, `erase`, `delete`, `shutdown all`, `runtime delete`, any flag before the subcommand, a physical device |
| `devicectl` | iOS physical | `xcrun devicectl <cmd> --device <udid> <rest>`; `--device` added unless the args name one | a command not on the allowed list (`src/drivers/ios/devicectl-exec.ts`), an option off it, host-path options (`--json-output`, `--log-output`, `--destination`, `--file`, `--filter`), a simulator |
| `adb` | Android emulator | `adb -P <simlock port> <args>`, `ANDROID_ADB_SERVER_PORT` set | bare `adb shell`, `kill-server`, `emu kill`, `emu avd stop`, `emu avd snapshot delete`, globals other than `-s`/`-t`/`-d`/`-e` |
| `simctl` | iOS simulator | `xcrun simctl --set <device root> <args>`; the lease's UDID goes first among the operands of a device verb (`src/drivers/ios/simctl-device.ts`) | another UDID or `booted` on a device verb, `simctl` on a non-iOS lease, `create`, `erase`, `delete`, `shutdown all`, `runtime delete`, any flag before the subcommand, a physical device |
| `devicectl` | iOS physical | `xcrun devicectl <cmd> --device <udid> <rest>`; `--device` added unless the args name one; a `--device` / `-d` that is not the lease's UDID is refused | a command not on the allowed list (`src/drivers/ios/devicectl-exec.ts`), an option off it, host-path options (`--json-output`, `--log-output`, `--destination`, `--file`, `--filter`), a simulator |
| `adb` | Android emulator | `adb -P <simlock port> -s <serial> <args>`, `ANDROID_ADB_SERVER_PORT` set; no `-s` for a no-device subcommand (`devices`, `version`, `forward --list`, …) or an empty address | `-s` other than the lease's serial, `-t` whatever it names, `-e`, `-d`, `adb` on a non-Android lease, bare `adb shell`, `kill-server`, `emu kill`, `emu avd stop`, `emu avd snapshot delete`, globals other than `-s`/`-t`/`-d`/`-e` |
| `adb` | Android physical | `adb -P <default port> -s <serial> <args>`; strips `ANDROID_ADB_SERVER_ADDRESS`, `ADB_SERVER_SOCKET`, `ANDROID_SERIAL` | bare `adb shell`, any leading flag, server and transport verbs (`kill-server`, `connect`, `tcpip`, `usb`, …), `reboot bootloader`/`sideload`, a closed default-server gate |

A bare `adb shell` is refused only here (`hasTerminal: false`); the local
`simlock adb shell` stays interactive. For `simctl` and `adb` the daemon parses
no argument beyond these lists; the arguments name the device.
no argument beyond these lists; the driver (not the daemon) fills in and checks the device.

## Through a gateway

Expand Down
3 changes: 2 additions & 1 deletion src/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ Surface: [cli.mdx](../../website/src/content/docs/docs/reference/cli.mdx) — ev
Lease lost → exit 14. Connection lost → `DAEMON_CONNECTION_LOST`; the lease runs to its deadline.
5. **`simlock simctl` / `adb`** — reads `status.get` `daemon.mode`:
- worker: `driver.passthrough` returns a scoped command; `spawnPassthrough` runs it here.
- gateway: `device.exec` on `--lease`, or the requester's only lease. A piped stdin is
- gateway: `device.exec` on `--lease`, or the requester's only lease; the worker's driver
fills the lease's device in and refuses another (ADR 0047). A piped stdin is
read to EOF first; output chunks go to stdout and stderr.
`PASSTHROUGH_REFUSED` and `UNKNOWN_PASSTHROUGH_TOOL` become usage errors (exit 2).
6. **`simlock events --follow`** — subscribe, then replay, then print buffered pushes not
Expand Down
5 changes: 3 additions & 2 deletions src/contract/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,9 @@ export const driverPassthrough = defineOperation({
* unlike `driver.passthrough`, which only builds a string -- this one actually runs a command
* against a machine, so it is gated on the caller being who the lease was granted to (see the
* `authorize` hook below). `leaseId` is that proof: for `simctl` and `adb` the command's own
* arguments still name the device (a udid, a serial) and the daemon parses none of them; for
* `devicectl` the driver adds the lease's `--device` unless the arguments name one.
* arguments may leave the device out and the daemon parses none of them: the driver puts the
* lease's device where the tool expects it (ADR 0047 §1) and refuses a command that names
* another one.
*
* A bare `adb shell` (nothing to run) is refused by the driver on this path, because there is
* no terminal to attach it to and it would otherwise sit until `exec.timeoutMs` -- the same
Expand Down
8 changes: 4 additions & 4 deletions src/daemon/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,10 +702,10 @@ export class Dispatcher {
* `leaseId` is checked against the registry here so an id that names no lease answers
* `UNKNOWN_LEASE` rather than running a command (`authorize`'s `ownsLease` deliberately lets
* an unknown id through so this handler can say that -- see `roles.ts`). Which device the
* command touches is the driver's answer: `simctl` and `adb` take it from the command's own
* arguments, which this daemon does not parse, and `devicectl` gets the lease's `--device`
* added when the arguments name none (`devicectl-exec.ts`). That is the same accident
* boundary ADR 0001 draws for the local wrappers, reached over the wire.
* command touches is the driver's answer: it fills the lease's device in where the tool
* expects it and refuses a command that names another (ADR 0047 §1-2); this daemon parses
* none of the arguments. That is the same accident boundary ADR 0001 draws for the local
* wrappers, reached over the wire.
*
* Nothing is buffered: each chunk goes straight to `session.onOutput` as it arrives (§19e),
* so a command that writes a gigabyte costs this process nothing, and a client sees the
Expand Down
2 changes: 2 additions & 0 deletions src/drivers/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Manuals: [Android](android/AGENTS.md), [iOS](ios/AGENTS.md). Flows: [device sele
streams, SIGTERM on abort, SIGKILL 10 s later. `runBoundedProcess` adds a deadline.
6. **Passthrough** — the driver owns the scoping flags and the refused verbs
(`PassthroughRefusedError`). `DriverCatalog.passthrough` routes by `passthroughTools`.
With a device (`device.exec`) the driver also fills the lease's device in and refuses a
command naming another, through `other-device-refusal.ts` (ADR 0047).
`hasTerminal: false` means `device.exec`: refuse what needs a tty.
7. **Lease environment** — set on the lease at grant from `leaseEnvironment`; values are per platform.
8. **Facts reach the bus through callbacks.** A driver never imports the bus; `daemon/main.ts`
Expand Down
10 changes: 5 additions & 5 deletions src/drivers/android/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,13 @@ the 5037 server's protocol must equal Simlock's `adb version`; nothing listening
Mismatch or unreadable → no command, advisory `adb-protocol-mismatch`. Enrol reads model,
`ro.build.version.sdk`, current user, `pm list packages -3`. Class is always `phone`.

Passthrough — virtual: `adb -P <port> …`. Refused: `kill-server` anywhere, `emu kill`,
`emu avd stop`, `emu avd snapshot delete`, any global but `-s`/`-t`/`-d`/`-e` (or a lone
`--version`/`--help`), a bare `shell` with no terminal. Physical: `adb -P 5037 -s <serial> …`;
Passthrough — virtual: `adb -P <port> …`; with a device that has an address (`device.exec`) `-s <serial>` goes
first unless the subcommand is on the no-device list (ADR 0047). Refused: another `-s`, `-e`, `-d`, `-t`,
`adb` on a non-Android lease, `kill-server`, `emu kill`, `emu avd stop`, `emu avd snapshot delete`, other
globals (a lone `--version`/`--help` passes), a bare `shell` with no terminal. Physical: `adb -P 5037 -s <serial> …`;
refuses any leading flag, server and transport verbs, `reboot` to bootloader/recovery/sideload/fastboot.

Lease environment: virtual `ANDROID_ADB_SERVER_PORT=<port>`; physical
`ANDROID_ADB_SERVER_PORT=5037`, `ANDROID_SERIAL=<serial>`.
Lease environment: virtual `ANDROID_ADB_SERVER_PORT=<port>`; physical `ANDROID_ADB_SERVER_PORT=5037`, `ANDROID_SERIAL=<serial>`.

No slim: `resolveSpec` ignores `mode` and returns a full spec. `fresh` is core's.

Expand Down
99 changes: 97 additions & 2 deletions src/drivers/android/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3285,6 +3285,101 @@ describe("AndroidDriver.create", () => {
expect(await passthroughOf(driver, args, undefined, VIRTUAL_DEVICE)).toEqual(expected);
});

it("fills an emulator's serial into an adb command that names no device", async () => {
const filesystem = await androidFilesystem();
const driver = await createDriver(filesystem, new ScriptedProcessRunner([]));
const emulator = {
address: "emulator-5554",
deviceId: "device",
driverData: {},
physical: false,
} as const;
const scoped = ["-P", String(adbServerPort)];
const cases: readonly (readonly [readonly string[], readonly string[]])[] = [
[
["shell", "getprop"],
["-s", "emulator-5554", "shell", "getprop"],
],
[
["install", "app.apk"],
["-s", "emulator-5554", "install", "app.apk"],
],
[
["reverse", "--list"],
["-s", "emulator-5554", "reverse", "--list"],
],
[
["-s", "emulator-5554", "shell", "getprop"],
["-s", "emulator-5554", "shell", "getprop"],
],
[["devices"], ["devices"]],
[["version"], ["version"]],
[["--version"], ["--version"]],
[
["forward", "--list"],
["forward", "--list"],
],
[
["reconnect", "offline"],
["reconnect", "offline"],
],
];

for (const [args, expected] of cases) {
expect((await passthroughOf(driver, args, { hasTerminal: false }, emulator)).args).toEqual([
...scoped,
...expected,
]);
}
});

it("refuses an emulator adb command that names another device, or that runs on another platform", async () => {
const filesystem = await androidFilesystem();
const driver = await createDriver(filesystem, new ScriptedProcessRunner([]));
const emulator = {
address: "emulator-5554",
deviceId: "device",
driverData: {},
physical: false,
} as const;
const context = { hasTerminal: false, platform: "android" };
const refusal = (command: string, named: string) =>
`Refusing \`adb ${command}\`: it names device ${named}, and this lease holds emulator-5554. Leave the device out; Simlock fills it in.`;
const cases: readonly (readonly [readonly string[], string])[] = [
[
["-s", "emulator-5556", "shell", "getprop"],
refusal("-s emulator-5556 shell getprop", "-s emulator-5556"),
],
[["-e", "shell", "getprop"], refusal("-e shell getprop", "-e")],
[["-d", "shell", "getprop"], refusal("-d shell getprop", "-d")],
[["-t", "3", "shell", "getprop"], refusal("-t 3 shell getprop", "-t 3")],
[["-t3", "shell", "getprop"], refusal("-t3 shell getprop", "-t3")],
[
["-t", "emulator-5554", "shell", "getprop"],
refusal("-t emulator-5554 shell getprop", "-t emulator-5554"),
],
];

for (const [args, message] of cases) {
await expect(passthroughOf(driver, args, context, emulator)).rejects.toMatchObject({
message,
tool: "adb",
});
}
await expect(
passthroughOf(
driver,
["shell", "getprop"],
{ hasTerminal: false, platform: "ios" },
emulator,
),
).rejects.toMatchObject({
message:
"Refusing `adb`: it only serves an Android device, and this lease is on the ios platform.",
tool: "adb",
});
});

it("claims only the adb passthrough tool", async () => {
const filesystem = await androidFilesystem();
const driver = await createDriver(filesystem, new ScriptedProcessRunner([]));
Expand Down Expand Up @@ -5658,15 +5753,15 @@ describe("AndroidDriver image fit", () => {
async function passthroughOf(
driver: object,
args: readonly string[],
context?: { readonly hasTerminal: boolean },
context?: { readonly hasTerminal: boolean; readonly platform?: string },
device?: unknown,
): Promise<{ readonly args: readonly string[]; readonly command: string; readonly env: object }> {
const routed = driver as {
passthrough(
tool: string,
device: unknown,
args: readonly string[],
context?: { readonly hasTerminal: boolean },
context?: { readonly hasTerminal: boolean; readonly platform?: string },
): Promise<{
readonly args: readonly string[];
readonly command: string;
Expand Down
65 changes: 64 additions & 1 deletion src/drivers/android/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { otherDeviceRefusal } from "../other-device-refusal.js";
import { dirname, isAbsolute, join } from "node:path";

import {
Expand Down Expand Up @@ -388,6 +389,60 @@ function walkGlobals(args: readonly string[]): GlobalsWalkResult {
return { kind: "none" };
}

/**
* why: ADR 0047 §1. `adb help` (platform-tools 37.0.0): these subcommands act on no device, so
* a `-s` in front of them would be noise. `forward --list` and `reconnect offline` are the
* two that depend on their operand and are matched in `actsOnNoDevice`.
*/
const NO_DEVICE_SUBCOMMANDS: ReadonlySet<string> = new Set([
"devices",
"help",
"version",
"connect",
"disconnect",
"pair",
"mdns",
"keygen",
"start-server",
"kill-server",
"--version",
"--help",
]);

function actsOnNoDevice(args: readonly string[], index: number): boolean {
const subcommand = args[index] as string;
if (NO_DEVICE_SUBCOMMANDS.has(subcommand)) return true;
if (subcommand === "forward") return args[index + 1] === "--list";
return subcommand === "reconnect" && args[index + 1] === "offline";
}

/** why: Whether the globals before `end` already name `serial` with `-s`; `-t`, `-e`, `-d` and any other serial are refused. */
function namesOwnSerial(args: readonly string[], end: number, serial: string): boolean {
let named = false;
for (let index = 0; index < end; index += 1) {
const flag = args[index] as string;
const value = flag === "-s" || flag === "-t" ? args[index + 1] : undefined;
if (flag !== "-s" || value !== serial) {
throw otherDeviceRefusal(
"adb",
args,
value === undefined ? flag : `${flag} ${value}`,
serial,
);
}
named = true;
index += 1;
}
return named;
}

/** why: Puts `-s <serial>` in front of the globals unless the subcommand needs no device or the caller already named this one. */
function fillAdbDevice(args: readonly string[], serial: string): readonly string[] {
const walked = walkGlobals(args);
if (walked.kind !== "subcommand" || actsOnNoDevice(args, walked.index)) return args;
return namesOwnSerial(args, walked.index, serial) ? args : ["-s", serial, ...args];
}

function callerSuppliedScopeFlag(args: readonly string[]): string | undefined {
const walked = walkGlobals(args);
return walked.kind === "refused" ? walked.argument : undefined;
Expand Down Expand Up @@ -687,12 +742,20 @@ export class AndroidDriver implements Driver {
args: readonly string[],
context?: PassthroughContext,
): Promise<PassthroughCommand> {
if (context?.platform !== undefined && context.platform !== this.platform) {
throw new PassthroughRefusedError(
"adb",
`Refusing \`adb\`: it only serves an Android device, and this lease is on the ${context.platform} platform.`,
);
}
if (device !== undefined && isPhysicalDriverDevice(device)) {
return this.#physical.passthrough(device, args, context);
}
this.#assertProxyable(args, context);
const scopedArgs =
device === undefined || device.address === "" ? args : fillAdbDevice(args, device.address);
return await Promise.resolve({
args: ["-P", String(this.#adbServerPort), ...args],
args: ["-P", String(this.#adbServerPort), ...scopedArgs],
command: this.#sdk.adb,
env: { ANDROID_ADB_SERVER_PORT: String(this.#adbServerPort) },
});
Expand Down
8 changes: 4 additions & 4 deletions src/drivers/ios/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,13 @@ off, locked. Present = listed, paired, unlocked.

Passthrough —

- `simctl`: `xcrun simctl --set <root> …`. Refused: `create`, `erase`, `delete`, `shutdown all`,
`runtime delete`, any flag before the subcommand (`--set`, `--profiles` named). Refused on a physical lease.
- `simctl`: `xcrun simctl --set <root> …`; with a device (`device.exec`) the lease's UDID goes first among
a device verb's operands (`simctl-device.ts`, ADR 0047). Refused: `booted`, another UUID, `create`, `erase`,
`delete`, `shutdown all`, `runtime delete`, a flag before the subcommand (`--set`, `--profiles`), a physical or non-iOS lease.
- `devicectl`: physical iOS lease only; no local wrapper. `devicectlExecCommand` allows
`device install app`, `device uninstall app`, `device process launch|terminate|signal`,
`device info apps|lockState|details|processes`, each with its own options. Host-path options
(`--json-output`, `--log-output`, `--destination`, `--file`, `--filter`, `-j`, `-l`) are refused
before `--`. Adds `--device <udid>` unless the caller names one.
(`--json-output`, `--log-output`, `--destination`, `--file`, `--filter`, `-j`, `-l`) are refused before `--`. Adds `--device <udid>` unless the caller names one; another UDID is refused.

Lease environment: virtual `SIMLOCK_IOS_DEVICE_SET=<root>`; physical `SIMLOCK_DEVICE_UDID=<udid>`.
Screen — `screenCapture` (only when `create` is given a launcher): `simlock-screen-capture
Expand Down
Loading
Loading