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