@stim-cli/agent-driver-agent-device 0.0.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Janic Duplessis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,10 @@
1
1
  # @stim-cli/agent-driver-agent-device
2
2
 
3
- Placeholder. Install a version published with the other Stim packages.
3
+ The [agent-device](https://github.com/callstack/agent-device) driver
4
+ for stim-server hosted apps, selected on the hosting Mac with
5
+ `hosting.agentDriver: agent-device`. It implements the
6
+ [`@stim-cli/agent-driver`](../agent-driver/README.md) contract.
7
+
8
+ The [stim-server README](../server/README.md#agent-driver-route) describes how
9
+ it starts agent-device, the daemon policy it applies, and how it confines a
10
+ client to one hosted app or device.
@@ -0,0 +1,76 @@
1
+ import { AGENT_DRIVER_API_VERSION, AgentDriverActions, AgentDriverClient, AgentDriverInput, HostedAgentApp, HostedAgentDevice, HostedAgentDriver } from "@stim-cli/agent-driver";
2
+ import { HostedAgentIssuedGrant } from "@stim-cli/core/state";
3
+ import { IncomingMessage, ServerResponse } from "node:http";
4
+ //#region driver.d.ts
5
+ interface AgentDeviceInvocation {
6
+ command: string;
7
+ args: string[];
8
+ }
9
+ /**
10
+ * Finds agent-device at a fixed path: the stim-server LaunchAgent's PATH holds only system directories, and
11
+ * agent-device's `#!/usr/bin/env node` script cannot find Node there, so a JavaScript entry runs under the
12
+ * server's own Node.
13
+ */
14
+ declare function resolveAgentDevice(env: NodeJS.ProcessEnv): AgentDeviceInvocation;
15
+ interface AgentDeviceDriverOptions {
16
+ env: NodeJS.ProcessEnv;
17
+ /** agent-device's own host or session state directory; created with mode 0700. */
18
+ stateDir: string;
19
+ /** Ownership claim root held for as long as the daemon runs. */
20
+ claimRoot: string;
21
+ device?: HostedAgentDevice;
22
+ startTimeoutMs?: number;
23
+ stopTimeoutMs?: number;
24
+ watchMs?: number;
25
+ leaseRenewMs?: number;
26
+ }
27
+ declare class AgentDeviceDriver implements HostedAgentDriver {
28
+ readonly name = "agent-device";
29
+ private readonly options;
30
+ private running;
31
+ private claim;
32
+ private starting;
33
+ private stopping;
34
+ private daemonRecord;
35
+ private deviceProxy;
36
+ private listener;
37
+ private androidProbe;
38
+ private androidVerifiedAt;
39
+ private readonly leases;
40
+ private readonly released;
41
+ constructor(options: AgentDeviceDriverOptions);
42
+ onExit(listener: () => void): void;
43
+ start(): Promise<void>;
44
+ private launch;
45
+ private listening;
46
+ private readDaemon;
47
+ private androidIdentity;
48
+ private watchDaemon;
49
+ private exited;
50
+ stop(): Promise<void>;
51
+ private teardown;
52
+ private captured;
53
+ private stopDaemon;
54
+ private signalAndWait;
55
+ private stopAndroidHelpers;
56
+ private stopIosRunners;
57
+ /**
58
+ * Grants one device's automatic instance lease or allocates and renews a macos-app lease over
59
+ * agent-device's host-only admin route. Forwarded requests are pinned to the grant's scope.
60
+ */
61
+ issue(app: HostedAgentApp): Promise<HostedAgentIssuedGrant>;
62
+ revoke(session: string): Promise<void>;
63
+ private removeSessionDirectories;
64
+ forward(session: string, request: IncomingMessage, response: ServerResponse): void;
65
+ private relayPinned;
66
+ private relay;
67
+ }
68
+ //#endregion
69
+ //#region index.d.ts
70
+ declare const apiVersion: typeof AGENT_DRIVER_API_VERSION;
71
+ declare const id: string;
72
+ declare function createAgentDriver(input: AgentDriverInput): AgentDeviceDriver;
73
+ declare const client: AgentDriverClient;
74
+ declare const actions: AgentDriverActions;
75
+ //#endregion
76
+ export { AgentDeviceDriver, type AgentDeviceDriverOptions, actions, apiVersion, client, createAgentDriver, id, resolveAgentDevice };
package/dist/index.mjs ADDED
@@ -0,0 +1,1132 @@
1
+ import { delimiter, dirname, join } from "node:path";
2
+ import { AGENT_DRIVER_API_VERSION, AGENT_ROUTE_PREFIX, AgentDriverUnavailable, agentRoute, newAgentToken } from "@stim-cli/agent-driver";
3
+ import { createAgentActionReader, isJsonObject } from "@stim-cli/core/state";
4
+ import { execFile, spawn } from "node:child_process";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
7
+ import { request } from "node:http";
8
+ import { homedir } from "node:os";
9
+ import { claimRemoveCommand, clearClaimChild, markClaimChildPending, releaseClaim, setClaimChild, tryAcquireClaim } from "@stim-cli/core/ownership-claim";
10
+ import { captureProcessIdentity, inspectProcessIdentity, waitForProcessExit } from "@stim-cli/core/process-identity";
11
+ //#region processes.ts
12
+ function listProcesses(columns = "pid=,command=") {
13
+ return new Promise((resolve, reject) => {
14
+ execFile("/bin/ps", [
15
+ "-A",
16
+ "-ww",
17
+ "-o",
18
+ columns
19
+ ], {
20
+ timeout: 1e4,
21
+ maxBuffer: 64 * 1024 ** 2
22
+ }, (error, stdout) => error ? reject(error) : resolve(stdout));
23
+ });
24
+ }
25
+ //#endregion
26
+ //#region driver.ts
27
+ function adbPath(env) {
28
+ for (const sdk of [
29
+ env.ANDROID_HOME,
30
+ env.ANDROID_SDK_ROOT,
31
+ join(env.HOME ?? homedir(), "Library/Android/sdk")
32
+ ]) if (sdk && existsSync(join(sdk, "platform-tools/adb"))) return join(sdk, "platform-tools/adb");
33
+ return "adb";
34
+ }
35
+ const UNSCOPED = "Agent control is unavailable: the agent-device on the hosting Mac has no macos-app lease to limit a client to one app, and Stim never hands a client the hosting Mac desktop.";
36
+ /** Names an agent-device binary to use instead of the fixed install locations. */
37
+ const AGENT_DEVICE_BIN_ENV = "STIM_AGENT_DEVICE_BIN";
38
+ const CANDIDATES = (home) => [
39
+ join(home, ".local", "bin", "agent-device"),
40
+ "/opt/homebrew/bin/agent-device",
41
+ "/usr/local/bin/agent-device"
42
+ ];
43
+ /**
44
+ * The daemon policy Stim starts agent-device with (agent-device ADR 0029): every request must run under a
45
+ * host-allocated `macos-app` lease, and only these commands run at all.
46
+ */
47
+ const POLICY = {
48
+ version: 1,
49
+ leases: { require: "macos-app" },
50
+ commands: { allow: [
51
+ "open",
52
+ "close",
53
+ "snapshot",
54
+ "wait",
55
+ "find",
56
+ "get",
57
+ "is",
58
+ "click",
59
+ "fill",
60
+ "press",
61
+ "type",
62
+ "focus",
63
+ "scroll",
64
+ "screenshot",
65
+ "batch"
66
+ ] }
67
+ };
68
+ const DEVICE_COMMANDS = [
69
+ ...POLICY.commands.allow,
70
+ "devices",
71
+ "diff",
72
+ "longpress",
73
+ "swipe",
74
+ "back",
75
+ "home",
76
+ "orientation",
77
+ "appstate",
78
+ "alert"
79
+ ];
80
+ function deviceScope(device) {
81
+ const platform = device.udid === void 0 ? "android" : "ios";
82
+ return {
83
+ platform,
84
+ id: device.udid ?? device.serial,
85
+ selector: platform === "ios" ? "udid" : "serial",
86
+ backend: `${platform}-instance`
87
+ };
88
+ }
89
+ function devicePolicy(device) {
90
+ const { selector, id } = deviceScope(device);
91
+ return {
92
+ version: 1,
93
+ devices: { allow: [{ [selector]: id }] },
94
+ commands: { allow: DEVICE_COMMANDS },
95
+ capabilities: { deny: ["device-shutdown"] }
96
+ };
97
+ }
98
+ function devicePolicyDigest(device) {
99
+ const { id } = deviceScope(device);
100
+ return createHash("sha256").update(JSON.stringify({
101
+ devices: [id],
102
+ commands: {
103
+ mode: "allow",
104
+ names: DEVICE_COMMANDS.toSorted()
105
+ },
106
+ capabilities: ["device-shutdown"]
107
+ })).digest("hex");
108
+ }
109
+ /** agent-device caps a lease's inactivity window at ten minutes; the host renews well inside it. */
110
+ const LEASE_TTL_MS = 6e5;
111
+ const MAX_RPC_BYTES = 1048576;
112
+ const MAX_ECHO = 64;
113
+ const COMMAND_METHODS = /* @__PURE__ */ new Set(["agent_device.command", "agent-device.command"]);
114
+ const LEASE_METHODS = /* @__PURE__ */ new Set([
115
+ "agent_device.lease.heartbeat",
116
+ "agent-device.lease.heartbeat",
117
+ "agent_device.lease.release",
118
+ "agent-device.lease.release"
119
+ ]);
120
+ /**
121
+ * Finds agent-device at a fixed path: the stim-server LaunchAgent's PATH holds only system directories, and
122
+ * agent-device's `#!/usr/bin/env node` script cannot find Node there, so a JavaScript entry runs under the
123
+ * server's own Node.
124
+ */
125
+ function resolveAgentDevice(env) {
126
+ const explicit = env[AGENT_DEVICE_BIN_ENV]?.trim();
127
+ const candidates = explicit ? [explicit] : CANDIDATES(env.HOME || homedir());
128
+ for (const candidate of candidates) try {
129
+ const real = realpathSync(candidate);
130
+ accessSync(real, constants.R_OK);
131
+ return /\.(mjs|cjs|js)$/.test(real) ? {
132
+ command: process.execPath,
133
+ args: [real]
134
+ } : {
135
+ command: real,
136
+ args: []
137
+ };
138
+ } catch {}
139
+ throw new AgentDriverUnavailable(explicit ? `${AGENT_DEVICE_BIN_ENV} names ${explicit}, which is not a readable agent-device on the hosting Mac.` : `agent-device is not installed on the hosting Mac (looked in ${candidates.join(", ")}). Install it with: npm install --global --prefix "$HOME/.local" agent-device`);
140
+ }
141
+ const FORWARDED = [
142
+ "content-type",
143
+ "content-length",
144
+ "content-range",
145
+ "range",
146
+ "accept",
147
+ "x-request-id",
148
+ "x-artifact-type",
149
+ "x-artifact-filename",
150
+ "x-artifact-hash",
151
+ "x-artifact-hash-algorithm"
152
+ ];
153
+ const HOP_BY_HOP = /* @__PURE__ */ new Set([
154
+ "connection",
155
+ "keep-alive",
156
+ "transfer-encoding",
157
+ "upgrade",
158
+ "proxy-authenticate"
159
+ ]);
160
+ const LISTENING = /Proxy listening at (http:\/\/127\.0\.0\.1:\d+)/;
161
+ var AgentDeviceDriver = class {
162
+ name = "agent-device";
163
+ options;
164
+ running = null;
165
+ claim = null;
166
+ starting = null;
167
+ stopping = false;
168
+ daemonRecord = null;
169
+ deviceProxy = null;
170
+ listener = null;
171
+ androidProbe = null;
172
+ androidVerifiedAt = 0;
173
+ leases = /* @__PURE__ */ new Map();
174
+ released = /* @__PURE__ */ new Set();
175
+ constructor(options) {
176
+ this.options = {
177
+ startTimeoutMs: 6e4,
178
+ stopTimeoutMs: 2e4,
179
+ watchMs: 5e3,
180
+ leaseRenewMs: 12e4,
181
+ ...options
182
+ };
183
+ }
184
+ onExit(listener) {
185
+ this.listener = listener;
186
+ }
187
+ start() {
188
+ if (this.running) return Promise.resolve();
189
+ this.starting ??= this.launch().finally(() => {
190
+ this.starting = null;
191
+ });
192
+ return this.starting;
193
+ }
194
+ async launch() {
195
+ if (this.claim) try {
196
+ await this.teardown();
197
+ } catch {
198
+ throw new Error(`The previous agent-device daemon is unresolved. Once it is gone, clear its claim with: ${claimRemoveCommand(this.claim.path)}`);
199
+ }
200
+ const invocation = resolveAgentDevice(this.options.env);
201
+ if (this.options.device?.serial) {
202
+ const version = await new Promise((resolve) => {
203
+ execFile(invocation.command, [...invocation.args, "--version"], {
204
+ env: this.options.env,
205
+ timeout: this.options.startTimeoutMs
206
+ }, (error, stdout) => resolve(error ? "" : stdout.trim()));
207
+ });
208
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
209
+ if (!match || !(Number(match[1]) > 0 || Number(match[2]) > 21 || Number(match[2]) === 21 && Number(match[3]) >= 22)) throw new AgentDriverUnavailable("Agent control requires agent-device 0.21.22 or later with serial daemon policy on the hosting Mac.");
210
+ }
211
+ mkdirSync(this.options.stateDir, {
212
+ recursive: true,
213
+ mode: 448
214
+ });
215
+ chmodSync(this.options.stateDir, 448);
216
+ const attempt = tryAcquireClaim({
217
+ root: this.options.claimRoot,
218
+ mode: "exclusive",
219
+ label: "agent-device daemon"
220
+ });
221
+ if (attempt.pending) releaseClaim(attempt.pending);
222
+ if (!attempt.acquired) throw new Error(`The agent-device daemon is held by another process: ${this.options.claimRoot}`);
223
+ const claim = attempt.acquired;
224
+ this.claim = claim;
225
+ this.stopping = false;
226
+ this.androidVerifiedAt = 0;
227
+ rmSync(join(this.options.stateDir, "daemon.json"), { force: true });
228
+ const policy = join(this.options.stateDir, "policy.json");
229
+ writeFileSync(policy, `${JSON.stringify(this.options.device ? devicePolicy(this.options.device) : POLICY)}\n`, { mode: 384 });
230
+ markClaimChildPending(claim);
231
+ const token = newAgentToken();
232
+ let proxy;
233
+ try {
234
+ proxy = spawn(invocation.command, [
235
+ ...invocation.args,
236
+ "proxy",
237
+ "--host",
238
+ "127.0.0.1",
239
+ "--state-dir",
240
+ this.options.stateDir
241
+ ], {
242
+ env: {
243
+ ...this.options.env,
244
+ PATH: [dirname(process.execPath), this.options.env.PATH].filter(Boolean).join(delimiter),
245
+ AGENT_DEVICE_DAEMON_AUTH_TOKEN: token,
246
+ AGENT_DEVICE_DAEMON_POLICY: policy,
247
+ AGENT_DEVICE_MACOS_APP_BACKEND: "native",
248
+ AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1",
249
+ ...this.options.device?.serial ? { AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION: "0" } : {},
250
+ ...this.options.device ? { AGENT_DEVICE_CLAIMS_DIR: join(this.options.stateDir, "device-claims") } : {}
251
+ },
252
+ ...this.options.device ? { cwd: "/" } : {},
253
+ detached: true,
254
+ stdio: [
255
+ "ignore",
256
+ "pipe",
257
+ "pipe"
258
+ ]
259
+ });
260
+ if (this.options.device) this.deviceProxy = {
261
+ child: proxy,
262
+ record: proxy.pid === void 0 ? null : this.captured(proxy.pid)
263
+ };
264
+ const url = await this.listening(proxy);
265
+ const { record: daemon, admin } = this.readDaemon();
266
+ this.daemonRecord = daemon;
267
+ const proxyIdentity = proxy.pid === void 0 ? null : captureProcessIdentity(proxy.pid);
268
+ if (!proxyIdentity?.ok) throw new Error("The agent-device proxy identity could not be captured.");
269
+ setClaimChild(claim, daemon);
270
+ if (!await leasesBackend(url, this.options.device ? deviceScope(this.options.device).backend : "macos-app")) throw new AgentDriverUnavailable(this.options.device ? this.options.device.serial === void 0 ? "Agent control requires agent-device 0.21.20 or later with the ios-instance backend and daemon policy on the hosting Mac." : "Agent control requires agent-device 0.21.22 or later with the android-instance backend and serial daemon policy on the hosting Mac." : UNSCOPED);
271
+ const watch = setInterval(() => void this.watchDaemon(), this.options.watchMs);
272
+ watch.unref();
273
+ const running = {
274
+ reported: false,
275
+ proxy,
276
+ proxyRecord: {
277
+ pid: proxy.pid,
278
+ processToken: proxyIdentity.token
279
+ },
280
+ daemon,
281
+ url,
282
+ token,
283
+ admin,
284
+ watch
285
+ };
286
+ this.running = running;
287
+ proxy.once("exit", () => {
288
+ if (this.running === running) this.exited();
289
+ });
290
+ } catch (error) {
291
+ this.running = null;
292
+ if (!this.daemonRecord) try {
293
+ this.daemonRecord = this.readDaemon().record;
294
+ } catch {}
295
+ await this.teardown(proxy).catch(() => void 0);
296
+ throw error;
297
+ }
298
+ }
299
+ listening(proxy) {
300
+ return new Promise((resolve, reject) => {
301
+ let output = "";
302
+ let errors = "";
303
+ const timer = setTimeout(() => finish(/* @__PURE__ */ new Error("The agent-device proxy did not report a listening address.")), this.options.startTimeoutMs);
304
+ const finish = (result) => {
305
+ clearTimeout(timer);
306
+ proxy.stdout?.off("data", onData);
307
+ proxy.stderr?.off("data", onError);
308
+ proxy.stderr?.resume();
309
+ proxy.off("close", onExit);
310
+ proxy.off("error", finish);
311
+ if (result instanceof Error) reject(result);
312
+ else resolve(result);
313
+ };
314
+ const onData = (chunk) => {
315
+ output = (output + chunk.toString()).slice(-4096);
316
+ const match = LISTENING.exec(output);
317
+ if (match) finish(new URL(match[1]));
318
+ };
319
+ const onExit = (code) => finish(/* @__PURE__ */ new Error(`The agent-device proxy exited with ${String(code)} before it was ready.${errors.trim() ? ` ${errors.trim()}` : ""}`));
320
+ const onError = (chunk) => {
321
+ errors = (errors + chunk.toString()).slice(-400);
322
+ };
323
+ proxy.stdout?.on("data", onData);
324
+ proxy.stderr?.on("data", onError);
325
+ proxy.once("close", onExit);
326
+ proxy.once("error", finish);
327
+ });
328
+ }
329
+ readDaemon() {
330
+ const value = JSON.parse(readFileSync(join(this.options.stateDir, "daemon.json"), "utf8"));
331
+ if (!isJsonObject(value) || typeof value.pid !== "number" || !Number.isInteger(value.pid) || value.pid < 2) throw new Error("The agent-device daemon record has no pid.");
332
+ const identity = captureProcessIdentity(value.pid);
333
+ if (!identity.ok) throw new Error("The agent-device daemon identity could not be captured.");
334
+ const record = {
335
+ pid: value.pid,
336
+ processToken: identity.token
337
+ };
338
+ if (typeof value.httpPort !== "number" || !Number.isInteger(value.httpPort) || typeof value.token !== "string" || !value.token) {
339
+ this.daemonRecord = record;
340
+ throw new Error("The agent-device daemon record has no HTTP listener.");
341
+ }
342
+ if (this.options.device && value.policyDigest !== devicePolicyDigest(this.options.device)) {
343
+ this.daemonRecord = record;
344
+ throw new AgentDriverUnavailable(this.options.device.serial === void 0 ? "Agent control requires agent-device 0.21.20 or later enforcing this hosted simulator policy." : "Agent control requires agent-device 0.21.22 or later enforcing this hosted emulator serial policy.");
345
+ }
346
+ return {
347
+ record,
348
+ admin: {
349
+ port: value.httpPort,
350
+ token: value.token
351
+ }
352
+ };
353
+ }
354
+ async androidIdentity(cached = false) {
355
+ const device = this.options.device;
356
+ if (!device?.serial) return "verified";
357
+ if (this.androidProbe) return this.androidProbe;
358
+ if (cached && Date.now() - this.androidVerifiedAt < 2e3) return "verified";
359
+ this.androidProbe = new Promise((resolve) => {
360
+ execFile(adbPath(this.options.env), [
361
+ "-s",
362
+ device.serial,
363
+ "emu",
364
+ "avd",
365
+ "name"
366
+ ], {
367
+ env: this.options.env,
368
+ timeout: 2e3,
369
+ killSignal: "SIGKILL"
370
+ }, (error, stdout, stderr) => {
371
+ if (error) resolve(!error.killed && /device .*not found|device not found|no devices\/emulators found|could not connect to TCP port \d+: Connection refused/.test(`${error.message} ${stderr}`) ? "lost" : "unverified");
372
+ else resolve(stdout.split("\n")[0]?.trim() === device.avdName ? "verified" : "lost");
373
+ });
374
+ });
375
+ try {
376
+ const identity = await this.androidProbe;
377
+ this.androidVerifiedAt = identity === "verified" ? Date.now() : 0;
378
+ return identity;
379
+ } finally {
380
+ this.androidProbe = null;
381
+ }
382
+ }
383
+ async watchDaemon() {
384
+ const running = this.running;
385
+ if (!running || this.stopping || running.reported) return;
386
+ if (inspectProcessIdentity(running.daemon) === "gone") this.exited();
387
+ else if (await this.androidIdentity() === "lost" && this.running === running && !this.stopping) this.exited();
388
+ }
389
+ exited() {
390
+ const running = this.running;
391
+ if (this.stopping || !running || running.reported) return;
392
+ running.reported = true;
393
+ clearInterval(running.watch);
394
+ this.listener?.();
395
+ }
396
+ async stop() {
397
+ this.stopping = true;
398
+ await this.starting?.catch(() => void 0);
399
+ const sessions = [...this.released];
400
+ this.released.clear();
401
+ for (const lease of this.leases.values()) clearInterval(lease.renew);
402
+ this.leases.clear();
403
+ const running = this.running;
404
+ this.running = null;
405
+ if (running) clearInterval(running.watch);
406
+ if (!this.claim && this.options.device) {
407
+ let attempt;
408
+ try {
409
+ attempt = tryAcquireClaim({
410
+ root: this.options.claimRoot,
411
+ mode: "exclusive",
412
+ label: "agent-device daemon"
413
+ });
414
+ } catch (error) {
415
+ throw new Error(`The agent-device daemon claim ${this.options.claimRoot} is unresolved: ${error.message}. Once it is gone, clear it with: ${claimRemoveCommand(this.options.claimRoot)}`, { cause: error });
416
+ }
417
+ if (attempt.pending) releaseClaim(attempt.pending);
418
+ if (!attempt.acquired) {
419
+ const path = attempt.held?.path ?? attempt.waitingFor?.[0]?.path ?? this.options.claimRoot;
420
+ throw new Error(`The agent-device daemon is unresolved; its claim ${path} was kept. Once it is gone, clear it with: ${claimRemoveCommand(path)}`);
421
+ }
422
+ this.claim = attempt.acquired;
423
+ this.daemonRecord = attempt.reaped.find((holder) => holder.child)?.child ?? null;
424
+ }
425
+ if (running || this.claim) await this.teardown(running?.proxy, running ?? void 0);
426
+ for (const session of sessions) this.removeSessionDirectories(session);
427
+ }
428
+ async teardown(proxy, running) {
429
+ proxy ??= this.deviceProxy?.child;
430
+ if (proxy?.pid !== void 0) {
431
+ const record = running?.proxyRecord ?? (this.options.device ? this.deviceProxy?.record : this.captured(proxy.pid));
432
+ const stopped = record ? await this.signalAndWait(record, true) : proxy.exitCode != null || proxy.signalCode != null;
433
+ if (this.options.device && !stopped) throw new Error(`The agent-device proxy is unresolved; its claim ${this.claim?.path} was kept. Once it is gone, clear it with: ${claimRemoveCommand(this.claim.path)}`);
434
+ }
435
+ await this.stopDaemon();
436
+ await this.stopIosRunners();
437
+ await this.stopAndroidHelpers();
438
+ this.daemonRecord = null;
439
+ this.deviceProxy = null;
440
+ if (!this.claim) return;
441
+ clearClaimChild(this.claim);
442
+ if (!releaseClaim(this.claim)) throw new Error(`The agent-device claim could not be released: ${this.claim.path}`);
443
+ this.claim = null;
444
+ }
445
+ captured(pid) {
446
+ const identity = captureProcessIdentity(pid);
447
+ return identity.ok ? {
448
+ pid,
449
+ processToken: identity.token
450
+ } : null;
451
+ }
452
+ async stopDaemon() {
453
+ if (!this.options.device || this.deviceProxy) try {
454
+ const invocation = resolveAgentDevice(this.options.env);
455
+ await new Promise((resolve) => {
456
+ execFile(invocation.command, [
457
+ ...invocation.args,
458
+ "daemon",
459
+ "stop",
460
+ "--state-dir",
461
+ this.options.stateDir
462
+ ], {
463
+ env: {
464
+ ...this.options.env,
465
+ AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1"
466
+ },
467
+ timeout: this.options.stopTimeoutMs
468
+ }, () => resolve());
469
+ });
470
+ } catch {}
471
+ if (this.deviceProxy?.child.pid !== void 0 && !this.daemonRecord) throw new Error(`The agent-device daemon identity is unresolved; its claim ${this.claim?.path} was kept. Once it is gone, clear it with: ${claimRemoveCommand(this.claim.path)}`);
472
+ if (this.daemonRecord && !await this.signalAndWait(this.daemonRecord, false)) throw new Error(`The agent-device daemon did not stop; its claim ${this.claim?.path} was kept.`);
473
+ }
474
+ async signalAndWait(record, group) {
475
+ for (const signal of ["SIGTERM", "SIGKILL"]) {
476
+ const status = inspectProcessIdentity(record);
477
+ if (status === "gone" || status === "different") return true;
478
+ if (status === "same") try {
479
+ process.kill(group ? -record.pid : record.pid, signal);
480
+ } catch {}
481
+ if (await waitForProcessExit(record, signal === "SIGTERM" ? 3e3 : 2e3)) return true;
482
+ }
483
+ return false;
484
+ }
485
+ async stopAndroidHelpers() {
486
+ const serial = this.options.device?.serial;
487
+ if (!serial || !this.daemonRecord) return;
488
+ const identity = await this.androidIdentity();
489
+ if (identity === "lost") return;
490
+ if (identity === "unverified") throw new Error("The hosted Android emulator identity could not be verified; its daemon claim was kept.");
491
+ const adb = (args) => new Promise((resolve, reject) => {
492
+ execFile(adbPath(this.options.env), [
493
+ "-s",
494
+ serial,
495
+ "shell",
496
+ ...args
497
+ ], {
498
+ env: this.options.env,
499
+ timeout: this.options.stopTimeoutMs
500
+ }, (error, stdout) => {
501
+ if (error && !(args[0] === "pidof" && error.code === 1 && !stdout.trim())) reject(error);
502
+ else resolve(stdout);
503
+ });
504
+ });
505
+ try {
506
+ for (const helper of ["com.callstack.agentdevice.snapshothelper", "com.callstack.agentdevice.imehelper"]) {
507
+ await adb([
508
+ "am",
509
+ "force-stop",
510
+ helper
511
+ ]);
512
+ if ((await adb(["pidof", helper])).trim()) throw new Error(`The agent-device Android helper ${helper} did not stop; its daemon claim ${this.claim?.path} was kept.`);
513
+ }
514
+ } catch (error) {
515
+ if (await this.androidIdentity() !== "lost") throw error;
516
+ }
517
+ }
518
+ async stopIosRunners() {
519
+ if (!this.options.device?.udid) return;
520
+ const udid = this.options.device.udid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
521
+ const destination = new RegExp(`(?:^|\\s)-destination\\s+platform=iOS Simulator,id=${udid}(?=\\s|$)`, "i");
522
+ const runners = (ps) => ps.split("\n").flatMap((line) => {
523
+ const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
524
+ if (!match || !/^(?:[^\n]*\/)?xcodebuild\s/.test(match[3]) || !/\stest-without-building(?:\s|$)/.test(match[3]) || !/\s-only-testing\s+AgentDeviceRunnerUITests\/RunnerTests\/testCommand(?:\s|$)/.test(match[3]) || !destination.test(match[3])) return [];
525
+ return [{
526
+ pid: Number(match[1]),
527
+ group: Number(match[1]) === Number(match[2])
528
+ }];
529
+ });
530
+ const candidates = runners(await listProcesses("pid=,pgid=,command=")).map((runner) => ({
531
+ pid: runner.pid,
532
+ group: runner.group,
533
+ record: this.captured(runner.pid)
534
+ }));
535
+ if (!candidates.length) return;
536
+ const current = runners(await listProcesses("pid=,pgid=,command="));
537
+ const failures = [];
538
+ for (const runner of candidates) {
539
+ if (!current.some((live) => live.pid === runner.pid && live.group === runner.group)) continue;
540
+ if (!runner.record || !await this.signalAndWait(runner.record, runner.group)) failures.push(runner.pid);
541
+ }
542
+ if (failures.length) throw new Error(`The agent-device iOS runners did not stop (${failures.join(", ")}); their daemon claim ${this.claim?.path} was kept.`);
543
+ }
544
+ /**
545
+ * Grants one device's automatic instance lease or allocates and renews a macos-app lease over
546
+ * agent-device's host-only admin route. Forwarded requests are pinned to the grant's scope.
547
+ */
548
+ async issue(app) {
549
+ const running = this.running;
550
+ if (!running) throw new Error("The agent-device daemon is not running.");
551
+ if (this.options.device) {
552
+ if (app.session !== this.options.device.session || app.udid !== this.options.device.udid || app.serial !== this.options.device.serial || app.avdName !== this.options.device.avdName) throw new Error(`The agent-device daemon belongs to another hosted ${this.options.device.serial ? "emulator" : "simulator"}.`);
553
+ if (await this.androidIdentity() !== "verified") throw new AgentDriverUnavailable("Agent control is refused: the hosted Android emulator identity could not be verified.");
554
+ const scope = {
555
+ tenant: `stim.${app.session}`,
556
+ runId: app.session,
557
+ clientId: "agent",
558
+ deviceKey: `${deviceScope(this.options.device).platform}:mobile:${deviceScope(this.options.device).id}`,
559
+ backend: deviceScope(this.options.device).backend
560
+ };
561
+ this.leases.set(app.session, {
562
+ id: app.session,
563
+ scope,
564
+ renewing: Promise.resolve()
565
+ });
566
+ return {
567
+ driver: "agent-device",
568
+ path: agentRoute(app.session),
569
+ token: newAgentToken(),
570
+ scope: app.session,
571
+ lease: scope
572
+ };
573
+ }
574
+ const scope = {
575
+ tenant: `stim.${app.session}`,
576
+ runId: app.session,
577
+ clientId: "agent",
578
+ deviceKey: `${app.bundleId}@${app.pid}`
579
+ };
580
+ await this.revoke(app.session);
581
+ const id = randomBytes(16).toString("hex");
582
+ await putLease(running.admin, id, scope);
583
+ const lease = {
584
+ id,
585
+ scope,
586
+ renewing: Promise.resolve(),
587
+ renew: setInterval(() => {
588
+ if (this.running !== running) return;
589
+ lease.renewing = putLease(running.admin, id, scope).catch((error) => process.stderr.write(`Hosted agent lease renewal failed: ${error.message}\n`));
590
+ }, this.options.leaseRenewMs)
591
+ };
592
+ lease.renew?.unref();
593
+ this.leases.set(app.session, lease);
594
+ return {
595
+ driver: "agent-device",
596
+ path: agentRoute(app.session),
597
+ token: newAgentToken(),
598
+ scope: id,
599
+ lease: scope
600
+ };
601
+ }
602
+ async revoke(session) {
603
+ const lease = this.leases.get(session);
604
+ if (!lease) return;
605
+ this.leases.delete(session);
606
+ this.released.add(session);
607
+ clearInterval(lease.renew);
608
+ try {
609
+ await lease.renewing;
610
+ if (this.running && !this.options.device) await adminRequest(this.running.admin, "DELETE", lease.id);
611
+ } finally {
612
+ this.removeSessionDirectories(session);
613
+ }
614
+ }
615
+ removeSessionDirectories(session) {
616
+ const sessionsDir = join(this.options.stateDir, "sessions");
617
+ try {
618
+ for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
619
+ if (!entry.isDirectory() || !entry.name.startsWith(`stim.${session}_`)) continue;
620
+ try {
621
+ rmSync(join(sessionsDir, entry.name), {
622
+ recursive: true,
623
+ force: true
624
+ });
625
+ } catch (error) {
626
+ process.stderr.write(`Hosted agent session cleanup failed: ${error.message.replace(/\s+/g, " ")}\n`);
627
+ }
628
+ }
629
+ } catch (error) {
630
+ if (error.code !== "ENOENT") process.stderr.write(`Hosted agent session cleanup failed: ${error.message.replace(/\s+/g, " ")}\n`);
631
+ }
632
+ }
633
+ forward(session, request, response) {
634
+ const running = this.running;
635
+ const lease = this.leases.get(session);
636
+ if (!running || !lease) {
637
+ response.writeHead(503, { "content-type": "text/plain" }).end("Agent control is not running.\n");
638
+ return;
639
+ }
640
+ const target = daemonPath(session, request.url ?? "", request.method);
641
+ if (!target) {
642
+ response.writeHead(404, { "content-type": "text/plain" }).end("Not found.\n");
643
+ return;
644
+ }
645
+ if (!target.startsWith("/rpc")) {
646
+ this.relay(running, lease, request, response, target);
647
+ return;
648
+ }
649
+ this.relayPinned(running, lease, request, response, target);
650
+ }
651
+ async relayPinned(running, lease, request, response, target) {
652
+ let body;
653
+ try {
654
+ body = await readBody(request, MAX_RPC_BYTES);
655
+ } catch {
656
+ response.destroy();
657
+ return;
658
+ }
659
+ const pinned = pinLease(body, lease);
660
+ if (!Buffer.isBuffer(pinned)) response.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({
661
+ jsonrpc: "2.0",
662
+ id: pinned.id,
663
+ error: {
664
+ code: -32e3,
665
+ message: pinned.message,
666
+ data: {
667
+ code: "UNAUTHORIZED",
668
+ message: pinned.message,
669
+ hint: `The hosted agent connection allows only ${(this.options.device ? DEVICE_COMMANDS : POLICY.commands.allow).join(", ")} on the one ${this.options.device ? this.options.device.udid ? "hosted iOS simulator" : "hosted Android emulator" : "leased macOS app"}; retrying will not help.`,
670
+ retriable: false,
671
+ details: {
672
+ reason: "STIM_AGENT_REQUEST_REFUSED",
673
+ rule: pinned.rule,
674
+ ...pinned.details
675
+ }
676
+ }
677
+ }
678
+ }));
679
+ else await this.relay(running, lease, request, response, target, pinned);
680
+ }
681
+ async relay(running, lease, request$1, response, target, body) {
682
+ const identity = await this.androidIdentity(true);
683
+ if (identity !== "verified" || this.running !== running || this.stopping || running.reported) {
684
+ response.writeHead(503, { "content-type": "text/plain" }).end(this.options.device?.serial ? "Agent control is refused: the hosted emulator is absent, replaced, or its identity could not be verified.\n" : "Agent control is not running.\n");
685
+ if (identity === "lost" && this.running === running) this.exited();
686
+ return;
687
+ }
688
+ const headers = {
689
+ authorization: `Bearer ${running.token}`,
690
+ "x-agent-device-tenant": lease.scope.tenant
691
+ };
692
+ for (const name of FORWARDED) {
693
+ const value = request$1.headers[name];
694
+ if (typeof value === "string") headers[name] = value;
695
+ }
696
+ if (body) headers["content-length"] = String(body.length);
697
+ const upstream = request({
698
+ host: running.url.hostname,
699
+ port: running.url.port,
700
+ method: request$1.method,
701
+ path: target,
702
+ headers
703
+ }, (answer) => {
704
+ const out = {};
705
+ for (const [name, value] of Object.entries(answer.headers)) if (value !== void 0 && !HOP_BY_HOP.has(name)) out[name] = value;
706
+ response.writeHead(answer.statusCode ?? 502, out);
707
+ answer.pipe(response);
708
+ answer.once("error", () => response.destroy());
709
+ });
710
+ upstream.once("error", () => {
711
+ if (response.headersSent) response.destroy();
712
+ else response.writeHead(502, { "content-type": "text/plain" }).end("Agent control is unavailable.\n");
713
+ });
714
+ response.once("close", () => {
715
+ if (!response.writableFinished) upstream.destroy();
716
+ });
717
+ if (body) upstream.end(body);
718
+ else request$1.pipe(upstream);
719
+ }
720
+ };
721
+ /**
722
+ * Rewrites one JSON-RPC body so it can act only under the session's lease. The proxy token is one credential
723
+ * for every client, so the lease, its owner scope and tenant session isolation come from the host, never from
724
+ * the client. Methods other than commands and lease renewal or release are refused.
725
+ */
726
+ function pinLease(body, lease) {
727
+ let rpc;
728
+ try {
729
+ rpc = body === null ? void 0 : JSON.parse(body.toString("utf8"));
730
+ } catch {}
731
+ const id = isJsonObject(rpc) && (typeof rpc.id === "string" || typeof rpc.id === "number") ? rpc.id : null;
732
+ if (!isJsonObject(rpc) || typeof rpc.method !== "string" || !isJsonObject(rpc.params)) return {
733
+ id,
734
+ rule: "request",
735
+ message: "Refused request: expected a JSON object with a string method and object params within the body size limit.",
736
+ details: {}
737
+ };
738
+ if (lease.scope.backend) return pinDeviceRequest(rpc, id, lease);
739
+ const { tenant, runId, clientId, deviceKey } = lease.scope;
740
+ const owner = {
741
+ runId,
742
+ leaseId: lease.id,
743
+ clientId,
744
+ deviceKey,
745
+ leaseProvider: "proxy"
746
+ };
747
+ if (COMMAND_METHODS.has(rpc.method)) {
748
+ const { runtime: _runtime, meta, flags, input, ...params } = rpc.params;
749
+ const refuseCommand = (value) => {
750
+ const command = typeof value === "string" ? value.slice(0, MAX_ECHO) : void 0;
751
+ return {
752
+ id,
753
+ rule: "command",
754
+ message: `Refused command${command === void 0 ? "" : ` "${command}"`}. Allowed commands: ${POLICY.commands.allow.join(", ")}.`,
755
+ details: command === void 0 ? {} : { command }
756
+ };
757
+ };
758
+ if (!isAllowedCommand(params.command)) return refuseCommand(params.command);
759
+ const steps = isJsonObject(flags) && Array.isArray(flags.batchSteps) ? flags.batchSteps : [];
760
+ for (const step of steps) if (!isJsonObject(step) || !isAllowedCommand(step.command)) return refuseCommand(isJsonObject(step) ? step.command : void 0);
761
+ rpc.params = {
762
+ ...params,
763
+ flags: {
764
+ ...withoutDeviceSelectors(flags),
765
+ platform: "macos",
766
+ ...steps.length ? { batchSteps: steps.map(pinStep) } : {}
767
+ },
768
+ ...isJsonObject(input) ? { input: withoutDeviceSelectors(input) } : {},
769
+ meta: {
770
+ ...pickClientMeta(meta),
771
+ ...owner,
772
+ tenantId: tenant,
773
+ leaseBackend: "macos-app",
774
+ sessionIsolation: "tenant"
775
+ }
776
+ };
777
+ } else if (LEASE_METHODS.has(rpc.method)) {
778
+ const { tenant: _tenant, provider: _provider, ...params } = rpc.params;
779
+ rpc.params = {
780
+ ...params,
781
+ ...owner,
782
+ tenantId: tenant,
783
+ backend: "macos-app"
784
+ };
785
+ } else {
786
+ const method = rpc.method.slice(0, MAX_ECHO);
787
+ return {
788
+ id,
789
+ rule: "method",
790
+ message: `Refused method "${method}".`,
791
+ details: { method }
792
+ };
793
+ }
794
+ return Buffer.from(JSON.stringify(rpc));
795
+ }
796
+ const DEVICE_LEASE_METHODS = /* @__PURE__ */ new Set([
797
+ ...LEASE_METHODS,
798
+ "agent_device.lease.allocate",
799
+ "agent-device.lease.allocate"
800
+ ]);
801
+ const DEVICE_CLIENT_AMBIENT_INPUTS = [
802
+ "cwd",
803
+ "stateDir",
804
+ "config",
805
+ "remoteConfig",
806
+ "daemonBaseUrl",
807
+ "daemonAuthToken",
808
+ "daemonTransport",
809
+ "daemonServerMode",
810
+ "tenant",
811
+ "tenantId",
812
+ "runId",
813
+ "clientId",
814
+ "deviceKey",
815
+ "leaseBackend",
816
+ "sessionIsolation",
817
+ "leaseProvider",
818
+ "provider"
819
+ ];
820
+ const HOST_INPUTS = [
821
+ "out",
822
+ "saveScript",
823
+ "sessionSaveScript",
824
+ "baseline",
825
+ "launchConsole",
826
+ "launchArgs",
827
+ "launchUrl",
828
+ "bundleUrl",
829
+ "artifactsDir",
830
+ "stepsFile",
831
+ "searchPath",
832
+ "retainPaths",
833
+ "installSource",
834
+ "metroProjectRoot",
835
+ "metroRuntimeFile",
836
+ "iosXctestrunFile",
837
+ "iosXctestDerivedDataPath",
838
+ "iosXctestEnvDir",
839
+ "developerDir",
840
+ "artifact",
841
+ "dsym",
842
+ "reportJunit",
843
+ "recordAs",
844
+ "keyframes"
845
+ ];
846
+ function pinDeviceRequest(rpc, id, lease) {
847
+ const params = rpc.params;
848
+ const { tenant, runId, clientId, deviceKey } = lease.scope;
849
+ const owner = {
850
+ tenantId: tenant,
851
+ runId,
852
+ clientId,
853
+ deviceKey,
854
+ leaseProvider: "proxy"
855
+ };
856
+ const platform = lease.scope.backend === "ios-instance" ? "ios" : "android";
857
+ const device = {
858
+ platform,
859
+ selector: platform === "ios" ? "udid" : "serial",
860
+ id: deviceKey.slice(`${platform}:mobile:`.length)
861
+ };
862
+ if (platform === "android" && [
863
+ params,
864
+ params.flags,
865
+ params.input
866
+ ].some((fields) => isJsonObject(fields) && fields.serial !== void 0 && fields.serial !== device.id)) return {
867
+ id,
868
+ rule: "device",
869
+ message: "Another emulator is refused; this connection targets one hosted emulator.",
870
+ details: {}
871
+ };
872
+ if (COMMAND_METHODS.has(rpc.method)) {
873
+ const refusal = inspectDeviceCommand(params, id, device);
874
+ if (refusal) return refusal;
875
+ rpc.params = {
876
+ ...pinDeviceCommand(params, device),
877
+ meta: {
878
+ ...pickClientMeta(params.meta),
879
+ ...owner,
880
+ ...isJsonObject(params.meta) && typeof params.meta.leaseId === "string" ? { leaseId: params.meta.leaseId } : {},
881
+ leaseBackend: lease.scope.backend,
882
+ sessionIsolation: "tenant"
883
+ }
884
+ };
885
+ } else if (DEVICE_LEASE_METHODS.has(rpc.method)) {
886
+ if (platform === "android" && typeof params.deviceKey === "string" && params.deviceKey.startsWith("android:mobile:") && params.deviceKey !== deviceKey) return {
887
+ id,
888
+ rule: "device",
889
+ message: "Another emulator lease is refused.",
890
+ details: {}
891
+ };
892
+ rpc.params = {
893
+ ...owner,
894
+ backend: lease.scope.backend,
895
+ ...typeof params.leaseId === "string" ? { leaseId: params.leaseId } : {},
896
+ ...typeof params.session === "string" ? { session: params.session } : {},
897
+ ...typeof params.ttlMs === "number" ? { ttlMs: params.ttlMs } : {}
898
+ };
899
+ } else return {
900
+ id,
901
+ rule: "method",
902
+ message: "Refused method.",
903
+ details: {}
904
+ };
905
+ return Buffer.from(JSON.stringify(rpc));
906
+ }
907
+ function remoteScreenshot(value) {
908
+ return typeof value === "string" && /^\/tmp\/agent-device-screenshot-\d+-[a-z0-9]+\.png$/.test(value);
909
+ }
910
+ function inspectDeviceCommand(params, id, device) {
911
+ const refuse = (rule, message) => ({
912
+ id,
913
+ rule,
914
+ message,
915
+ details: {}
916
+ });
917
+ if (typeof params.command !== "string" || !DEVICE_COMMANDS.includes(params.command)) return refuse("command", "Refused command: this connection allows only hosted device inspection and interaction.");
918
+ if (device.platform === "android" && params.serial !== void 0 && params.serial !== device.id) return refuse("device", "Another emulator is refused; this connection targets one hosted emulator.");
919
+ const noun = device.platform === "android" ? "emulator" : "simulator";
920
+ const screenshot = params.command === "screenshot";
921
+ for (const fields of [params.flags, params.input]) {
922
+ if (!isJsonObject(fields)) continue;
923
+ if (device.platform === "android" ? fields.serial !== void 0 && fields.serial !== device.id : typeof fields.udid === "string" && fields.udid.trim() !== device.id) return refuse("device", `Another ${noun} is refused; this connection targets one hosted ${noun}.`);
924
+ if (HOST_INPUTS.some((key) => fields[key] !== void 0 && fields[key] !== false && !(screenshot && key === "out" && remoteScreenshot(fields[key])))) return refuse("host-path", "Host paths and launch inputs are refused.");
925
+ if (fields.shutdown === true) return refuse("command", "Simulator shutdown is refused; use stim stop.");
926
+ }
927
+ const positionals = Array.isArray(params.positionals) ? params.positionals : [];
928
+ if (screenshot && positionals.some((value) => !remoteScreenshot(value))) return refuse("host-path", "Screenshots use only agent-device remote artifacts.");
929
+ if ((params.command === "open" || params.command === "close") && positionals.some((value) => typeof value !== "string" || /[\\:]/.test(value) || value.includes("/") || value.includes(String.fromCharCode(0)))) return refuse("host-path", "Open and close accept only installed simulator app names or bundle ids.");
930
+ for (const fields of [params.flags, params.input]) {
931
+ if (!isJsonObject(fields) || fields.batchSteps === void 0) continue;
932
+ if (!Array.isArray(fields.batchSteps)) return refuse("request", "Invalid batch steps.");
933
+ for (const step of fields.batchSteps) {
934
+ if (!isJsonObject(step)) return refuse("request", "Invalid batch step.");
935
+ const failure = inspectDeviceCommand(step, id, device);
936
+ if (failure) return failure;
937
+ }
938
+ }
939
+ return null;
940
+ }
941
+ function pinDeviceCommand(params, device) {
942
+ const { runtime: _runtime, meta: _meta, flags, input, ...rest } = params;
943
+ const fields = (value) => {
944
+ const source = withoutDeviceSelectors(value);
945
+ for (const key of DEVICE_CLIENT_AMBIENT_INPUTS) delete source[key];
946
+ return {
947
+ ...source,
948
+ platform: device.platform,
949
+ [device.selector]: device.id,
950
+ ...Array.isArray(source.batchSteps) ? { batchSteps: source.batchSteps.map((step) => pinDeviceCommand(step, device)) } : {}
951
+ };
952
+ };
953
+ return {
954
+ command: rest.command,
955
+ ...rest.session !== void 0 ? { session: rest.session } : {},
956
+ ...rest.positionals !== void 0 ? { positionals: rest.positionals } : {},
957
+ flags: fields(flags),
958
+ ...isJsonObject(input) ? { input: fields(input) } : {}
959
+ };
960
+ }
961
+ /**
962
+ * Flags that pick a device. The host forces `platform: macos` so a first `open` cannot resolve to a host
963
+ * simulator, emulator or phone; agent-device's `macos-app` admission enforces the same rule.
964
+ */
965
+ const DEVICE_SELECTORS = [
966
+ "device",
967
+ "udid",
968
+ "serial",
969
+ "target",
970
+ "iosSimulatorDeviceSet",
971
+ "androidDeviceAllowlist"
972
+ ];
973
+ function withoutDeviceSelectors(fields) {
974
+ if (!isJsonObject(fields)) return {};
975
+ return Object.fromEntries(Object.entries(fields).filter(([key]) => !DEVICE_SELECTORS.includes(key)));
976
+ }
977
+ function pinStep(step) {
978
+ if (!isJsonObject(step)) return step;
979
+ const { runtime: _runtime, flags, input, ...rest } = step;
980
+ return {
981
+ ...rest,
982
+ flags: {
983
+ ...withoutDeviceSelectors(flags),
984
+ platform: "macos"
985
+ },
986
+ ...isJsonObject(input) ? { input: withoutDeviceSelectors(input) } : {}
987
+ };
988
+ }
989
+ function isAllowedCommand(command) {
990
+ return typeof command === "string" && POLICY.commands.allow.includes(command);
991
+ }
992
+ /**
993
+ * The request metadata a client may set: reporting preferences and the local paths its own artifacts land
994
+ * at. Host paths (`cwd`, `developerDir`), install sources, lock and lease options and runtime hints stay with
995
+ * the host.
996
+ */
997
+ const CLIENT_META = [
998
+ "requestId",
999
+ "debug",
1000
+ "includeCost",
1001
+ "responseLevel",
1002
+ "sessionExplicit",
1003
+ "clientArtifactPaths"
1004
+ ];
1005
+ function pickClientMeta(meta) {
1006
+ if (!isJsonObject(meta)) return {};
1007
+ return Object.fromEntries(CLIENT_META.filter((key) => meta[key] !== void 0).map((key) => [key, meta[key]]));
1008
+ }
1009
+ function readBody(request, limit) {
1010
+ return new Promise((resolve, reject) => {
1011
+ const chunks = [];
1012
+ let size = 0;
1013
+ request.on("data", (chunk) => {
1014
+ size += chunk.length;
1015
+ if (size <= limit) chunks.push(chunk);
1016
+ });
1017
+ request.once("end", () => resolve(size > limit ? null : Buffer.concat(chunks)));
1018
+ request.once("error", reject);
1019
+ });
1020
+ }
1021
+ function leasesBackend(url, backend) {
1022
+ return new Promise((resolve) => {
1023
+ const probe = request({
1024
+ host: url.hostname,
1025
+ port: url.port,
1026
+ method: "GET",
1027
+ path: "/health",
1028
+ timeout: 1e4
1029
+ }, (answer) => {
1030
+ let text = "";
1031
+ answer.on("data", (chunk) => text += chunk.toString());
1032
+ answer.once("end", () => {
1033
+ try {
1034
+ const health = JSON.parse(text);
1035
+ const backends = isJsonObject(health) && isJsonObject(health.upstream) ? health.upstream.leaseBackends : null;
1036
+ resolve(Array.isArray(backends) && backends.includes(backend));
1037
+ } catch {
1038
+ resolve(false);
1039
+ }
1040
+ });
1041
+ });
1042
+ probe.once("timeout", () => probe.destroy());
1043
+ probe.once("error", () => resolve(false));
1044
+ probe.end();
1045
+ });
1046
+ }
1047
+ function putLease(admin, id, scope) {
1048
+ return adminRequest(admin, "PUT", id, {
1049
+ tenantId: scope.tenant,
1050
+ runId: scope.runId,
1051
+ clientId: scope.clientId,
1052
+ leaseBackend: "macos-app",
1053
+ leaseProvider: "proxy",
1054
+ deviceKey: scope.deviceKey,
1055
+ ttlMs: LEASE_TTL_MS
1056
+ });
1057
+ }
1058
+ /** One call to agent-device's host-only `/admin/leases` route on the daemon's loopback listener. */
1059
+ function adminRequest(admin, method, id, body) {
1060
+ const payload = body ? Buffer.from(JSON.stringify(body)) : void 0;
1061
+ return new Promise((resolve, reject) => {
1062
+ const call = request({
1063
+ host: "127.0.0.1",
1064
+ port: admin.port,
1065
+ method,
1066
+ path: `/admin/leases/${id}`,
1067
+ timeout: 1e4,
1068
+ headers: {
1069
+ authorization: `Bearer ${admin.token}`,
1070
+ ...payload ? {
1071
+ "content-type": "application/json",
1072
+ "content-length": String(payload.length)
1073
+ } : {}
1074
+ }
1075
+ }, (answer) => {
1076
+ let text = "";
1077
+ answer.on("data", (chunk) => text += chunk.toString());
1078
+ answer.once("end", () => answer.statusCode === 200 ? resolve() : reject(/* @__PURE__ */ new Error(`agent-device refused the lease (${String(answer.statusCode)}): ${text.slice(0, 200)}`)));
1079
+ });
1080
+ call.once("timeout", () => call.destroy(/* @__PURE__ */ new Error("agent-device did not answer the lease request.")));
1081
+ call.once("error", reject);
1082
+ call.end(payload);
1083
+ });
1084
+ }
1085
+ const DAEMON_PATHS = /^\/(?:rpc|health|artifacts)(?:\/[\w.~-]+)*\/?$/;
1086
+ /**
1087
+ * The proxy path behind a session route, only for commands (`/rpc`), health and artifact downloads; null
1088
+ * otherwise. Uploads serve installs, which a macos-app lease refuses, and session diagnostics are not scoped
1089
+ * to one client.
1090
+ */
1091
+ function daemonPath(session, url, method) {
1092
+ const prefix = `${AGENT_ROUTE_PREFIX}${session}`;
1093
+ if (!url.startsWith(prefix)) return null;
1094
+ const rest = url.slice(prefix.length);
1095
+ const query = rest.indexOf("?");
1096
+ const path = query < 0 ? rest : rest.slice(0, query);
1097
+ if (!DAEMON_PATHS.test(path) || path.includes("..")) return null;
1098
+ return (path.startsWith("/rpc") ? method === "POST" : method === "GET") ? rest : null;
1099
+ }
1100
+ //#endregion
1101
+ //#region index.ts
1102
+ const apiVersion = AGENT_DRIVER_API_VERSION;
1103
+ const id = "agent-device";
1104
+ function createAgentDriver(input) {
1105
+ return new AgentDeviceDriver(input);
1106
+ }
1107
+ const client = {
1108
+ configFile: "agent-device-remote.json",
1109
+ access({ grant, baseUrl, file, platform }) {
1110
+ if (grant.driver !== "agent-device" || !grant.lease || grant.lease.backend !== (platform === "macos" ? void 0 : `${platform}-instance`)) return null;
1111
+ const config = {
1112
+ daemonBaseUrl: baseUrl,
1113
+ daemonAuthToken: grant.token,
1114
+ tenant: grant.lease.tenant,
1115
+ sessionIsolation: "tenant",
1116
+ runId: grant.lease.runId,
1117
+ ...platform === "macos" ? { leaseId: grant.scope } : {},
1118
+ leaseBackend: platform === "macos" ? "macos-app" : `${platform}-instance`,
1119
+ leaseProvider: "proxy",
1120
+ clientId: grant.lease.clientId,
1121
+ deviceKey: grant.lease.deviceKey,
1122
+ platform
1123
+ };
1124
+ return {
1125
+ config: `${JSON.stringify(config, null, 2)}\n`,
1126
+ command: `agent-device <command> --remote-config ${file}`
1127
+ };
1128
+ }
1129
+ };
1130
+ const actions = { read: ({ workspaceDir }) => createAgentActionReader({ sessionsDirs: [join(workspaceDir, "agent-device", "sessions")] })() };
1131
+ //#endregion
1132
+ export { AgentDeviceDriver, actions, apiVersion, client, createAgentDriver, id, resolveAgentDevice };
package/package.json CHANGED
@@ -1,11 +1,36 @@
1
1
  {
2
2
  "name": "@stim-cli/agent-driver-agent-device",
3
- "version": "0.0.0",
4
- "description": "Placeholder. The first real version is published by the Stim release workflow.",
3
+ "version": "1.21.0",
4
+ "description": "agent-device driver for stim-server hosted apps.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/appandflow/stim.git",
9
9
  "directory": "packages/agent-driver-agent-device"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "type": "module",
17
+ "main": "dist/index.mjs",
18
+ "types": "dist/index.d.mts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.mts",
22
+ "module-sync": "./dist/index.mjs",
23
+ "default": "./dist/index.mjs"
24
+ }
25
+ },
26
+ "dependencies": {
27
+ "@stim-cli/agent-driver": "^1.21.0",
28
+ "@stim-cli/core": "^1.21.0"
29
+ },
30
+ "engines": {
31
+ "node": ">=22.12.0"
32
+ },
33
+ "scripts": {
34
+ "build": "tsdown"
10
35
  }
11
- }
36
+ }