Skip to content

Commit 829105e

Browse files
gabrielelpidioclaude
authored andcommitted
fix(server): agents drive simulators through the device tools instead of ssh (pingdotgg#25661)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6fe58cb commit 829105e

26 files changed

Lines changed: 2280 additions & 214 deletions

‎apps/server/src/device/AgentDeviceShim.ts‎

Lines changed: 286 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,49 +6,303 @@ import * as FileSystem from "effect/FileSystem";
66
import * as Path from "effect/Path";
77

88
const 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"];
25163
const 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+
}
32214
const env = { ...process.env, ELECTRON_RUN_AS_NODE: "1" };
33215
delete env.AGENT_DEVICE_DAEMON_BASE_URL;
34216
delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
35217
delete 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 });
37219
child.on("error", error => { console.error(error.message); process.exitCode = 1; });
38220
child.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

Comments
 (0)