From fe45fbbc8a14e10215b79e29e40dce09d1065679 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:15:48 +0200 Subject: [PATCH 01/17] test: a device.exec caller's simctl device verb runs with the lease's UDID in place (#958) --- src/drivers/ios/index.test.ts | 51 +++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/drivers/ios/index.test.ts b/src/drivers/ios/index.test.ts index ae831dd00..3b6b336d5 100644 --- a/src/drivers/ios/index.test.ts +++ b/src/drivers/ios/index.test.ts @@ -1671,6 +1671,57 @@ describe("IosSimctlDriver", () => { }); }); + it("fills a simctl device verb's missing device with the lease's UDID", async () => { + const driver = await createDriver(new ScriptedProcessRunner([])); + const udid = "6F1D2A8C-3B4E-4F7A-9C2D-8E5B1A7F3C90"; + const simulator = { address: "", deviceId: udid, driverData: {}, physical: false } as const; + const scoped = ["simctl", "--set", deviceRoot]; + const cases: readonly (readonly [readonly string[], readonly string[]])[] = [ + [ + ["install", "/b/App.app"], + ["install", udid, "/b/App.app"], + ], + [ + ["launch", "--console", "com.x"], + ["launch", "--console", udid, "com.x"], + ], + [ + ["launch", "-a", "arm64", "com.x"], + ["launch", "-a", "arm64", udid, "com.x"], + ], + [ + ["spawn", "-s", "launchctl", "list"], + ["spawn", "-s", udid, "launchctl", "list"], + ], + [ + ["io", "screenshot", "/tmp/s.png"], + ["io", udid, "screenshot", "/tmp/s.png"], + ], + [ + ["install", udid, "/b/App.app"], + ["install", udid, "/b/App.app"], + ], + [ + ["install", udid.toLowerCase(), "/b/App.app"], + ["install", udid.toLowerCase(), "/b/App.app"], + ], + [ + ["list", "devices"], + ["list", "devices"], + ], + [ + ["runtime", "list"], + ["runtime", "list"], + ], + ]; + + for (const [args, expected] of cases) { + await expect( + driver.passthrough("simctl", simulator, args, { hasTerminal: false, platform: "ios" }), + ).resolves.toEqual({ args: [...scoped, ...expected], command: "xcrun", env: {} }); + } + }); + it.each([["create"], ["erase"], ["delete"]])( "refuses to proxy %s, naming the command that reclaims a device properly", async (verb) => { From a3e990f42ba1f04c03a65aa087b57c0fbf34ea27 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:17:15 +0200 Subject: [PATCH 02/17] feat(ios): simctl fills the lease's UDID into a device verb (#958) --- src/drivers/ios/index.ts | 4 ++- src/drivers/ios/simctl-device.ts | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/drivers/ios/simctl-device.ts diff --git a/src/drivers/ios/index.ts b/src/drivers/ios/index.ts index 79655681a..07e343a17 100644 --- a/src/drivers/ios/index.ts +++ b/src/drivers/ios/index.ts @@ -55,6 +55,7 @@ import type { } from "../../ports/index.js"; import { runBoundedProcess, runInstallerProcess } from "../installer-process.js"; import { devicectlExecCommand } from "./devicectl-exec.js"; +import { fillSimctlDevice } from "./simctl-device.js"; import { IosPhysicalHandler } from "./physical.js"; import { createIosScreenCapture } from "./screen-capture.js"; import { labelsFor, resolveSlimCategories, slimSignature } from "./slim-labels.js"; @@ -1336,9 +1337,10 @@ export class IosSimctlDriver implements Driver { ); } this.#assertProxyable(args); + const scopedArgs = device === undefined ? args : fillSimctlDevice(args, device.deviceId); // why: No environment: simctl ignores every variable that could carry the device set. return await Promise.resolve({ - args: ["simctl", "--set", this.#deviceRoot, ...args], + args: ["simctl", "--set", this.#deviceRoot, ...scopedArgs], command: "xcrun", env: {}, }); diff --git a/src/drivers/ios/simctl-device.ts b/src/drivers/ios/simctl-device.ts new file mode 100644 index 000000000..3fdb4045c --- /dev/null +++ b/src/drivers/ios/simctl-device.ts @@ -0,0 +1,56 @@ +/** + * why: ADR 0047 §1. `xcrun simctl help` (Xcode 27.0) puts the device first among a verb's + * operands for these verbs; a verb on neither list passes as the caller wrote it. + */ +const DEVICE_FIRST_VERBS: ReadonlySet = new Set([ + "addmedia", + "appinfo", + "boot", + "bootstatus", + "get_app_container", + "getenv", + "icloud_sync", + "install", + "install_app_data", + "io", + "keychain", + "listapps", + "location", + "openurl", + "privacy", + "push", + "reboot", + "rename", + "shutdown", + "status_bar", + "terminate", + "ui", + "uninstall", + "upgrade", +]); + +/** why: These verbs take their own options first, then the device; only `-a` takes a separate value. */ +const DEVICE_AFTER_OPTIONS_VERBS: ReadonlySet = new Set([ + "launch", + "spawn", + "pbcopy", + "pbpaste", +]); + +const OPTION_WITH_SEPARATE_VALUE = "-a"; + +/** why: A caller may leave the device out (ADR 0047 §1); this puts it where `simctl` expects it unless the caller already named it. */ +export function fillSimctlDevice(args: readonly string[], udid: string): readonly string[] { + const verb = args[0]; + if (verb === undefined) return args; + let index = 1; + if (DEVICE_AFTER_OPTIONS_VERBS.has(verb)) { + while (args[index]?.startsWith("-") === true) { + index += args[index] === OPTION_WITH_SEPARATE_VALUE ? 2 : 1; + } + } else if (!DEVICE_FIRST_VERBS.has(verb)) { + return args; + } + if (args[index]?.toLowerCase() === udid.toLowerCase()) return args; + return [...args.slice(0, index), udid, ...args.slice(index)]; +} From 2e3b36eba57107672eb0eeac723f7e21f8f420db Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:17:47 +0200 Subject: [PATCH 03/17] test: a device.exec caller's simctl command names another device and is refused with the shared message (#958) --- src/drivers/ios/index.test.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/drivers/ios/index.test.ts b/src/drivers/ios/index.test.ts index 3b6b336d5..5676537cd 100644 --- a/src/drivers/ios/index.test.ts +++ b/src/drivers/ios/index.test.ts @@ -1722,6 +1722,39 @@ describe("IosSimctlDriver", () => { } }); + it("refuses a simctl command that names another device, or that runs on another platform", async () => { + const driver = await createDriver(new ScriptedProcessRunner([])); + const udid = "6F1D2A8C-3B4E-4F7A-9C2D-8E5B1A7F3C90"; + const other = "0A1B2C3D-0000-4000-8000-123456789ABC"; + const simulator = { address: "", deviceId: udid, driverData: {}, physical: false } as const; + const refusal = (command: string, named: string) => + `Refusing \`simctl ${command}\`: it names device ${named}, and this lease holds ${udid}. Leave the device out; Simlock fills it in.`; + const cases: readonly (readonly [readonly string[], string])[] = [ + [["install", other, "/b/App.app"], refusal(`install ${other} /b/App.app`, other)], + [["install", "booted", "/b/App.app"], refusal("install booted /b/App.app", "booted")], + [ + ["launch", "--console", "booted", "com.x"], + refusal("launch --console booted com.x", "booted"), + ], + ]; + + for (const [args, message] of cases) { + await expect( + driver.passthrough("simctl", simulator, args, { hasTerminal: false, platform: "ios" }), + ).rejects.toMatchObject({ message, tool: "simctl" }); + } + await expect( + driver.passthrough("simctl", simulator, ["install", "/b/App.app"], { + hasTerminal: false, + platform: "android", + }), + ).rejects.toMatchObject({ + message: + "Refusing `simctl`: it only serves an iOS simulator, and this lease is on the android platform.", + tool: "simctl", + }); + }); + it.each([["create"], ["erase"], ["delete"]])( "refuses to proxy %s, naming the command that reclaims a device properly", async (verb) => { From af7745c76fc07f22968fd45c385aea41c3854a6f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:18:20 +0200 Subject: [PATCH 04/17] feat(ios): simctl refuses another device and another platform (#958) --- src/drivers/ios/index.ts | 6 ++++++ src/drivers/ios/simctl-device.ts | 12 +++++++++++- src/drivers/other-device-refusal.ts | 17 +++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/drivers/other-device-refusal.ts diff --git a/src/drivers/ios/index.ts b/src/drivers/ios/index.ts index 07e343a17..5b45c8ffd 100644 --- a/src/drivers/ios/index.ts +++ b/src/drivers/ios/index.ts @@ -1330,6 +1330,12 @@ export class IosSimctlDriver implements Driver { `Refusing \`${tool}\`: the iOS driver does not wrap it.`, ); } + if (context?.platform !== undefined && context.platform !== this.platform) { + throw new PassthroughRefusedError( + tool, + `Refusing \`simctl\`: it only serves an iOS simulator, and this lease is on the ${context.platform} platform.`, + ); + } if (device !== undefined && isPhysicalDriverDevice(device)) { throw new PassthroughRefusedError( tool, diff --git a/src/drivers/ios/simctl-device.ts b/src/drivers/ios/simctl-device.ts index 3fdb4045c..7aabfa44a 100644 --- a/src/drivers/ios/simctl-device.ts +++ b/src/drivers/ios/simctl-device.ts @@ -1,3 +1,5 @@ +import { otherDeviceRefusal } from "../other-device-refusal.js"; + /** * why: ADR 0047 §1. `xcrun simctl help` (Xcode 27.0) puts the device first among a verb's * operands for these verbs; a verb on neither list passes as the caller wrote it. @@ -39,6 +41,10 @@ const DEVICE_AFTER_OPTIONS_VERBS: ReadonlySet = new Set([ const OPTION_WITH_SEPARATE_VALUE = "-a"; +const BOOTED = "booted"; + +const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + /** why: A caller may leave the device out (ADR 0047 §1); this puts it where `simctl` expects it unless the caller already named it. */ export function fillSimctlDevice(args: readonly string[], udid: string): readonly string[] { const verb = args[0]; @@ -51,6 +57,10 @@ export function fillSimctlDevice(args: readonly string[], udid: string): readonl } else if (!DEVICE_FIRST_VERBS.has(verb)) { return args; } - if (args[index]?.toLowerCase() === udid.toLowerCase()) return args; + const named = args[index]; + if (named?.toLowerCase() === udid.toLowerCase()) return args; + if (named !== undefined && (named === BOOTED || UUID_SHAPE.test(named))) { + throw otherDeviceRefusal("simctl", args, named, udid); + } return [...args.slice(0, index), udid, ...args.slice(index)]; } diff --git a/src/drivers/other-device-refusal.ts b/src/drivers/other-device-refusal.ts new file mode 100644 index 000000000..29a1369ad --- /dev/null +++ b/src/drivers/other-device-refusal.ts @@ -0,0 +1,17 @@ +import { PassthroughRefusedError } from "../core/index.js"; + +/** + * why: One message for every driver and tool that refuses a command naming another device + * (ADR 0047 §2); `named` is what the caller wrote, `own` the lease's UDID or serial. + */ +export function otherDeviceRefusal( + tool: string, + args: readonly string[], + named: string, + own: string, +): PassthroughRefusedError { + return new PassthroughRefusedError( + tool, + `Refusing \`${[tool, ...args].join(" ")}\`: it names device ${named}, and this lease holds ${own}. Leave the device out; Simlock fills it in.`, + ); +} From 6550f0a9d2ac5cb3767701acf9a85165ff00b4f9 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:19:00 +0200 Subject: [PATCH 05/17] test: a device.exec caller's emulator adb command runs with -s first (#958) --- src/drivers/android/index.test.ts | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/drivers/android/index.test.ts b/src/drivers/android/index.test.ts index 1e36c8253..3094d040b 100644 --- a/src/drivers/android/index.test.ts +++ b/src/drivers/android/index.test.ts @@ -3285,6 +3285,46 @@ 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"]], + ]; + + for (const [args, expected] of cases) { + expect((await passthroughOf(driver, args, { hasTerminal: false }, emulator)).args).toEqual([ + ...scoped, + ...expected, + ]); + } + }); + it("claims only the adb passthrough tool", async () => { const filesystem = await androidFilesystem(); const driver = await createDriver(filesystem, new ScriptedProcessRunner([])); From 1c4c432ddb8c3fd499c295ffc2ebf5feb7174d4b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:19:13 +0200 Subject: [PATCH 06/17] feat(android): adb fills the emulator's serial into a command that names no device (#958) --- src/drivers/android/index.ts | 42 +++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/drivers/android/index.ts b/src/drivers/android/index.ts index 568b9b15b..0264fb3d5 100644 --- a/src/drivers/android/index.ts +++ b/src/drivers/android/index.ts @@ -388,6 +388,44 @@ 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 = 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: Puts `-s ` 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; + const globals = args.slice(0, walked.index); + if (globals.some((argument, index) => argument === "-s" && globals[index + 1] === serial)) { + return args; + } + return ["-s", serial, ...args]; +} + function callerSuppliedScopeFlag(args: readonly string[]): string | undefined { const walked = walkGlobals(args); return walked.kind === "refused" ? walked.argument : undefined; @@ -691,8 +729,10 @@ export class AndroidDriver implements Driver { 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) }, }); From 98472868474c74df0c0ff606602c1a37929750a1 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:19:56 +0200 Subject: [PATCH 07/17] test: a device.exec caller's emulator adb command names another device and is refused with the shared message (#958) --- src/drivers/android/index.test.ts | 47 +++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/src/drivers/android/index.test.ts b/src/drivers/android/index.test.ts index 3094d040b..6f732f9ab 100644 --- a/src/drivers/android/index.test.ts +++ b/src/drivers/android/index.test.ts @@ -3325,6 +3325,49 @@ describe("AndroidDriver.create", () => { } }); + 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")], + ]; + + 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([])); @@ -5698,7 +5741,7 @@ 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 { @@ -5706,7 +5749,7 @@ async function passthroughOf( 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; From 893090493bb387087065757583cb403aeef0d4c8 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:20:09 +0200 Subject: [PATCH 08/17] feat(android): adb refuses another device and another platform (#958) --- src/drivers/android/index.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/drivers/android/index.ts b/src/drivers/android/index.ts index 0264fb3d5..d56d099d7 100644 --- a/src/drivers/android/index.ts +++ b/src/drivers/android/index.ts @@ -1,3 +1,4 @@ +import { otherDeviceRefusal } from "../other-device-refusal.js"; import { dirname, isAbsolute, join } from "node:path"; import { @@ -419,11 +420,18 @@ function actsOnNoDevice(args: readonly string[], index: number): boolean { function fillAdbDevice(args: readonly string[], serial: string): readonly string[] { const walked = walkGlobals(args); if (walked.kind !== "subcommand" || actsOnNoDevice(args, walked.index)) return args; - const globals = args.slice(0, walked.index); - if (globals.some((argument, index) => argument === "-s" && globals[index + 1] === serial)) { - return args; - } - return ["-s", serial, ...args]; + let alreadyNamed = false; + for (let index = 0; index < walked.index; index += 1) { + const flag = args[index] as string; + const value = flag === "-s" || flag === "-t" ? args[index + 1] : undefined; + if (flag === "-s" && value === serial) { + alreadyNamed = true; + index += 1; + continue; + } + throw otherDeviceRefusal("adb", args, value === undefined ? flag : `${flag} ${value}`, serial); + } + return alreadyNamed ? args : ["-s", serial, ...args]; } function callerSuppliedScopeFlag(args: readonly string[]): string | undefined { @@ -725,6 +733,12 @@ export class AndroidDriver implements Driver { args: readonly string[], context?: PassthroughContext, ): Promise { + 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); } From 2db755158118e1f5a6a84d8b0b4cd8b412e5ca3c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:20:43 +0200 Subject: [PATCH 09/17] test: a device.exec caller's devicectl command names another device and is refused with the shared message (#958) --- src/drivers/ios/devicectl-passthrough.test.ts | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/drivers/ios/devicectl-passthrough.test.ts b/src/drivers/ios/devicectl-passthrough.test.ts index 08d6966df..4733045ae 100644 --- a/src/drivers/ios/devicectl-passthrough.test.ts +++ b/src/drivers/ios/devicectl-passthrough.test.ts @@ -203,8 +203,9 @@ const OPTION_CASES: readonly (readonly [ const forms: (readonly string[])[] = [ ...valued.flatMap((aliases) => aliases.flatMap((alias) => { - const separate = [alias, "x"]; - return alias.startsWith("--") ? [separate, [`${alias}=x`]] : [separate]; + const value = isDeviceOption(alias) ? UDID : "x"; + const separate = [alias, value]; + return alias.startsWith("--") ? [separate, [`${alias}=${value}`]] : [separate]; }), ), ...flags.map((flag) => [flag]), @@ -269,15 +270,35 @@ describe("IosSimctlDriver passthrough", () => { ); }); - it.each([ - ["--device", ["--device", "OTHER"]], - ["-d", ["-d", "OTHER"]], - ["--device=", ["--device=OTHER"]], - ])("passes %s before -- as the caller gave it, naming another device", async (_name, given) => { - const args = ["device", "process", "launch", ...given, APP]; - const { outcome } = await resolvesFor(args); + it("refuses a --device or -d naming another device, and leaves the lease's own alone", async () => { + const launch = (...given: readonly string[]) => [ + "device", + "process", + "launch", + ...given, + APP, + ]; + const own = [ + ["--device", UDID], + ["--device", UDID.toLowerCase()], + ["-d", UDID.toUpperCase()], + [`--device=${UDID}`], + ]; + for (const given of own) { + const { outcome } = await resolvesFor(launch(...given)); + expect(outcome).toEqual(runs(["devicectl", ...launch(...given)])); + } - expect(outcome).toEqual(runs(["devicectl", ...args])); + const other = [["--device", "OTHER"], ["-d", "OTHER"], ["--device=OTHER"]]; + for (const given of other) { + const { outcome } = await resolvesFor(launch(...given)); + expect(outcome).toMatchObject({ + rejected: { + message: `Refusing \`devicectl ${launch(...given).join(" ")}\`: it names device ${given.join(" ")}, and this lease holds ${UDID}. Leave the device out; Simlock fills it in.`, + tool: "devicectl", + }, + }); + } }); it("inserts --device when the only -d is the app's own, after --", async () => { From caf3a5bd8f13fe29d93178a211cae3456285fa1f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:21:01 +0200 Subject: [PATCH 10/17] feat(ios): devicectl refuses a --device that is not the lease's (#958) --- src/drivers/ios/devicectl-exec.ts | 38 +++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/drivers/ios/devicectl-exec.ts b/src/drivers/ios/devicectl-exec.ts index c29b06684..cb749d5dd 100644 --- a/src/drivers/ios/devicectl-exec.ts +++ b/src/drivers/ios/devicectl-exec.ts @@ -1,4 +1,5 @@ import { type PassthroughCommand, PassthroughRefusedError } from "../../core/index.js"; +import { otherDeviceRefusal } from "../other-device-refusal.js"; const TOOL = "devicectl"; @@ -139,7 +140,10 @@ function isOnList( } interface Scan { - namesDevice: boolean; + /** why: The `-d` / `--device` whose separate value is the next argument. */ + deviceOption: string | undefined; + /** why: Each device the caller named, as written, with its value for the comparison. */ + namedDevices: { readonly named: string; readonly value: string }[]; positional: number; expectsValue: boolean; } @@ -153,6 +157,10 @@ function scanArgument( ): void { if (scan.expectsValue) { scan.expectsValue = false; + if (scan.deviceOption !== undefined) { + scan.namedDevices.push({ named: `${scan.deviceOption} ${argument}`, value: argument }); + scan.deviceOption = undefined; + } return; } if (!isOption(argument)) { @@ -165,7 +173,13 @@ function scanArgument( refuse(`${command}: ${argument} is not on the reviewed list`); } if (!lists.valued.has(name)) return; - if (name === "-d" || name === "--device") scan.namesDevice = true; + if (name === "-d" || name === "--device") { + if (inline) { + scan.namedDevices.push({ named: argument, value: argument.slice(name.length + 1) }); + } else { + scan.deviceOption = name; + } + } scan.expectsValue = !inline; } @@ -173,15 +187,20 @@ function parseOptions( command: string, spec: CommandSpec, options: readonly string[], -): { readonly namesDevice: boolean } { +): Scan["namedDevices"] { const lists = { flags: new Set([...spec.flags, ...COMMON_FLAGS]), valued: new Set([...spec.valued, ...COMMON_VALUED].flat()), }; - const scan: Scan = { expectsValue: false, namesDevice: false, positional: 0 }; + const scan: Scan = { + deviceOption: undefined, + expectsValue: false, + namedDevices: [], + positional: 0, + }; for (const argument of options) scanArgument(command, spec, lists, scan, argument); if (scan.expectsValue) refuse(`${command}: an option has no value`); - return { namesDevice: scan.namesDevice }; + return scan.namedDevices; } function isOption(argument: string): boolean { @@ -204,11 +223,16 @@ export function devicectlExecCommand(udid: string, args: readonly string[]): Pas `${before.length === 0 ? "no command" : before.slice(0, 3).join(" ")} is not on the reviewed list`, ); } - const { namesDevice } = parseOptions(command, spec, before.slice(3)); + const namedDevices = parseOptions(command, spec, before.slice(3)); + for (const { named, value } of namedDevices) { + if (value.toLowerCase() !== udid.toLowerCase()) { + throw otherDeviceRefusal(TOOL, args, named, udid); + } + } const head = args.slice(0, 3); const rest = args.slice(3); return { - args: [TOOL, ...head, ...(namesDevice ? [] : ["--device", udid]), ...rest], + args: [TOOL, ...head, ...(namedDevices.length > 0 ? [] : ["--device", udid]), ...rest], command: "xcrun", env: {}, }; From 4d1ac193d5dc49f1fc15c2c2e20a4c2b188ef643 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:21:48 +0200 Subject: [PATCH 11/17] docs(drivers): device.exec fills in the lease's device and refuses another (#958) --- docs/internal/flows/device-exec.md | 8 +++++--- src/cli/AGENTS.md | 3 ++- src/contract/operations.ts | 5 +++-- src/daemon/dispatcher.ts | 8 ++++---- src/drivers/AGENTS.md | 2 ++ src/drivers/android/AGENTS.md | 4 +++- src/drivers/ios/AGENTS.md | 6 ++++-- website/src/content/docs/docs/gateway/index.mdx | 2 +- website/src/content/docs/docs/reference/cli.mdx | 4 ++-- website/src/content/docs/docs/reference/errors.mdx | 2 +- .../docs/docs/using-devices/programmatic-client.mdx | 2 +- .../content/docs/docs/using-devices/remote-agents.mdx | 10 ++++------ 12 files changed, 32 insertions(+), 24 deletions(-) diff --git a/docs/internal/flows/device-exec.md b/docs/internal/flows/device-exec.md index ef1149cc1..bbc182d47 100644 --- a/docs/internal/flows/device-exec.md +++ b/docs/internal/flows/device-exec.md @@ -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. @@ -43,9 +45,9 @@ 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 ` | `create`, `erase`, `delete`, `shutdown all`, `runtime delete`, any flag before the subcommand, a physical device | -| `devicectl` | iOS physical | `xcrun devicectl --device `; `--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 `, `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 `; 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 --device `; `--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 -s `, `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, `-e`, `-d`, `-t`, `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 -s `; 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 diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index 975ca8abc..52f86738e 100644 --- a/src/cli/AGENTS.md +++ b/src/cli/AGENTS.md @@ -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 diff --git a/src/contract/operations.ts b/src/contract/operations.ts index 4fab44876..2f55f9896 100644 --- a/src/contract/operations.ts +++ b/src/contract/operations.ts @@ -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 diff --git a/src/daemon/dispatcher.ts b/src/daemon/dispatcher.ts index 1f6229926..960ce4206 100644 --- a/src/daemon/dispatcher.ts +++ b/src/daemon/dispatcher.ts @@ -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 diff --git a/src/drivers/AGENTS.md b/src/drivers/AGENTS.md index cc9a52e4a..93babb26f 100644 --- a/src/drivers/AGENTS.md +++ b/src/drivers/AGENTS.md @@ -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` diff --git a/src/drivers/android/AGENTS.md b/src/drivers/android/AGENTS.md index ea0c0d9c0..ca2ddb7ca 100644 --- a/src/drivers/android/AGENTS.md +++ b/src/drivers/android/AGENTS.md @@ -101,7 +101,9 @@ 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 …`. Refused: `kill-server` anywhere, `emu kill`, +Passthrough — virtual: `adb -P …`; with a device that has an address (`device.exec`) +`-s ` goes first unless the subcommand is on the no-device list (ADR 0047), and `-s` of +another serial, `-e`, `-d`, `-t` or `adb` on a non-Android lease is refused. 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 …`; refuses any leading flag, server and transport verbs, `reboot` to bootloader/recovery/sideload/fastboot. diff --git a/src/drivers/ios/AGENTS.md b/src/drivers/ios/AGENTS.md index 1c7a531fb..77d91bf9d 100644 --- a/src/drivers/ios/AGENTS.md +++ b/src/drivers/ios/AGENTS.md @@ -72,13 +72,15 @@ off, locked. Present = listed, paired, unlocked. Passthrough — -- `simctl`: `xcrun simctl --set …`. Refused: `create`, `erase`, `delete`, `shutdown all`, +- `simctl`: `xcrun simctl --set …`. With a device (`device.exec`) the lease's UDID is put + first among a device verb's operands (`simctl-device.ts`, ADR 0047); `booted` or another + UUID is refused, so is `simctl` on a non-iOS lease. Refused: `create`, `erase`, `delete`, `shutdown all`, `runtime delete`, any flag before the subcommand (`--set`, `--profiles` named). Refused on a physical 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 ` unless the caller names one. + before `--`. Adds `--device ` unless the caller names one; a `--device`/`-d` naming another is refused. Lease environment: virtual `SIMLOCK_IOS_DEVICE_SET=`; physical `SIMLOCK_DEVICE_UDID=`. Screen — `screenCapture` (only when `create` is given a launcher): `simlock-screen-capture diff --git a/website/src/content/docs/docs/gateway/index.mdx b/website/src/content/docs/docs/gateway/index.mdx index 5b50785e9..69945d746 100644 --- a/website/src/content/docs/docs/gateway/index.mdx +++ b/website/src/content/docs/docs/gateway/index.mdx @@ -29,7 +29,7 @@ Agents use the same commands and the same HTTP calls as with a single worker: - **On the gateway machine**, run the CLI with `--gateway`, for example `simlock --gateway lease --platform ios --device "iPhone 16"`. - **On any other machine**, use the gateway's HTTP API with an agent token made on the gateway. See [Remote agents](/simlock/docs/using-devices/remote-agents). -Through a gateway, `simlock simctl` and `simlock adb` run on the worker that holds the device. A file path in the command is a path on that worker, not on your machine, so `simlock --gateway simctl install booted ./MyApp.app` fails unless `MyApp.app` exists on the worker. +Through a gateway, `simlock simctl` and `simlock adb` run on the worker that holds the device. A file path in the command is a path on that worker, not on your machine, so `simlock --gateway simctl install ./MyApp.app` fails unless `MyApp.app` exists on the worker. A gateway does not do these things a worker does: diff --git a/website/src/content/docs/docs/reference/cli.mdx b/website/src/content/docs/docs/reference/cli.mdx index 460e59263..e219f0de6 100644 --- a/website/src/content/docs/docs/reference/cli.mdx +++ b/website/src/content/docs/docs/reference/cli.mdx @@ -165,7 +165,7 @@ simlock simctl [--lease ] | `--lease ` | Through a gateway: the lease whose device the command runs against. Must come before the simctl arguments. On a worker it is accepted and ignored. | Through a gateway: your only lease | | `` | Passed to `xcrun simctl` as written. | — | -Every other argument goes to `simctl`, including `--help`. On a worker the command runs on this machine with your terminal. Through a gateway it runs on the worker that holds the lease, its output is streamed back, and a piped stdin is sent along. The command exits with `simctl`'s own exit code; a Simlock failure also writes a `{"error":...}` line to stderr. Simlock refuses `create`, `erase`, `delete`, `shutdown all`, `runtime delete`, and a caller-supplied `--set` or `--profiles`, with `PASSTHROUGH_REFUSED` (exit 2). Use `simlock release` and `simlock cleanup` to give devices back. +Every other argument goes to `simctl`, including `--help`. On a worker the command runs on this machine with your terminal. Through a gateway it runs on the worker that holds the lease, its output is streamed back, and a piped stdin is sent along. The command exits with `simctl`'s own exit code; a Simlock failure also writes a `{"error":...}` line to stderr. Through a gateway the worker puts the lease's UDID into a command that leaves the device out, so write `simlock --gateway simctl install ./MyApp.app`. Simlock refuses `create`, `erase`, `delete`, `shutdown all`, `runtime delete`, a caller-supplied `--set` or `--profiles`, and (through a gateway) a command that names another device such as `booted`, with `PASSTHROUGH_REFUSED` (exit 2). Use `simlock release` and `simlock cleanup` to give devices back. ## simlock adb @@ -180,7 +180,7 @@ simlock adb [--lease ] | `--lease ` | Through a gateway: the lease whose device the command runs against. Must come before the adb arguments. On a worker it is accepted and ignored. | Through a gateway: your only lease | | `` | Passed to `adb` as written. | — | -The command exits with `adb`'s own exit code. Simlock refuses `adb kill-server`, flags that move adb to a different server, and (through a gateway) `adb shell` with no command, with `PASSTHROUGH_REFUSED` (exit 2). `-s`, `-t`, `-d` and `-e` are allowed before the subcommand. +The command exits with `adb`'s own exit code. Simlock refuses `adb kill-server`, flags that move adb to a different server, and (through a gateway) `adb shell` with no command, with `PASSTHROUGH_REFUSED` (exit 2). `-s`, `-t`, `-d` and `-e` are allowed before the subcommand. Through a gateway the worker adds `-s ` for an emulator, and refuses `-s` with another serial, `-e`, `-d` and `-t`. ## simlock catalog diff --git a/website/src/content/docs/docs/reference/errors.mdx b/website/src/content/docs/docs/reference/errors.mdx index dc297887f..3fcc5f9c5 100644 --- a/website/src/content/docs/docs/reference/errors.mdx +++ b/website/src/content/docs/docs/reference/errors.mdx @@ -76,7 +76,7 @@ Branch on `code`, not on the message: the message wording can change, the code d | Code | Exit | HTTP | Meaning | What to do | | --- | --- | --- | --- | --- | -| `PASSTHROUGH_REFUSED` | 2 | 422 | Simlock refuses this `simctl`, `adb` or `devicectl` command: a verb that creates, erases or deletes devices or touches every device at once (such as `simctl erase` or `adb kill-server`), a flag that points the tool at another device set or adb server, `simctl` on a physical device, or `adb shell` with no command where there is no terminal. | Drop the refused flag or verb, or name the command to run. The message says which. | +| `PASSTHROUGH_REFUSED` | 2 | 422 | Simlock refuses this `simctl`, `adb` or `devicectl` command: a verb that creates, erases or deletes devices or touches every device at once (such as `simctl erase` or `adb kill-server`), a flag that points the tool at another device set or adb server, `simctl` on a physical device, `simctl` or `adb` on a lease of the other platform, a `device.exec` command that names a device other than the lease's (`booted`, another UDID, `adb -s` with another serial, `-e`, `-d`, `-t`, a different `devicectl --device`), or `adb shell` with no command where there is no terminal. | Drop the refused flag or verb, leave the device out of the command, or name the command to run. The message says which. | | `UNKNOWN_PASSTHROUGH_TOOL` | 2 | 422 | A tool Simlock does not wrap. | Use `simctl` or `adb`. | | `EXEC_TIMEOUT` | 10 | 504 | A command run on a device through a gateway ran longer than `exec.timeoutMs` (10 minutes by default) and was stopped. | Split the work, or raise `exec.timeoutMs` on the worker. | diff --git a/website/src/content/docs/docs/using-devices/programmatic-client.mdx b/website/src/content/docs/docs/using-devices/programmatic-client.mdx index b773e877f..676070799 100644 --- a/website/src/content/docs/docs/using-devices/programmatic-client.mdx +++ b/website/src/content/docs/docs/using-devices/programmatic-client.mdx @@ -112,7 +112,7 @@ What the calls do: `requestLease` takes the same choices as `simlock lease`, under these names: `platform`, `model` or `class`, `osVersion` (an exact version or a range), `mode`, `imageTag`, `physical`, `ttlMs`, `timeoutMs`, `noWait`, `allowDownload` and `leaseId`. See [Choosing a device](/simlock/docs/using-devices/choosing-a-device). -`exec` arguments are passed to the tool unchanged. Name your device by its UDID (`device.driverDeviceId`) for `simctl`, or its serial (`device.address`) with `-s` for `adb`. Use absolute paths: the command runs in the daemon's process, not in your program's working directory. +`exec` arguments are passed to the tool as written, with the lease's device filled in: leave the device out of a `simctl`, `adb` or `devicectl` command. A command that names a different device is refused with `PASSTHROUGH_REFUSED` before it runs. Use absolute paths: the command runs in the daemon's process, not in your program's working directory. ## Stop waiting diff --git a/website/src/content/docs/docs/using-devices/remote-agents.mdx b/website/src/content/docs/docs/using-devices/remote-agents.mdx index eaba16809..738275282 100644 --- a/website/src/content/docs/docs/using-devices/remote-agents.mdx +++ b/website/src/content/docs/docs/using-devices/remote-agents.mdx @@ -111,7 +111,7 @@ export SIMLOCK_TOKEN=slk_d2FybS1wb29sLWFnZW50LXRva2VuLWV4YW1wbGUtMDM curl -sS -N -X POST "$SIMLOCK_URL/v1/leases/$LEASE/exec" \ -H "Authorization: Bearer $SIMLOCK_TOKEN" \ -H "Content-Type: application/json" \ - -d '{"tool":"simctl","args":["launch","6F1D2A8C-3B4E-4F7A-9C2D-8E5B1A7F3C90","com.example.myapp"]}' + -d '{"tool":"simctl","args":["launch","com.example.myapp"]}' ``` ```text @@ -141,14 +141,12 @@ export SIMLOCK_TOKEN=slk_d2FybS1wb29sLWFnZW50LXRva2VuLWV4YW1wbGUtMDM ## Running commands with exec -- **Name your device in the arguments.** Use the lease's `udid` for `simctl`. Other agents' devices are on the same worker, so never use `booted`. -- **On Android, use the lease's `address` as the adb serial.** The lease's `udid` is the emulator's AVD name (`simlock_…`), not its adb serial. Run `adb -s
…`. `address` is absent while the device has none, for example before it is ready or after it shuts down. -- **On a physical device, Simlock names the device for you.** `devicectl` gets `--device ` added, and `adb` on a physical Android phone gets the phone's serial. -- **Paths are on the worker.** `{"tool":"simctl","args":["install","","/builds/MyApp.app"]}` needs `/builds/MyApp.app` to exist on the worker. The API has no way to upload a file; get it there another way, or use [agent-device](/simlock/docs/using-devices/agent-device), which uploads apps for you. +- **Leave the device out.** Simlock puts the lease's device into a `simctl` command (its UDID, where the verb takes one), an `adb` command on an emulator (`-s `) and a `devicectl` command (`--device `); `adb` on a physical Android phone gets the phone's serial. Write `["install","/builds/MyApp.app"]`, not a UDID or serial. A command that names a different device, such as `booted`, another UDID, `adb -s emulator-5556`, `-e`, `-d` or `-t`, is refused with `422 PASSTHROUGH_REFUSED` before anything runs. Naming the lease's own UDID or serial is accepted. +- **Paths are on the worker.** `{"tool":"simctl","args":["install","/builds/MyApp.app"]}` needs `/builds/MyApp.app` to exist on the worker. The API has no way to upload a file; get it there another way, or use [agent-device](/simlock/docs/using-devices/agent-device), which uploads apps for you. - **No terminal.** `stdin` in the body is sent to the command once. Interactive commands, such as a bare `adb shell`, are refused. - **Output is text.** Each chunk is decoded as UTF-8, so binary output to stdout (a screenshot written to `-`) does not survive. Write it to a file on the worker instead. - **Time limit.** A command that runs longer than 10 minutes is stopped, and the stream ends with an `error` event carrying `EXEC_TIMEOUT`. Disconnecting does not stop the command. -- **Refused commands.** The commands `simlock simctl` and `simlock adb` refuse (see [Working with a device](/simlock/docs/using-devices/working-with-a-device#commands-simlock-refuses)) are refused here too, with `422 PASSTHROUGH_REFUSED`, before anything runs. +- **Refused commands.** The commands `simlock simctl` and `simlock adb` refuse (see [Working with a device](/simlock/docs/using-devices/working-with-a-device#commands-simlock-refuses)) are refused here too, with `422 PASSTHROUGH_REFUSED`, before anything runs, and so is `simctl` on an Android lease or `adb` on an iOS one. ## More calls From 8886426f88d5158c5563e74fb13f41200cdb598b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:24:05 +0200 Subject: [PATCH 12/17] refactor(drivers): split the device checks to stay under the complexity limit (#958) --- src/drivers/android/index.ts | 33 ++++++++++++++++++++----------- src/drivers/ios/devicectl-exec.ts | 27 ++++++++++++++----------- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/drivers/android/index.ts b/src/drivers/android/index.ts index d56d099d7..67c46b114 100644 --- a/src/drivers/android/index.ts +++ b/src/drivers/android/index.ts @@ -416,22 +416,31 @@ function actsOnNoDevice(args: readonly string[], index: number): boolean { return subcommand === "reconnect" && args[index + 1] === "offline"; } -/** why: Puts `-s ` 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; - let alreadyNamed = false; - for (let index = 0; index < walked.index; index += 1) { +/** why: Whether the globals before `end` already name `serial`; any other device flag is 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) { - alreadyNamed = true; - index += 1; - continue; + if (flag !== "-s" || value !== serial) { + throw otherDeviceRefusal( + "adb", + args, + value === undefined ? flag : `${flag} ${value}`, + serial, + ); } - throw otherDeviceRefusal("adb", args, value === undefined ? flag : `${flag} ${value}`, serial); + named = true; + index += 1; } - return alreadyNamed ? args : ["-s", serial, ...args]; + return named; +} + +/** why: Puts `-s ` 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 { diff --git a/src/drivers/ios/devicectl-exec.ts b/src/drivers/ios/devicectl-exec.ts index cb749d5dd..7db524f9f 100644 --- a/src/drivers/ios/devicectl-exec.ts +++ b/src/drivers/ios/devicectl-exec.ts @@ -148,6 +148,20 @@ interface Scan { expectsValue: boolean; } +function takeDeviceValue(scan: Scan, value: string): void { + if (scan.deviceOption === undefined) return; + scan.namedDevices.push({ named: `${scan.deviceOption} ${value}`, value }); + scan.deviceOption = undefined; +} + +function noteDeviceOption(scan: Scan, argument: string, name: string, inline: boolean): void { + if (inline) { + scan.namedDevices.push({ named: argument, value: argument.slice(name.length + 1) }); + } else { + scan.deviceOption = name; + } +} + function scanArgument( command: string, spec: CommandSpec, @@ -157,10 +171,7 @@ function scanArgument( ): void { if (scan.expectsValue) { scan.expectsValue = false; - if (scan.deviceOption !== undefined) { - scan.namedDevices.push({ named: `${scan.deviceOption} ${argument}`, value: argument }); - scan.deviceOption = undefined; - } + takeDeviceValue(scan, argument); return; } if (!isOption(argument)) { @@ -173,13 +184,7 @@ function scanArgument( refuse(`${command}: ${argument} is not on the reviewed list`); } if (!lists.valued.has(name)) return; - if (name === "-d" || name === "--device") { - if (inline) { - scan.namedDevices.push({ named: argument, value: argument.slice(name.length + 1) }); - } else { - scan.deviceOption = name; - } - } + if (name === "-d" || name === "--device") noteDeviceOption(scan, argument, name, inline); scan.expectsValue = !inline; } From bc9e0ba87dfa92ce73e666f732135ad803b7a1be Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:27:44 +0200 Subject: [PATCH 13/17] docs(exec): the flow says the driver, not the daemon, fills in and checks the device (#958) --- docs/internal/flows/device-exec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/internal/flows/device-exec.md b/docs/internal/flows/device-exec.md index bbc182d47..100aded1f 100644 --- a/docs/internal/flows/device-exec.md +++ b/docs/internal/flows/device-exec.md @@ -52,7 +52,7 @@ Runs the command on the machine that owns the device and streams its output back 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 From 9fc753d24a1ef77885182acbe3c5d668f044e426 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:30:02 +0200 Subject: [PATCH 14/17] fix(drivers): an emulator adb command naming its own serial with -t is left alone; test the no-device forms (#958) --- src/drivers/android/index.test.ts | 12 ++++++++++++ src/drivers/android/index.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/drivers/android/index.test.ts b/src/drivers/android/index.test.ts index 6f732f9ab..a1f6e3a6c 100644 --- a/src/drivers/android/index.test.ts +++ b/src/drivers/android/index.test.ts @@ -3312,9 +3312,21 @@ describe("AndroidDriver.create", () => { ["-s", "emulator-5554", "shell", "getprop"], ["-s", "emulator-5554", "shell", "getprop"], ], + [ + ["-t", "emulator-5554", "shell", "getprop"], + ["-t", "emulator-5554", "shell", "getprop"], + ], [["devices"], ["devices"]], [["version"], ["version"]], [["--version"], ["--version"]], + [ + ["forward", "--list"], + ["forward", "--list"], + ], + [ + ["reconnect", "offline"], + ["reconnect", "offline"], + ], ]; for (const [args, expected] of cases) { diff --git a/src/drivers/android/index.ts b/src/drivers/android/index.ts index 67c46b114..c94b1e0c0 100644 --- a/src/drivers/android/index.ts +++ b/src/drivers/android/index.ts @@ -422,7 +422,7 @@ function namesOwnSerial(args: readonly string[], end: number, serial: string): b 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) { + if (value !== serial) { throw otherDeviceRefusal( "adb", args, From c0d31177259fa3181d82e1dc626548f4a4a9c0bf Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:30:26 +0200 Subject: [PATCH 15/17] docs(exec): -t with the lease's own serial is accepted like -s (#958) --- docs/internal/flows/device-exec.md | 2 +- website/src/content/docs/docs/reference/cli.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/internal/flows/device-exec.md b/docs/internal/flows/device-exec.md index 100aded1f..871424ebf 100644 --- a/docs/internal/flows/device-exec.md +++ b/docs/internal/flows/device-exec.md @@ -47,7 +47,7 @@ Runs the command on the machine that owns the device and streams its output back |---|---|---|---| | `simctl` | iOS simulator | `xcrun simctl --set `; 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 --device `; `--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 -s `, `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, `-e`, `-d`, `-t`, `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 emulator | `adb -P -s `, `ANDROID_ADB_SERVER_PORT` set; no `-s` for a no-device subcommand (`devices`, `version`, `forward --list`, …) or an empty address | `-s` or `-t` other than the lease's serial, `-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 -s `; 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 diff --git a/website/src/content/docs/docs/reference/cli.mdx b/website/src/content/docs/docs/reference/cli.mdx index e219f0de6..2aa0e844f 100644 --- a/website/src/content/docs/docs/reference/cli.mdx +++ b/website/src/content/docs/docs/reference/cli.mdx @@ -180,7 +180,7 @@ simlock adb [--lease ] | `--lease ` | Through a gateway: the lease whose device the command runs against. Must come before the adb arguments. On a worker it is accepted and ignored. | Through a gateway: your only lease | | `` | Passed to `adb` as written. | — | -The command exits with `adb`'s own exit code. Simlock refuses `adb kill-server`, flags that move adb to a different server, and (through a gateway) `adb shell` with no command, with `PASSTHROUGH_REFUSED` (exit 2). `-s`, `-t`, `-d` and `-e` are allowed before the subcommand. Through a gateway the worker adds `-s ` for an emulator, and refuses `-s` with another serial, `-e`, `-d` and `-t`. +The command exits with `adb`'s own exit code. Simlock refuses `adb kill-server`, flags that move adb to a different server, and (through a gateway) `adb shell` with no command, with `PASSTHROUGH_REFUSED` (exit 2). `-s`, `-t`, `-d` and `-e` are allowed before the subcommand. Through a gateway the worker adds `-s ` for an emulator, and refuses `-s` or `-t` with another serial, `-e` and `-d`. ## simlock catalog From ac9187b11a3fa382742805b4a11e444c76addd7c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:32:11 +0200 Subject: [PATCH 16/17] fix(drivers): an emulator adb command refuses -t whatever it names; only -s with the lease's serial passes (#958) --- docs/internal/flows/device-exec.md | 2 +- src/drivers/android/index.test.ts | 8 ++++---- src/drivers/android/index.ts | 4 ++-- website/src/content/docs/docs/reference/cli.mdx | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/internal/flows/device-exec.md b/docs/internal/flows/device-exec.md index 871424ebf..f10944c5f 100644 --- a/docs/internal/flows/device-exec.md +++ b/docs/internal/flows/device-exec.md @@ -47,7 +47,7 @@ Runs the command on the machine that owns the device and streams its output back |---|---|---|---| | `simctl` | iOS simulator | `xcrun simctl --set `; 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 --device `; `--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 -s `, `ANDROID_ADB_SERVER_PORT` set; no `-s` for a no-device subcommand (`devices`, `version`, `forward --list`, …) or an empty address | `-s` or `-t` other than the lease's serial, `-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 emulator | `adb -P -s `, `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 -s `; 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 diff --git a/src/drivers/android/index.test.ts b/src/drivers/android/index.test.ts index a1f6e3a6c..26db23124 100644 --- a/src/drivers/android/index.test.ts +++ b/src/drivers/android/index.test.ts @@ -3312,10 +3312,6 @@ describe("AndroidDriver.create", () => { ["-s", "emulator-5554", "shell", "getprop"], ["-s", "emulator-5554", "shell", "getprop"], ], - [ - ["-t", "emulator-5554", "shell", "getprop"], - ["-t", "emulator-5554", "shell", "getprop"], - ], [["devices"], ["devices"]], [["version"], ["version"]], [["--version"], ["--version"]], @@ -3358,6 +3354,10 @@ describe("AndroidDriver.create", () => { [["-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) { diff --git a/src/drivers/android/index.ts b/src/drivers/android/index.ts index c94b1e0c0..9cd2f0220 100644 --- a/src/drivers/android/index.ts +++ b/src/drivers/android/index.ts @@ -416,13 +416,13 @@ function actsOnNoDevice(args: readonly string[], index: number): boolean { return subcommand === "reconnect" && args[index + 1] === "offline"; } -/** why: Whether the globals before `end` already name `serial`; any other device flag is refused. */ +/** 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 (value !== serial) { + if (flag !== "-s" || value !== serial) { throw otherDeviceRefusal( "adb", args, diff --git a/website/src/content/docs/docs/reference/cli.mdx b/website/src/content/docs/docs/reference/cli.mdx index 2aa0e844f..e219f0de6 100644 --- a/website/src/content/docs/docs/reference/cli.mdx +++ b/website/src/content/docs/docs/reference/cli.mdx @@ -180,7 +180,7 @@ simlock adb [--lease ] | `--lease ` | Through a gateway: the lease whose device the command runs against. Must come before the adb arguments. On a worker it is accepted and ignored. | Through a gateway: your only lease | | `` | Passed to `adb` as written. | — | -The command exits with `adb`'s own exit code. Simlock refuses `adb kill-server`, flags that move adb to a different server, and (through a gateway) `adb shell` with no command, with `PASSTHROUGH_REFUSED` (exit 2). `-s`, `-t`, `-d` and `-e` are allowed before the subcommand. Through a gateway the worker adds `-s ` for an emulator, and refuses `-s` or `-t` with another serial, `-e` and `-d`. +The command exits with `adb`'s own exit code. Simlock refuses `adb kill-server`, flags that move adb to a different server, and (through a gateway) `adb shell` with no command, with `PASSTHROUGH_REFUSED` (exit 2). `-s`, `-t`, `-d` and `-e` are allowed before the subcommand. Through a gateway the worker adds `-s ` for an emulator, and refuses `-s` with another serial, `-e`, `-d` and `-t`. ## simlock catalog From 473d52b95a2ea25c67702823551b9dbc5ebdb1fd Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 24 Oct 2026 08:46:41 +0200 Subject: [PATCH 17/17] docs(exec): own-device naming is true per lease kind; driver manuals fit 150 lines (#958) --- src/drivers/android/AGENTS.md | 12 +++++------- src/drivers/ios/AGENTS.md | 10 ++++------ .../docs/docs/using-devices/remote-agents.mdx | 2 +- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/drivers/android/AGENTS.md b/src/drivers/android/AGENTS.md index ca2ddb7ca..ad8b18e7c 100644 --- a/src/drivers/android/AGENTS.md +++ b/src/drivers/android/AGENTS.md @@ -101,15 +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 …`; with a device that has an address (`device.exec`) -`-s ` goes first unless the subcommand is on the no-device list (ADR 0047), and `-s` of -another serial, `-e`, `-d`, `-t` or `adb` on a non-Android lease is refused. 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 …`; +Passthrough — virtual: `adb -P …`; with a device that has an address (`device.exec`) `-s ` 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 …`; refuses any leading flag, server and transport verbs, `reboot` to bootloader/recovery/sideload/fastboot. -Lease environment: virtual `ANDROID_ADB_SERVER_PORT=`; physical -`ANDROID_ADB_SERVER_PORT=5037`, `ANDROID_SERIAL=`. +Lease environment: virtual `ANDROID_ADB_SERVER_PORT=`; physical `ANDROID_ADB_SERVER_PORT=5037`, `ANDROID_SERIAL=`. No slim: `resolveSpec` ignores `mode` and returns a full spec. `fresh` is core's. diff --git a/src/drivers/ios/AGENTS.md b/src/drivers/ios/AGENTS.md index 77d91bf9d..bdc31a1ba 100644 --- a/src/drivers/ios/AGENTS.md +++ b/src/drivers/ios/AGENTS.md @@ -72,15 +72,13 @@ off, locked. Present = listed, paired, unlocked. Passthrough — -- `simctl`: `xcrun simctl --set …`. With a device (`device.exec`) the lease's UDID is put - first among a device verb's operands (`simctl-device.ts`, ADR 0047); `booted` or another - UUID is refused, so is `simctl` on a non-iOS lease. 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 …`; 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 ` unless the caller names one; a `--device`/`-d` naming another is refused. + (`--json-output`, `--log-output`, `--destination`, `--file`, `--filter`, `-j`, `-l`) are refused before `--`. Adds `--device ` unless the caller names one; another UDID is refused. Lease environment: virtual `SIMLOCK_IOS_DEVICE_SET=`; physical `SIMLOCK_DEVICE_UDID=`. Screen — `screenCapture` (only when `create` is given a launcher): `simlock-screen-capture diff --git a/website/src/content/docs/docs/using-devices/remote-agents.mdx b/website/src/content/docs/docs/using-devices/remote-agents.mdx index 738275282..95ece2da6 100644 --- a/website/src/content/docs/docs/using-devices/remote-agents.mdx +++ b/website/src/content/docs/docs/using-devices/remote-agents.mdx @@ -141,7 +141,7 @@ export SIMLOCK_TOKEN=slk_d2FybS1wb29sLWFnZW50LXRva2VuLWV4YW1wbGUtMDM ## Running commands with exec -- **Leave the device out.** Simlock puts the lease's device into a `simctl` command (its UDID, where the verb takes one), an `adb` command on an emulator (`-s `) and a `devicectl` command (`--device `); `adb` on a physical Android phone gets the phone's serial. Write `["install","/builds/MyApp.app"]`, not a UDID or serial. A command that names a different device, such as `booted`, another UDID, `adb -s emulator-5556`, `-e`, `-d` or `-t`, is refused with `422 PASSTHROUGH_REFUSED` before anything runs. Naming the lease's own UDID or serial is accepted. +- **Leave the device out.** Simlock puts the lease's device into a `simctl` command (its UDID, where the verb takes one), an `adb` command on an emulator (`-s `) and a `devicectl` command (`--device `); `adb` on a physical Android phone gets the phone's serial. Write `["install","/builds/MyApp.app"]`, not a UDID or serial. A command that names a different device, such as `booted`, another UDID, `adb -s emulator-5556`, `-e`, `-d` or `-t`, is refused with `422 PASSTHROUGH_REFUSED` before anything runs. Naming the lease's own device is accepted on a simulator (its UDID), an emulator (`-s `) and an iPhone or iPad (`--device `); a physical Android lease refuses any leading flag, its own `-s ` included. - **Paths are on the worker.** `{"tool":"simctl","args":["install","/builds/MyApp.app"]}` needs `/builds/MyApp.app` to exist on the worker. The API has no way to upload a file; get it there another way, or use [agent-device](/simlock/docs/using-devices/agent-device), which uploads apps for you. - **No terminal.** `stdin` in the body is sent to the command once. Interactive commands, such as a bare `adb shell`, are refused. - **Output is text.** Each chunk is decoded as UTF-8, so binary output to stdout (a screenshot written to `-`) does not survive. Write it to a file on the worker instead.