simulator-use 0.10.1 → 0.11.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/dist/hosted.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import { execFile, spawn } from "node:child_process";
7
7
  import { createHash } from "node:crypto";
8
8
  import { createReadStream, createWriteStream, existsSync, statSync } from "node:fs";
9
- import { chmod, lstat, lutimes, mkdir, mkdtemp, readdir, readFile, readlink, rename, rm, unlink, writeFile } from "node:fs/promises";
9
+ import { chmod, lstat, lutimes, mkdir, mkdtemp, open, readdir, readFile, readlink, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
10
10
  import { tmpdir } from "node:os";
11
11
  import { basename, dirname, join, sep } from "node:path";
12
12
  import { Readable } from "node:stream";
@@ -16,8 +16,10 @@ import { closeEngines, engineCall } from "./engine/native.js";
16
16
  import { cancelAllJobs } from "./engine/jobs.js";
17
17
  import { listDevices, pickDevice } from "./engine/simctl.js";
18
18
  import { heldDevice, stateDir } from "./engine/state.js";
19
+ import { describeTarget, parseStep } from "./act/steps.js";
19
20
  import { normalizeRemote, SKIP_DIRS } from "./cloud/sync.js";
20
21
  import { callTool } from "./registry.js";
22
+ import { fetchState, uploadState } from "./state-archive.js";
21
23
  const PROJECT = /^prj_[a-f0-9]{32}$/;
22
24
  const SYNC = /^sync_[a-f0-9]{40}$/;
23
25
  const SHA = /^[a-f0-9]{64}$/;
@@ -31,6 +33,22 @@ const syncs = new Map();
31
33
  const invalid = (message) => Object.assign(new Error(message), { code: -32602 });
32
34
  const absolute = (value) => typeof value === "string" && value.startsWith("/") && !value.includes("\0") && !value.split("/").includes("..") && value.length <= 1024;
33
35
  const safePath = (path) => typeof path === "string" && path.length > 0 && !path.includes("\0") && !path.includes("\\") && path.split("/").every((part) => part !== "" && part !== "." && part !== "..");
36
+ /**
37
+ * Whether every folder on the way to rel inside root is a real folder (or not there yet): a sync or an upload never
38
+ * reads or writes through a link that code in the workspace left where a folder goes.
39
+ */
40
+ export async function realFolders(root, rel) {
41
+ let at = root;
42
+ for (const part of rel.split("/").slice(0, -1)) {
43
+ at = join(at, part);
44
+ const st = await lstat(at).catch(() => undefined);
45
+ if (!st)
46
+ return true;
47
+ if (!st.isDirectory())
48
+ return false;
49
+ }
50
+ return true;
51
+ }
34
52
  /** The host tells a warm server, at hand-over, the account's build cache and workspace. */
35
53
  export function configure(params) {
36
54
  for (const key of ["cache_dir", "workspace"])
@@ -85,7 +103,7 @@ async function manifest(params) {
85
103
  for (const e of entries) {
86
104
  const known = index[e.path];
87
105
  let present = false;
88
- if (known && known.sha256 === e.sha256 && known.kind === (e.kind ?? "file")) {
106
+ if (known && known.sha256 === e.sha256 && known.kind === (e.kind ?? "file") && (await realFolders(root, e.path))) {
89
107
  present = await lstat(join(root, e.path)).then((st) => (known.kind === "symlink" ? st.isSymbolicLink() : st.isFile() && st.size === e.size), () => false);
90
108
  }
91
109
  if (!present) {
@@ -142,9 +160,12 @@ async function asAnnounced(root, entries, missing) {
142
160
  if (!e)
143
161
  continue;
144
162
  const at = join(root, path);
145
- const st = await lstat(at).catch(() => undefined);
163
+ const inside = await realFolders(root, path);
164
+ const st = inside ? await lstat(at).catch(() => undefined) : undefined;
146
165
  let why;
147
- if (!st)
166
+ if (!inside)
167
+ why = "a folder on its way is a link";
168
+ else if (!st)
148
169
  why = "not in the archive";
149
170
  else if ((e.kind ?? "file") === "symlink") {
150
171
  if (!st.isSymbolicLink())
@@ -181,10 +202,7 @@ async function write(sync, archive) {
181
202
  await writeFile(file, Buffer.from(archive.base64, "base64"));
182
203
  }
183
204
  else if (typeof archive.url === "string" && archive.url.startsWith("https://")) {
184
- const response = await fetch(archive.url);
185
- if (!response.ok || !response.body)
186
- throw new Error(`archive_download_failed: ${response.status}`);
187
- await pipeline(Readable.fromWeb(response.body), createWriteStream(file));
205
+ await download(archive.url, file, "archive");
188
206
  }
189
207
  else {
190
208
  throw new Error("archive_invalid");
@@ -196,6 +214,9 @@ async function write(sync, archive) {
196
214
  throw new Error("archive_checksum_mismatch");
197
215
  }
198
216
  await onlyAskedFor(file, sync.missing);
217
+ // The folders the manifest's files go in are real folders, as on the client: a link left in one's place goes.
218
+ for (const path of sync.missing)
219
+ await realFoldersIn(root, path);
199
220
  // bsdtar keeps every entry inside root: it strips a leading / and refuses .. without -P.
200
221
  await run("/usr/bin/tar", ["-xzf", file, "-C", root], { timeoutMs: 10 * 60_000 });
201
222
  }
@@ -210,11 +231,14 @@ async function write(sync, archive) {
210
231
  for (const e of entries) {
211
232
  const path = join(root, e.path);
212
233
  const kind = e.kind ?? "file";
234
+ if (!(await realFolders(root, e.path)))
235
+ continue;
213
236
  const st = await lstat(path).catch(() => undefined);
214
237
  if (!st)
215
238
  continue;
216
239
  const mode = kind === "executable" ? 0o755 : 0o644;
217
- if (kind !== "symlink" && (st.mode & 0o777) !== mode)
240
+ // chmod follows a link: only a file itself gets its mode.
241
+ if (kind !== "symlink" && st.isFile() && (st.mode & 0o777) !== mode)
218
242
  await chmod(path, mode).catch(() => { });
219
243
  if (Math.floor(st.mtimeMs) !== e.mtime_ms) {
220
244
  const when = new Date(e.mtime_ms);
@@ -225,7 +249,7 @@ async function write(sync, archive) {
225
249
  const now = new Set(entries.map((e) => e.path));
226
250
  let removed = 0;
227
251
  for (const path of Object.keys(before)) {
228
- if (now.has(path) || !safePath(path))
252
+ if (now.has(path) || !safePath(path) || !(await realFolders(root, path)))
229
253
  continue;
230
254
  await rm(join(root, path), { force: true });
231
255
  removed++;
@@ -306,7 +330,7 @@ async function get(params) {
306
330
  let got = gets.get(id);
307
331
  if (!got) {
308
332
  const path = files.get(file);
309
- if (!path || !existsSync(path))
333
+ if (!path || !(await insideRoots(path)))
310
334
  throw Object.assign(new Error("file_not_found"), { code: -32000 });
311
335
  const started = { state: "running" };
312
336
  gets.set(id, started);
@@ -320,6 +344,17 @@ async function get(params) {
320
344
  await new Promise((r) => setTimeout(r, 50));
321
345
  return { ...got };
322
346
  }
347
+ /** A file itself (never a link), whose real path is inside the folders an answer may name. */
348
+ async function insideRoots(path) {
349
+ const st = await lstat(path).catch(() => undefined);
350
+ if (!st?.isFile())
351
+ return false;
352
+ const real = await realpath(path).catch(() => undefined);
353
+ if (!real)
354
+ return false;
355
+ const roots = await Promise.all(fileRoots().map((root) => realpath(root).catch(() => undefined)));
356
+ return roots.some((root) => root !== undefined && real.startsWith(`${root}${sep}`));
357
+ }
323
358
  /** Streams the file to the link, never whole in memory: as many bytes as it had when the upload began. */
324
359
  async function upload(path, url) {
325
360
  const { size } = statSync(path);
@@ -382,14 +417,16 @@ export function gitUrl(url) {
382
417
  async function fetchRepository(url, ref, token) {
383
418
  const root = join(srcRoot(), `git_${createHash("sha256").update(normalizeRemote(url)).digest("hex").slice(0, 32)}`);
384
419
  const git = (args, timeoutMs = 60_000) => gitRun(args, gitEnv(url, token), timeoutMs, token);
385
- if (!existsSync(join(root, ".git"))) {
420
+ // A folder's git metadata is a folder of its own; anything else there (a file or a link pointing elsewhere) is not
421
+ // trusted, and the repository is fetched again from nothing.
422
+ const meta = await lstat(join(root, ".git")).catch(() => undefined);
423
+ if (meta && !meta.isDirectory())
424
+ await rm(root, { recursive: true, force: true });
425
+ if (!meta || !meta.isDirectory()) {
386
426
  await mkdir(root, { recursive: true });
387
427
  await git(["init", "-q", root]);
388
- await git(["-C", root, "remote", "add", "origin", url]);
389
- }
390
- else {
391
- await git(["-C", root, "remote", "set-url", "origin", url]);
392
428
  }
429
+ await trustNothingIn(root, url);
393
430
  await git(["-C", root, "fetch", "--depth", "1", "--no-tags", "origin", ref ?? "HEAD"], 10 * 60_000);
394
431
  await git(["-C", root, "checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"]);
395
432
  if (existsSync(join(root, ".gitmodules")))
@@ -397,14 +434,63 @@ async function fetchRepository(url, ref, token) {
397
434
  const commit = (await git(["-C", root, "rev-parse", "--short=12", "HEAD"])).trim();
398
435
  return { root, commit };
399
436
  }
400
- /** git never asks, and a token goes as an HTTP header set from the environment (GIT_CONFIG_*), for this host only. */
437
+ /**
438
+ * The clone folder is reused, so code that ran in this workspace may have changed its git configuration: before each
439
+ * fetch the repository's config, and each submodule's, is written again from nothing but the values git needs (the
440
+ * remote's link, a submodule's worktree), so no setting there runs a program or sends the token anywhere. The link
441
+ * goes in the file, never in an argument. Hooks and the file monitor are off from the environment (gitEnv).
442
+ */
443
+ export async function trustNothingIn(root, url) {
444
+ const gitDir = join(root, ".git");
445
+ await writeConfig(gitDir, { url });
446
+ // Submodules' own repositories, at any depth: .git/modules/<name>[/modules/<name>...].
447
+ const walk = async (dir) => {
448
+ for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
449
+ if (!entry.isDirectory())
450
+ continue;
451
+ const sub = join(dir, entry.name);
452
+ if (existsSync(join(sub, "HEAD"))) {
453
+ const read = async (key) => (await gitRun(["config", "--file", join(sub, "config"), "--get", key], gitEnv(url, undefined), 10_000, undefined).catch(() => "")).trim();
454
+ const worktree = await read("core.worktree");
455
+ const remote = await read("remote.origin.url");
456
+ await writeConfig(sub, { worktree: worktree && !/[\n\r"\\]/.test(worktree) ? worktree : undefined, url: remote && gitUrl(remote) ? remote : undefined });
457
+ await walk(join(sub, "modules"));
458
+ }
459
+ else {
460
+ await walk(sub);
461
+ }
462
+ }
463
+ };
464
+ await walk(join(gitDir, "modules"));
465
+ }
466
+ async function writeConfig(gitDir, values) {
467
+ const lines = ["[core]", "\trepositoryformatversion = 0", "\tfilemode = true", "\tbare = false"];
468
+ if (values.worktree)
469
+ lines.push(`\tworktree = "${values.worktree}"`);
470
+ if (values.url)
471
+ lines.push('[remote "origin"]', `\turl = ${values.url}`, "\tfetch = +refs/heads/*:refs/remotes/origin/*");
472
+ const tmp = join(gitDir, `config.nomac-${process.pid}`);
473
+ await writeFile(tmp, `${lines.join("\n")}\n`, { mode: 0o600 });
474
+ await rename(tmp, join(gitDir, "config"));
475
+ }
476
+ /**
477
+ * git never asks, reads no configuration of the user or the system, runs no hook or file monitor, and fetches only
478
+ * over https; a token goes as an HTTP header set from the environment (GIT_CONFIG_*), for this host only.
479
+ */
401
480
  export function gitEnv(url, token) {
402
- const env = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "/usr/bin/false" };
481
+ const config = [
482
+ ["core.hooksPath", "/dev/null"],
483
+ ["core.fsmonitor", "false"],
484
+ ["protocol.allow", "never"],
485
+ ["protocol.https.allow", "always"],
486
+ ];
403
487
  if (token) {
404
488
  const { hostname } = new URL(url);
405
489
  const user = hostname === "gitlab.com" ? "oauth2" : hostname === "bitbucket.org" ? "x-token-auth" : "x-access-token";
406
- Object.assign(env, { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: `http.https://${hostname}/.extraHeader`, GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${user}:${token}`).toString("base64")}` });
490
+ config.push([`http.https://${hostname}/.extraHeader`, `Authorization: Basic ${Buffer.from(`${user}:${token}`).toString("base64")}`]);
407
491
  }
492
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "/usr/bin/false", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_COUNT: String(config.length) };
493
+ config.forEach(([key, value], i) => Object.assign(env, { [`GIT_CONFIG_KEY_${i}`]: key, [`GIT_CONFIG_VALUE_${i}`]: value }));
408
494
  return env;
409
495
  }
410
496
  function gitRun(args, env, timeoutMs, token) {
@@ -462,17 +548,11 @@ async function unpackUpload(url, sha256, name) {
462
548
  const work = await mkdtemp(join(srcRoot(), ".nomac-upload-"));
463
549
  try {
464
550
  const file = join(work, "archive");
465
- const response = await fetch(url);
466
- if (!response.ok || !response.body)
467
- throw new Error(`upload_download_failed: ${response.status}`);
468
- await pipeline(Readable.fromWeb(response.body), createWriteStream(file));
469
- const hash = createHash("sha256");
470
- await pipeline(createReadStream(file), hash);
471
- if (hash.digest("hex") !== sha256)
551
+ if ((await download(url, file, "upload")) !== sha256)
472
552
  throw new Error("upload_checksum_mismatch: the file is not the one its sha256 names");
473
553
  const out = join(work, "out");
474
554
  await mkdir(out);
475
- const head = (await readFile(file)).subarray(0, 4);
555
+ const head = await firstBytes(file, 4);
476
556
  if (head[0] === 0x50 && head[1] === 0x4b)
477
557
  await run("/usr/bin/ditto", ["-x", "-k", file, out], { timeoutMs: 10 * 60_000 });
478
558
  // bsdtar keeps every entry inside out: it strips a leading / and refuses .. without -P.
@@ -489,6 +569,62 @@ async function unpackUpload(url, sha256, name) {
489
569
  await rm(work, { recursive: true, force: true });
490
570
  }
491
571
  }
572
+ /** An upload or a sync's archive is at most this large, and its download takes at most this long. */
573
+ let DOWNLOAD_MAX_BYTES = 2 * 1024 ** 3;
574
+ const DOWNLOAD_MS = 30 * 60_000;
575
+ export const setDownloadLimit = (bytes) => (DOWNLOAD_MAX_BYTES = bytes);
576
+ /**
577
+ * Downloads a link NoMac signed to file, streamed (never whole in memory), within DOWNLOAD_MS and DOWNLOAD_MAX_BYTES;
578
+ * answers its sha256.
579
+ */
580
+ async function download(url, file, what) {
581
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_MS) }).catch((error) => {
582
+ throw new Error(`${what}_download_failed: ${error.name === "TimeoutError" ? "timed out" : error.message}`);
583
+ });
584
+ if (!response.ok || !response.body)
585
+ throw new Error(`${what}_download_failed: ${response.status}`);
586
+ if (Number(response.headers.get("content-length") ?? 0) > DOWNLOAD_MAX_BYTES)
587
+ throw new Error(`${what}_too_large: at most ${Math.round(DOWNLOAD_MAX_BYTES / 1024 ** 2)} MiB`);
588
+ const hash = createHash("sha256");
589
+ let bytes = 0;
590
+ await pipeline(Readable.fromWeb(response.body), async function* (chunks) {
591
+ for await (const chunk of chunks) {
592
+ bytes += chunk.length;
593
+ if (bytes > DOWNLOAD_MAX_BYTES)
594
+ throw new Error(`${what}_too_large: at most ${Math.round(DOWNLOAD_MAX_BYTES / 1024 ** 2)} MiB`);
595
+ hash.update(chunk);
596
+ yield chunk;
597
+ }
598
+ }, createWriteStream(file)).catch((error) => {
599
+ throw error.message.startsWith(what) ? error : new Error(`${what}_download_failed: ${error.name === "TimeoutError" ? "timed out" : error.message}`);
600
+ });
601
+ return hash.digest("hex");
602
+ }
603
+ /** A file's first bytes, read alone. */
604
+ async function firstBytes(file, n) {
605
+ const handle = await open(file, "r");
606
+ try {
607
+ const buffer = Buffer.alloc(n);
608
+ const { bytesRead } = await handle.read(buffer, 0, n, 0);
609
+ return buffer.subarray(0, bytesRead);
610
+ }
611
+ finally {
612
+ await handle.close();
613
+ }
614
+ }
615
+ /** Each folder on the way to rel inside root becomes a real folder: a link or a file where one goes is removed first. */
616
+ async function realFoldersIn(root, rel) {
617
+ let at = root;
618
+ for (const part of rel.split("/").slice(0, -1)) {
619
+ at = join(at, part);
620
+ const st = await lstat(at).catch(() => undefined);
621
+ if (st?.isDirectory())
622
+ continue;
623
+ if (st)
624
+ await unlink(at);
625
+ await mkdir(at);
626
+ }
627
+ }
492
628
  /**
493
629
  * Makes to hold what from holds: a file whose content differs is moved in, the rest keep theirs and their mtimes;
494
630
  * what to has beyond from goes, but build output and dependencies stay (they were made here).
@@ -506,6 +642,7 @@ async function mirror(from, to) {
506
642
  }
507
643
  files.add(rel);
508
644
  const target = join(to, rel);
645
+ await realFoldersIn(to, rel);
509
646
  if (await sameContent(path, target, entry.isSymbolicLink()))
510
647
  continue;
511
648
  await mkdir(dirname(target), { recursive: true });
@@ -540,7 +677,11 @@ async function sameContent(a, b, link) {
540
677
  return sa.isSymbolicLink() && sb.isSymbolicLink() && (await readlink(a)) === (await readlink(b));
541
678
  if (sa.size !== sb.size || (sa.mode & 0o111) !== (sb.mode & 0o111))
542
679
  return false;
543
- const [ha, hb] = await Promise.all([a, b].map(async (p) => createHash("sha256").update(await readFile(p)).digest("hex")));
680
+ const [ha, hb] = await Promise.all([a, b].map(async (p) => {
681
+ const hash = createHash("sha256");
682
+ await pipeline(createReadStream(p), hash);
683
+ return hash.digest("hex");
684
+ }));
544
685
  return ha === hb;
545
686
  }
546
687
  catch {
@@ -562,26 +703,69 @@ async function appIn(root) {
562
703
  // The replay's own recorder, the file it records and the calls so far.
563
704
  let replay;
564
705
  const REPLAY_CALLS = 2000;
706
+ /** A ui_act step as the replay lists it, from the parsed step: what it acted on, never what it typed or a line it could not read. */
707
+ function stepSummary(line) {
708
+ const step = parseStep(String(line));
709
+ if (typeof step === "string")
710
+ return "(a step that did not parse)";
711
+ switch (step.op) {
712
+ case "fill":
713
+ return `fill ${describeTarget(step.target)} …`;
714
+ case "type":
715
+ return "type …";
716
+ case "scroll":
717
+ case "swipe":
718
+ return [step.op, step.direction, ...(step.target ? [describeTarget(step.target)] : [])].join(" ");
719
+ case "key":
720
+ return `key ${step.key}`;
721
+ case "back":
722
+ case "home":
723
+ return step.op;
724
+ default:
725
+ return `${step.op} ${describeTarget(step.target)}`;
726
+ }
727
+ }
565
728
  /**
566
- * What a call did, for the replay: the tool and its main argument, with typed text masked (fill #password …).
729
+ * What a call did, for the replay: the tool and its main argument, built from what was asked (a ui_act step's action and
730
+ * target), so typed text and links never reach the list.
567
731
  */
568
732
  export function callSummary(tool, args) {
569
733
  if (tool === "ui_act" && Array.isArray(args.steps))
570
- return args.steps.map((step) => String(step).replace(/^((?:fill|type)\b(?:\s+(?:#\S+|"[^"]*"|\S+))?)\s.*$/i, (_m, head) => (/^type/i.test(head) ? "type …" : `${head} …`))).join(" · ").slice(0, 200);
734
+ return args.steps.map(stepSummary).join(" · ").slice(0, 200);
571
735
  if (tool === "app_control")
572
736
  return [args.action, args.action === "open_url" ? "(a link)" : args.app].filter(Boolean).join(" ").slice(0, 120);
573
737
  if (tool === "app_run" || tool === "tests_run")
574
738
  return tool;
575
739
  return Object.keys(args).length ? `${tool} ${Object.keys(args).join(", ")}` : tool;
576
740
  }
741
+ /**
742
+ * What a call answered, for the replay: for ui_act, how many steps were done or which one failed, never its answer's
743
+ * words (they can repeat what a field shows); for a link, that it opened; for logs, that they were read (an app's logs
744
+ * can hold anything). Other tools' first line.
745
+ */
746
+ export function callAnswer(tool, args, text, failed) {
747
+ const first = hostedText(text.split(/\r?\n|\r|\u2028|\u2029/)[0] ?? "");
748
+ if (tool === "ui_act") {
749
+ const done = /^(\d+) steps? done in ([\d.]+) s$/.exec(first);
750
+ if (done && !failed)
751
+ return `${done[1]} step${done[1] === "1" ? "" : "s"} done in ${done[2]} s`;
752
+ const step = /^Step (\d+)\b/.exec(first) ?? /^Stopped before step (\d+)\b/.exec(first);
753
+ if (step)
754
+ return first.startsWith("Stopped") ? `Stopped before step ${step[1]}: out of time` : `Step ${step[1]} failed`;
755
+ return failed ? "failed" : "done";
756
+ }
757
+ if (tool === "app_control" && args.action === "open_url")
758
+ return failed ? "Could not open the link." : "Opened the link.";
759
+ if (tool === "app_logs")
760
+ return failed ? "Could not read the app's logs." : "Read the app's logs.";
761
+ return first;
762
+ }
577
763
  /** Notes a call for the replay, while one records. */
578
764
  export function noteCall(tool, args, started, result) {
579
765
  if (!replay || replay.log.length >= REPLAY_CALLS)
580
766
  return;
581
767
  const text = result.content.map((c) => (c && typeof c === "object" && c.type === "text" ? String(c.text ?? "") : "")).join("\n");
582
- // ui_act's answer can repeat what was typed ("fill #password \"…\" failed"): its quoted parts never reach the list.
583
- const first = hostedText(text.split("\n")[0] ?? "");
584
- const said = tool === "ui_act" ? first.replace(/"[^"]*"|“[^”]*”/g, "…") : first;
768
+ const said = callAnswer(tool, args, text, Boolean(result.isError));
585
769
  replay.log.push({ at_ms: started - replay.started, tool, what: callSummary(tool, args), ms: Date.now() - started, ok: !result.isError, said: said.slice(0, 200) });
586
770
  }
587
771
  // simctl's recorder, as screen_record's but its own process: the agent's recordings go on beside it, and the video
@@ -754,11 +938,12 @@ const listStates = async () => {
754
938
  }
755
939
  return states.sort((a, b) => b.saved_at.localeCompare(a.saved_at));
756
940
  };
757
- async function saveState(name) {
941
+ async function saveState(name, upload) {
758
942
  const device = pickDevice(await listDevices(true), undefined, await heldDevice());
759
943
  const data = await dataPathOf(device.udid);
760
944
  const dir = join(statesDir(), name), saving = `${dir}.saving`;
761
945
  await shutDown(device.udid);
946
+ let uploading, result;
762
947
  try {
763
948
  await rm(saving, { recursive: true, force: true });
764
949
  await mkdir(saving, { recursive: true });
@@ -768,22 +953,36 @@ async function saveState(name) {
768
953
  await writeFile(join(saving, "state.json"), JSON.stringify(meta));
769
954
  await rm(dir, { recursive: true, force: true });
770
955
  await rename(saving, dir);
771
- return { state: "done", bytes, device: `${device.name} (${device.runtime})` };
956
+ // Kept in NoMac's storage too, so it restores on any phone: it uploads while the phone boots again.
957
+ uploading = upload ? uploadState(statesDir(), name, upload.url, upload.key, tmpdir()) : undefined;
958
+ uploading?.catch(() => { });
959
+ result = { state: "done", bytes, device: `${device.name} (${device.runtime})`, saved_at: meta.saved_at };
772
960
  }
773
961
  finally {
774
962
  await rm(saving, { recursive: true, force: true });
775
963
  await bootAgain(device.udid);
776
964
  }
965
+ if (uploading) {
966
+ await uploading.catch((error) => { throw new Error(`saved on this phone, but not kept for other phones: ${error.message}`); });
967
+ result.uploaded = true;
968
+ }
969
+ return result;
777
970
  }
778
- async function restoreState(name) {
971
+ async function restoreState(name, fetch) {
779
972
  const dir = join(statesDir(), name);
780
- let meta;
781
- try {
782
- meta = JSON.parse(await readFile(join(dir, "state.json"), "utf8"));
973
+ const local = async () => JSON.parse(await readFile(join(dir, "state.json"), "utf8"));
974
+ let meta = await local().catch(() => undefined);
975
+ // A copy here is the state only if it is the same save; otherwise the account's copy in NoMac's storage comes down.
976
+ let fetched = false;
977
+ if (fetch && meta?.saved_at !== fetch.savedAt) {
978
+ await fetchState(statesDir(), name, fetch.url, fetch.key);
979
+ meta = await local();
980
+ if (meta.saved_at !== fetch.savedAt)
981
+ throw new Error(`the stored ${name} is not the save asked for`);
982
+ fetched = true;
783
983
  }
784
- catch {
984
+ if (!meta)
785
985
  throw new Error(`no saved state named ${name}`);
786
- }
787
986
  const device = pickDevice(await listDevices(true), undefined, await heldDevice());
788
987
  // A state goes back onto the phone it came from: the same model and iOS.
789
988
  if (meta.device !== device.name || meta.runtime !== device.runtime)
@@ -794,7 +993,7 @@ async function restoreState(name) {
794
993
  await removeExcept(data, SYSTEM_ASSETS);
795
994
  await run("/usr/bin/ditto", [join(dir, "data"), data], { timeoutMs: 10 * 60_000 });
796
995
  await forgetAppPaths(data);
797
- return { state: "done", bytes: meta.bytes, device: `${device.name} (${device.runtime})` };
996
+ return { state: "done", bytes: meta.bytes, device: `${device.name} (${device.runtime})`, ...(fetch ? { fetched } : {}) };
798
997
  }
799
998
  finally {
800
999
  await bootAgain(device.udid);
@@ -807,7 +1006,14 @@ async function restoreState(name) {
807
1006
  * id reads their state.
808
1007
  */
809
1008
  async function phoneState(params) {
810
- const { id, action, name } = params;
1009
+ const { id, action, name, url, data_key: key, saved_at: savedAt } = params;
1010
+ // A save given a link and a key keeps the state in NoMac's storage too; a restore given them fetches it when this
1011
+ // workspace has no copy of that save (saved_at).
1012
+ const transfer = url === undefined && key === undefined ? undefined : typeof url === "string" && url.startsWith("https://") && typeof key === "string" && /^[A-Za-z0-9_-]{43}$/.test(key) ? { url, key } : null;
1013
+ if (transfer === null)
1014
+ throw invalid("a state's url (https) and data_key");
1015
+ if (transfer && action === "restore" && (typeof savedAt !== "string" || savedAt.length > 64))
1016
+ throw invalid("a restore's saved_at");
811
1017
  if (typeof id !== "string" || !STATE_ID.test(id))
812
1018
  throw invalid("state id");
813
1019
  if (action === "list")
@@ -828,7 +1034,7 @@ async function phoneState(params) {
828
1034
  while (stateJobs.size > KEEP_STATE_JOBS)
829
1035
  stateJobs.delete(stateJobs.keys().next().value);
830
1036
  const began = Date.now();
831
- (action === "save" ? saveState(name) : restoreState(name)).then(
1037
+ (action === "save" ? saveState(name, transfer) : restoreState(name, transfer ? { ...transfer, savedAt: savedAt } : undefined)).then(
832
1038
  // The whole of it: the copy and the phone booted and readable again.
833
1039
  (result) => Object.assign(started, result, { ms: Date.now() - began }), (error) => Object.assign(started, { state: "failed", error: error.message.slice(0, 300) }));
834
1040
  job = started;
package/dist/registry.js CHANGED
@@ -2,11 +2,12 @@ import { createRequire } from "node:module";
2
2
  import { cloudState } from "./cloud/account.js";
3
3
  import { forward } from "./cloud/forward.js";
4
4
  import { ToolError } from "./errors.js";
5
+ import { simulatorMissing } from "./engine/simctl.js";
5
6
  import { setting } from "./settings.js";
6
7
  import { appControl } from "./tools/app-control.js";
7
8
  import { appLogs } from "./tools/app-logs.js";
8
9
  import { appRun } from "./tools/app-run.js";
9
- import { cloud } from "./tools/cloud.js";
10
+ import { cloud, tunnelNotice } from "./tools/cloud.js";
10
11
  import { deviceUse } from "./tools/device-use.js";
11
12
  import { devices } from "./tools/devices.js";
12
13
  import { jobCancel, jobStatus } from "./tools/jobs.js";
@@ -15,6 +16,8 @@ import { testsRun } from "./tools/tests-run.js";
15
16
  import { uiAct } from "./tools/ui-act.js";
16
17
  import { uiLook } from "./tools/ui-look.js";
17
18
  export const VERSION = createRequire(import.meta.url)("../package.json").version;
19
+ /** Tools that run without a Simulator on this machine: cloud mode itself, and following a job. */
20
+ const NO_SIMULATOR_NEEDED = new Set(["cloud", "job_status", "job_cancel"]);
18
21
  /** Every tool, in the order clients list them. The MCP server and the CLI are both built from this. */
19
22
  export const TOOLS = [devices, deviceUse, appRun, uiLook, uiAct, appControl, appLogs, testsRun, jobStatus, jobCancel, screenRecord, cloud];
20
23
  /** How the tools fit together, sent once to MCP clients. */
@@ -68,8 +71,17 @@ export async function callTool(name, args = {}, context = { waitForJobs: false }
68
71
  return { text: problem, isError: true };
69
72
  try {
70
73
  // With cloud on, every other tool runs on the NoMac phone (src/cloud/forward.ts).
71
- if (tool !== cloud && !setting("HOSTED") && (await cloudState()).on)
72
- return await forward(tool.name, args, context);
74
+ if (tool !== cloud && !setting("HOSTED") && (await cloudState()).on) {
75
+ const out = await forward(tool.name, args, context);
76
+ const notice = tunnelNotice();
77
+ return notice ? { ...out, text: `${out.text}\n${notice}` } : out;
78
+ }
79
+ // Where the Simulator can't run, every tool that needs it says so, and how to get a cloud phone.
80
+ if (!NO_SIMULATOR_NEEDED.has(tool.name) && !setting("HOSTED")) {
81
+ const missing = await simulatorMissing();
82
+ if (missing)
83
+ throw missing;
84
+ }
73
85
  return await tool.run(args, context);
74
86
  }
75
87
  catch (error) {