1// @effect-diagnostics globalJsonInEffect:off - JSON string literals embed paths safely into generated JavaScript.
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7
8const SHIM_DIR = "device/bin";
9const TARGETS_DIR = "device/targets";
10const STALE_RUNTIME =
11 "This agent-device command is out of date because T3 Code moved or updated its runtime. Call device_open again for a fresh one.";
12
13const quoteShell = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'";
14
15/** agent-device 0.21.36's flags that take a value, so the launcher can find the command past them. */
16const VALUE_FLAGS: ReadonlyArray<string> = [
17 "--activity",
18 "--all",
19 "--android-device-allowlist",
20 "--app",
21 "--appium-version",
22 "--artifact",
23 "--artifacts-dir",
24 "--aws-app-arn",
25 "--aws-device-arn",
26 "--aws-interaction-mode",
27 "--aws-project-arn",
28 "--aws-region",
29 "-b",
30 "--baseline",
31 "--bearer-token",
32 "--bundle-url",
33 "--button",
34 "--config",
35 "--count",
36 "--crop-on",
37 "--custom-network",
38 "-d",
39 "--daemon-auth-token",
40 "--daemon-base-url",
41 "--daemon-server-mode",
42 "--daemon-transport",
43 "--delay-ms",
44 "--depth",
45 "--device",
46 "--device-orientation",
47 "--dsym",
48 "--duration-ms",
49 "--fps",
50 "--from",
51 "--geo-location",
52 "--github-actions-artifact",
53 "--header",
54 "--hold-ms",
55 "--host",
56 "--image-tag",
57 "--in-app",
58 "--include",
59 "--interval-ms",
60 "--ios-simulator-device-set",
61 "--ios-xctest-derived-data-path",
62 "--ios-xctest-env-dir",
63 "--ios-xctestrun-file",
64 "--jitter-px",
65 "--keyframes",
66 "--kind",
67 "--language",
68 "--launch-args",
69 "--launch-console",
70 "--launch-url",
71 "--lease-backend",
72 "--lease-id",
73 "--level",
74 "--listen-host",
75 "--locale",
76 "--max-passes",
77 "--max-steps",
78 "--metro-host",
79 "--metro-kind",
80 "--metro-port",
81 "--network-profile",
82 "--on-error",
83 "--os",
84 "--os-version",
85 "--out",
86 "--pattern",
87 "--pause-ms",
88 "--pixel-density",
89 "--pixels",
90 "--plan-digest",
91 "--platform",
92 "--pointer-count",
93 "--port",
94 "--probe-timeout-ms",
95 "--project-root",
96 "--provider",
97 "--provider-app",
98 "--provider-appium-version",
99 "--provider-build",
100 "--provider-custom-network",
101 "--provider-device-orientation",
102 "--provider-device-type",
103 "--provider-geo-location",
104 "--provider-language",
105 "--provider-locale",
106 "--provider-network-hosts",
107 "--provider-network-profile",
108 "--provider-os-version",
109 "--provider-project",
110 "--provider-session",
111 "--provider-session-name",
112 "--provider-timezone",
113 "--proxy-base-url",
114 "--public-base-url",
115 "--quality",
116 "--record-as",
117 "--remote-config",
118 "--reporter",
119 "--report-junit",
120 "--retention-ms",
121 "--retries",
122 "--run-id",
123 "--runtime-file",
124 "-s",
125 "--scale",
126 "--scope",
127 "--search-path",
128 "--serial",
129 "--session",
130 "--session-isolation",
131 "--session-lock",
132 "--settle-quiet",
133 "--shard-all",
134 "--shard-split",
135 "--startup-timeout-ms",
136 "--state-dir",
137 "--status-host",
138 "--steps",
139 "--steps-file",
140 "--surface",
141 "--system",
142 "--target",
143 "--target-app",
144 "--template",
145 "--tenant",
146 "--threshold",
147 "--timeout",
148 "--timezone",
149 "--udid",
150 "--until",
151 "--wait",
152];
153
154// Help gets no bound flags (agent-device reads --config even for help); only args before `--` are flags.
155// agent-device 0.21.31 refuses some bound flags per command; connect/disconnect/auth/daemon would
156// replace T3's connection; device status reads this machine's claims. It reports a sandboxed shell and a
157// dropped tunnel alike, so check first, at --daemon-base-url if given, as agent-device prefers it.
158const launcherSource = (node: string, entryPath: string, boundArgs: ReadonlyArray<string>) =>
159 `import { spawn } from "node:child_process";
160import { existsSync, readFileSync } from "node:fs";
161import { connect } from "node:net";
162const entry = ${JSON.stringify(entryPath)};
163const bound = ${JSON.stringify(boundArgs)};
164const refused = new Map([["cdp", ["--platform", "--udid", "--config", "--session"]], ["connection", ["--platform", "--udid"]]]);
165const managed = ["connect", "disconnect", "auth", "daemon"];
166const args = process.argv.slice(2);
167const end = args.indexOf("--");
168const options = end < 0 ? args : args.slice(0, end);
169const valued = new Set(${JSON.stringify(VALUE_FLAGS)});
170let name = "";
171for (let index = 0; index < args.length; index++) {
172 const arg = args[index];
173 if (arg === "--") { name = args[index + 1] ?? ""; break; }
174 if (!arg.startsWith("-")) { name = arg; break; }
175 if (!arg.includes("=") && valued.has(arg)) index++;
176}
177const informational = args.length === 0 || ["help", "--help", "-h", "--version", "version"].includes(args[0]) || options.includes("--help") || options.includes("-h");
178if (!informational && managed.includes(name)) {
179 console.error("T3 Code manages this command's connection to the device, so \`" + name + "\` is not available through it.");
180 process.exit(1);
181}
182if (!informational && name === "device") {
183 console.error("\`device status\` reads this machine's device claims, not those on the device's host, so it is not available through this command. Quote the DEVICE_IN_USE error instead.");
184 process.exit(1);
185}
186const skip = refused.get(name) ?? [];
187if (!informational) for (let index = 0; index + 1 < bound.length; index += 2) if (!skip.includes(bound[index]) && !options.some(arg => arg === bound[index] || arg.startsWith(bound[index] + "="))) options.push(bound[index], bound[index + 1]);
188const command = end < 0 ? options : [...options, ...args.slice(end)];
189const hasValue = flag => { const index = options.indexOf(flag); return options.some(arg => arg.startsWith(flag + "=") && arg.length > flag.length + 1) || (index >= 0 && !!options[index + 1] && !options[index + 1].startsWith("--")); };
190if (!informational && !skip.includes("--session") && !(hasValue("--config") && hasValue("--session"))) {
191 console.error("Call device_open first and run the agent-device command it returns.");
192 process.exit(1);
193}
194if (!existsSync(entry)) {
195 console.error(${JSON.stringify(STALE_RUNTIME)});
196 process.exit(1);
197}
198const valueOf = flag => { const inline = options.find(arg => arg.startsWith(flag + "=")); if (inline) return inline.slice(flag.length + 1); const index = options.indexOf(flag); return index >= 0 ? options[index + 1] : undefined; };
199const endpoint = () => { try { const url = new URL(valueOf("--daemon-base-url") ?? JSON.parse(readFileSync(valueOf("--config"), "utf8")).daemonBaseUrl); return ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) && url.port ? url : undefined; } catch { return undefined; } };
200const local = informational || refused.has(name) ? undefined : endpoint();
201if (local) {
202 const failure = await new Promise(resolve => {
203 const socket = connect({ host: local.hostname.replace(/^\\[|\\]$/g, ""), port: Number(local.port) });
204 socket.setTimeout(3000, () => { socket.destroy(); resolve("ETIMEDOUT"); });
205 socket.once("connect", () => { socket.destroy(); resolve(undefined); });
206 socket.once("error", error => resolve(error.code ?? "ERROR"));
207 });
208 if (failure === "EPERM" || failure === "EACCES") {
209 console.error("This shell cannot reach T3 Code's device connection at " + local.host + " (connect " + failure + "): a network sandbox is blocking localhost. Run this command again with network access, outside the sandbox.");
210 process.exit(1);
211 }
212 if (failure) {
213 console.error("T3 Code's device connection at " + local.host + " is not reachable from this shell (connect " + failure + "). Call device_open again to reconnect; if that succeeds and this repeats, this shell runs in a network sandbox, so run the command with network access, outside the sandbox.");
214 process.exit(1);
215 }
216}
217const env = { ...process.env, ELECTRON_RUN_AS_NODE: "1" };
218delete env.AGENT_DEVICE_DAEMON_BASE_URL;
219delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
220delete env.AGENT_DEVICE_CONFIG;
221const child = spawn(${JSON.stringify(node)}, [entry, ...command], { stdio: "inherit", env });
222child.on("error", error => { console.error(error.message); process.exitCode = 1; });
223child.on("exit", code => { process.exitCode = code ?? 1; });
224`;
225
226/** Writes a launcher into `dir` and returns the executable agents run. */
227const writeLauncher = Effect.fn("AgentDeviceShim.writeLauncher")(function* (input: {
228 readonly dir: string;
229 readonly entryPath: string;
230 readonly boundArgs: ReadonlyArray<string>;
231}) {
232 const fs = yield* FileSystem.FileSystem;
233 const path = yield* Path.Path;
234 const platform = yield* HostProcess.Platform;
235 const node = yield* resolveNodeExecutable("Device automation");
236 yield* fs.makeDirectory(input.dir, { recursive: true });
237 const launcherPath = path.join(input.dir, "agent-device-launcher.mjs");
238 yield* fs.writeFileString(launcherPath, launcherSource(node, input.entryPath, input.boundArgs));
239 if (platform === "win32") {
240 const script = `@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\nif not exist "${node}" (echo ${STALE_RUNTIME} 1>&2 & exit /b 1)\r\nset "ELECTRON_RUN_AS_NODE=1"\r\n"${node}" "${launcherPath}" %*\r\n`;
241 const scriptPath = path.join(input.dir, "agent-device.cmd");
242 yield* fs.writeFileString(scriptPath, script);
243 return scriptPath;
244 }
245 const script = `#!/bin/sh\n[ -x ${quoteShell(node)} ] || { echo ${quoteShell(STALE_RUNTIME)} >&2; exit 1; }\nexport ELECTRON_RUN_AS_NODE=1\nexec ${quoteShell(node)} ${quoteShell(launcherPath)} "$@"\n`;
246 const scriptPath = path.join(input.dir, "agent-device");
247 yield* fs.writeFileString(scriptPath, script);
248 yield* fs.chmod(scriptPath, 0o755);
249 return scriptPath;
250});
251
252/** The unbound launcher: every call needs the device's target flags. */
253export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(function* (input: {
254 readonly entryPath: string;
255 readonly stateDir: string;
256}) {
257 const path = yield* Path.Path;
258 const shimDir = path.join(input.stateDir, SHIM_DIR);
259 yield* writeLauncher({ dir: shimDir, entryPath: input.entryPath, boundArgs: [] });
260 return shimDir;
261});
262
263/**
264 * A launcher for one agent-device session with its target flags built in, so
265 * agents pass none, and a shell that does not word-split a variable cannot mangle them.
266 */
267export const bindAgentDeviceLauncher = Effect.fn("AgentDeviceShim.bind")(function* (input: {
268 readonly entryPath: string;
269 readonly stateDir: string;
270 readonly session: string;
271 readonly targetArgs: ReadonlyArray<string>;
272}) {
273 const path = yield* Path.Path;
274 return yield* writeLauncher({
275 dir: path.join(input.stateDir, TARGETS_DIR, input.session),
276 entryPath: input.entryPath,
277 boundArgs: input.targetArgs,
278 });
279});
280
281/** Removes the bound launchers of agent-device sessions not in `live`. */
282export const pruneAgentDeviceLaunchers = Effect.fn("AgentDeviceShim.prune")(function* (input: {
283 readonly stateDir: string;
284 readonly live: ReadonlySet<string>;
285}) {
286 const fs = yield* FileSystem.FileSystem;
287 const path = yield* Path.Path;
288 const dir = path.join(input.stateDir, TARGETS_DIR);
289 const sessions = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => []));
290 yield* Effect.forEach(
291 sessions.filter((session) => !input.live.has(session)),
292 (session) => fs.remove(path.join(dir, session), { recursive: true, force: true }),
293 { discard: true },
294 ).pipe(Effect.ignore);
295});
296
297export type AgentDeviceCloseOutcome = "closed" | "no-session" | "failed" | "timed-out";
298
299/** What a launcher's `close` did; only a failure or timeout can leave the device claimed. */
300export const agentDeviceCloseOutcome = (result: {
301 readonly code: number | null;
302 readonly stderr: string;
303 readonly timedOut: boolean;
304}): AgentDeviceCloseOutcome =>
305 result.timedOut
306 ? "timed-out"
307 : result.code === 0
308 ? "closed"
309 : result.stderr.includes("SESSION_NOT_FOUND")
310 ? "no-session"
311 : "failed";