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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/internal/flows/device-exec.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ Runs the command on the machine that owns the device and streams its output back
`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`.
5. Env — `execBaseEnvironment(process.env)` (`src/daemon/exec-env.ts`, the allowlisted
keys, read once at start) + the command's `env`, minus its `unsetEnv`.
6. Spawn — `ProcessRunner.spawnStreaming`, pipes only. `stdin` is written once,
then closed.
7. `started` push the moment the process exists, then one `output` push per chunk
Expand Down Expand Up @@ -83,8 +84,9 @@ gateway that gives up the same way leaves the worker's command running.
keeps running, maybe against a device reclaimed and granted again. Bounded by
`exec.timeoutMs` and by `reclaiming` coming first; refused verbs stay refused.
- **An `agent` token on a worker is a host-level credential.** Commands run as the
daemon's uid with its env; `adb pull <device path> <host path>` writes any path
the daemon can. Arguments are an accident boundary, not a security boundary.
daemon's uid (`simctl spawn` runs any program on the host); `adb pull <device path>
<host path>` writes any path the daemon can. Arguments are an accident boundary, not
a security boundary. Only the env is trimmed to the allowlist.
- **No files.** Paths resolve on the worker: `simctl install /tmp/App.app` fails
with the tool's own "no such file". Ship artifacts out of band; `device.upload`
is an idea, not built.
Expand Down
5 changes: 3 additions & 2 deletions src/core/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,9 @@ export interface LegacyDevice {
readonly path?: string;
}

// why: the frontend that runs it merges `env` over its own environment, because a tool spawned
// without `PATH` would not be found, and then deletes every key in `unsetEnv` from the child's.
// why: the frontend that runs it merges `env` over a base environment (the CLI's own; for
// `device.exec`, the daemon's allowlisted keys), because a tool spawned without `PATH` would not
// be found, and then deletes every key in `unsetEnv` from the child's.
export interface PassthroughCommand {
readonly command: string;
readonly args: readonly string[];
Expand Down
9 changes: 5 additions & 4 deletions src/daemon/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Flows: [startup](../../docs/internal/flows/startup.md), [device.exec](../../docs
| `relay.ts` | `RelayTargets`, `RelayConnection`: relays over the uplink |
| `error-code.ts` | `classifyError`: thrown error → contract code |
| `enrollment-wake.ts` | `wakingQueueAfter`: wakes the queue after `device.add`/`device.remove` |
| `exec-env.ts` | `execBaseEnvironment`: the allowlisted keys of `process.env` a `device.exec` child starts from |
| `test-relay-support.ts` | Test helpers for relay e2e tests |

## Composition (`main.ts`)
Expand Down Expand Up @@ -110,13 +111,14 @@ Security model: cooperative identity, not a hostile-process boundary.
- A `hello.principal` is a claim. Ownership checks stop accidents, not attacks.
- Admin comes only from a credential at `hello`, never from the socket itself.
- A worker that joins a fleet grants its gateway `admin` over itself.
- An `agent` token is shell access to the worker: `device.exec` runs host tools as the daemon's
user, with only the keys `execBaseEnvironment` (`exec-env.ts`) keeps plus the command's own.

## Uplink and relays (worker side)

- `GatewayUplink` dials `gateway.url` with `gateway.token`, after the first host load reading. Backoff from 1 s, ×2, cap 30 s, jittered; retries forever.
- `DaemonServer.acceptUplink` serves it as one more connection, role `admin`.
- `relay.request` runs only on that connection; targets come from `relayTargets`
(today `agent-device` and `stream`). See [gateway](../gateway/AGENTS.md).
- `relay.request` runs only on that connection; targets come from `relayTargets` (today `agent-device` and `stream`). See [gateway](../gateway/AGENTS.md).

## Logging and event records

Expand All @@ -134,8 +136,7 @@ Security model: cooperative identity, not a hostile-process boundary.

## Rules

- One `Dispatcher` instance serves the socket, the uplink and HTTP; HTTP calls
`DaemonServer.dispatch` in process.
- One `Dispatcher` instance serves the socket, the uplink and HTTP; HTTP calls `DaemonServer.dispatch` in process.
- Handlers never see raw input and never check roles.
- The claim comes before any write to `admin.token` and before converge.
- On a worker, no request but `hello`, `status.get` and `daemon.stop` runs before
Expand Down
2 changes: 1 addition & 1 deletion src/daemon/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,7 @@ export class Dispatcher {
);
const execEnv: NodeJS.ProcessEnv = { ...this.options.execEnv, ...command.env };
for (const key of command.unsetEnv ?? []) {
// why: the daemon's own environment is the base, so a key it inherited could re-aim the command.
// why: the base comes from the daemon's environment, so a key it kept could re-aim the command.
delete execEnv[key];
}
const handle = this.options.processRunner.spawnStreaming(command.command, command.args, {
Expand Down
41 changes: 41 additions & 0 deletions src/daemon/exec-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";

import { execBaseEnvironment } from "./exec-env.js";

// why: pure logic with many cases (testing rule 6); the startDaemon test in `main.test.ts` proves a `device.exec` child gets this list.
describe("execBaseEnvironment", () => {
it("keeps only the allowlisted keys and every LC_ key, and drops every other key of the daemon's environment", () => {
const kept = {
ANDROID_AVD_HOME: "/Users/me/.android/avd",
ANDROID_HOME: "/sdk",
ANDROID_PREFS_ROOT: "/Users/me",
ANDROID_SDK_HOME: "/Users/me",
ANDROID_SDK_ROOT: "/sdk",
ANDROID_USER_HOME: "/Users/me/.android",
DEVELOPER_DIR: "/Applications/Xcode.app/Contents/Developer",
HOME: "/Users/me",
JAVA_HOME: "/jdk",
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
LC_CTYPE: "UTF-8",
LOGNAME: "me",
PATH: "/usr/bin:/bin",
SHELL: "/bin/zsh",
TERM: "xterm-256color",
TMPDIR: "/var/folders/xy/T/",
USER: "me",
};
const dropped = {
ANDROID_SERIAL: "someone-elses-phone",
AWS_SECRET_ACCESS_KEY: "aws-secret",
GITHUB_TOKEN: "ghp_secret",
LCX: "not-a-locale",
NPM_TOKEN: "npm-secret",
SIMCTL_CHILD_SECRET: "reaches-the-simulator",
SIMLOCK_HOME: "/Users/me/.simlock",
XLC_CTYPE: "not-a-locale",
};

expect(execBaseEnvironment({ ...kept, ...dropped, UNSET: undefined })).toEqual(kept);
});
});
36 changes: 36 additions & 0 deletions src/daemon/exec-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* why: a `device.exec` command runs as the daemon's user for whoever holds a lease, so it gets
* only what the wrapped tools need to start; every other key of the daemon's environment (CI
* tokens, cloud keys, launchd-injected values) is not passed on. This is no secrecy boundary: the
* command runs as the daemon's user and can still read the daemon's own environment.
*/
const KEPT_KEYS: ReadonlySet<string> = new Set([
"PATH",
"HOME",
"TMPDIR",
"USER",
"LOGNAME",
"SHELL",
"LANG",
"TERM",
// why: xcrun reads it to pick the Xcode whose simctl and devicectl run.
"DEVELOPER_DIR",
// why: adb and the SDK tools find the SDK, its user files and the AVDs through these.
"ANDROID_HOME",
"ANDROID_SDK_ROOT",
"ANDROID_USER_HOME",
"ANDROID_AVD_HOME",
"ANDROID_PREFS_ROOT",
"ANDROID_SDK_HOME",
"JAVA_HOME",
]);

export function execBaseEnvironment(
env: Readonly<Record<string, string | undefined>>,
): NodeJS.ProcessEnv {
const kept: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(env)) {
if (value !== undefined && (KEPT_KEYS.has(key) || key.startsWith("LC_"))) kept[key] = value;
}
return kept;
}
42 changes: 42 additions & 0 deletions src/daemon/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,48 @@ describe("startDaemon wires core and leasing together", () => {
expect(entry).toMatchObject({ presence: { present: true } });
});

it("starts a device.exec child from the allowlisted keys of the daemon's environment plus the command's own env, without a secret the daemon inherited", async () => {
const clock = new FakeClock(1_000);
const runner = new ScriptedProcessRunner([
{ match: { args: ["list", "devices"], command: "fake-simctl" } },
]);
const driver = new FakeDriver({
availableOsVersions: ["26.5"],
clock,
passthrough: () => ({
args: ["list", "devices"],
command: "fake-simctl",
env: { SIMLOCK_SCOPED: "1" },
}),
passthroughTools: ["simctl"],
platform: "ios",
});
vi.stubEnv("AWS_SECRET_ACCESS_KEY", "aws-secret");
vi.stubEnv("LC_CTYPE", "simlock-test-locale");
try {
const { daemon } = await start({
clock,
drivers: [driver],
hostInfo: new FakeHostInfo({ arch: "arm64", os: "macOS", osVersion: "15.5" }),
processRunner: runner,
});
const grant = (await daemon.dispatch("lease.request", ios, agent)) as Grant;

await daemon.dispatch(
"device.exec",
{ args: ["list", "devices"], leaseId: grant.lease.id, tool: "simctl" },
agent,
);

const env = runner.calls.find((call) => call.command === "fake-simctl")?.options.env;
expect(env).toMatchObject({ LC_CTYPE: "simlock-test-locale", SIMLOCK_SCOPED: "1" });
expect(env?.PATH).toBe(process.env.PATH);
expect(env).not.toHaveProperty("AWS_SECRET_ACCESS_KEY");
} finally {
vi.unstubAllEnvs();
}
});

it("announces the capacity figures and then the queue depth once startup is done", async () => {
const { directory } = await start();

Expand Down
3 changes: 2 additions & 1 deletion src/daemon/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ import {
} from "./connection-host.js";
import { describeLeaseRequestFailure } from "./error-code.js";
import { wakingQueueAfter } from "./enrollment-wake.js";
import { execBaseEnvironment } from "./exec-env.js";
import { GatewayUplink } from "./gateway-uplink.js";
import type { RelayTargets } from "./relay.js";

Expand Down Expand Up @@ -671,7 +672,7 @@ async function composeDaemon(
...(options.protocolVersion === undefined ? {} : { protocolVersion: options.protocolVersion }),
passthrough: core.catalog,
processRunner,
execEnv: process.env,
execEnv: execBaseEnvironment(process.env),
queue: leasing,
presence: latestPresence,
reaper,
Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/docs/gateway/access-control.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Over HTTP, the token is the requester. Every lease and request made with one age
### An agent token reaches the worker's machine

<Aside type="caution">
An agent token is a credential for the worker machine, not for one device. Commands an agent runs on its device, such as `adb pull` or `simctl` commands, run on the worker as the user who runs the worker daemon, and can read and write any file that user can. This holds through a gateway too: the command runs on the worker that holds the lease. Give an agent token only to something you would let run commands on your workers.
An agent token is a credential for the worker machine, not for one device. Commands an agent runs on its device, such as `adb pull` or `simctl` commands, run on the worker as the user who runs the worker daemon, and can read and write any file that user can. This holds through a gateway too: the command runs on the worker that holds the lease. Give an agent token only to something you would let run commands on your workers. `simctl spawn` alone starts any program on the worker, so treat an agent token like shell access to it. A command gets only a short fixed list of the daemon's environment variables (see [Running commands with exec](/simlock/docs/using-devices/remote-agents#running-commands-with-exec)), so a secret in that environment is not handed on to the command. It is not hidden from it either: the command runs as the same user as the daemon, and can read the daemon's environment and any file that user can read.
</Aside>

## Make, list and revoke tokens
Expand Down
2 changes: 2 additions & 0 deletions website/src/content/docs/docs/using-devices/remote-agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ export SIMLOCK_TOKEN=slk_d2FybS1wb29sLWFnZW50LXRva2VuLWV4YW1wbGUtMDM

## Running commands with exec

A command runs on the worker machine, as the user who runs the worker daemon. `simctl spawn` and similar commands start programs on that machine, so treat an agent token like shell access to the worker. Agents that must not see each other's work belong on separate workers. The command does not get the daemon's environment: it gets only `PATH`, `HOME`, `TMPDIR`, `USER`, `LOGNAME`, `SHELL`, `LANG`, every `LC_*` variable, `TERM`, `DEVELOPER_DIR`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `ANDROID_USER_HOME`, `ANDROID_AVD_HOME`, `ANDROID_PREFS_ROOT`, `ANDROID_SDK_HOME` and `JAVA_HOME` from it, plus what Simlock sets to reach the device, such as `ANDROID_ADB_SERVER_PORT` for `adb`. Any other variable, such as a token the daemon was started with, is not passed to the command. That is not secrecy: the command runs as the same user and can read the daemon's own environment.

- **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 <serial>`) and a `devicectl` command (`--device <UDID>`); `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 <serial>`) and an iPhone or iPad (`--device <UDID>`); a physical Android lease refuses any leading flag, its own `-s <serial>` 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.
Expand Down
Loading