@nomac/cli 0.9.1 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Cloud Mac sessions over SSH and HTTPS, plus iOS builds and publishing — built for agents.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/skill/SKILL.md CHANGED
@@ -51,14 +51,30 @@ line in .nomacignore sends any of them. It preserves remote extra files. See the
51
51
  `screenshot_mac`, which returns an image you can look at. Record a video for
52
52
  the human with `nomac record`, do the work, then `nomac record stop` (saves the
53
53
  MP4 and prints a share link); MCP `start_mac_recording`, `stop_mac_recording`.
54
- Commands drive any app with AppleScript without a permission prompt; drive the
55
- Simulator with simctl, and give the human `open_mac_desktop` when a screen
56
- needs a person. `get_mac_session` (and `nomac status --json`) lists what
54
+ Commands drive any app with AppleScript without a permission prompt. For real
55
+ keyboard and mouse, also on the lock screen and in password dialogs, use
56
+ `nomac input type TEXT`, `nomac input click X Y`, `nomac unlock` (MCP
57
+ `mac_input`): each answers with a screenshot. Drive the Simulator with simctl,
58
+ and give the human `open_mac_desktop` when they want to see the screen.
59
+ `get_mac_session` (and `nomac status --json`) lists what
57
60
  is installed: Ruby with CocoaPods, xcbeautify, xcodegen, gh, Git LFS, jq.
58
61
  Pipe xcodebuild through `xcbeautify`. Persistent disks are not currently offered.
59
62
  Report failures using `report_issue` with `session_id`, optional `job_id` and
60
63
  a description without secrets. Read https://nomac.app/llms.txt for full limits.
61
64
 
65
+ ## A Mac fully enabled for you
66
+
67
+ The Mac is yours to do anything on: the `agent` account is an administrator,
68
+ sudo needs no password, macOS asks for no administrator password, and the
69
+ Mac's login password is in `$NOMAC_MAC_PASSWORD` (for the login keychain, such
70
+ as `security unlock-keychain -p "$NOMAC_MAC_PASSWORD"`, or any prompt).
71
+ Privacy prompts are answered Allow as they appear, and `nomac-permit APP
72
+ camera microphone` (CLI `nomac permit`, at start `--permit APP=camera`) grants
73
+ an app permissions before it asks. `~/NOMAC.md` on the Mac says the same. When
74
+ `screenshot_mac` reports `screen_locked` or a `dialog`, follow its
75
+ `next_step`. `nomac start --prompts ask` keeps macOS's usual prompts, for
76
+ testing an app's own.
77
+
62
78
  ## Secrets, signing and shipping
63
79
 
64
80
  Never ask for a secret in the chat. `nomac secrets request NAME --description
package/src/cli.mjs CHANGED
@@ -19,8 +19,13 @@ Use it:
19
19
  nomac ssh open a shell (nomac ssh -- command runs one)
20
20
  nomac sync . upload changed project files
21
21
  nomac pull ./build download a folder from the Mac
22
- nomac desktop the Mac's desktop in your browser
22
+ nomac desktop the Mac's desktop in your browser (--app: Apple's Screen Sharing)
23
23
  nomac screenshot save the Mac's screen as nomac-screenshot.jpg
24
+ nomac input type hello real keyboard and mouse: type, key, click; works on the lock screen
25
+ nomac unlock unlock the Mac's lock screen
26
+ nomac password the Mac's login password (also $NOMAC_MAC_PASSWORD on it)
27
+ nomac permit APP camera grant an app privacy permissions, so it never asks
28
+ nomac defaults options every new Mac starts with (nomac defaults --help)
24
29
  nomac record record a video; nomac record stop saves it with a share link
25
30
  nomac status state, cost so far and when it stops
26
31
  nomac secrets variables and files every Mac receives (nomac secrets --help)
@@ -76,6 +81,16 @@ export async function main(argv) {
76
81
  const { transportCommand } = await import("./transport-command.mjs");
77
82
  return transportCommand(rest);
78
83
  }
84
+ // Only a cloud Mac has a session password and NoMac's input.
85
+ case "password":
86
+ case "input":
87
+ case "unlock":
88
+ case "permit":
89
+ case "defaults": {
90
+ const { sessionStore } = await import("./session-store.mjs");
91
+ const { hostedSessionCommand } = await import("./hosted-sessions.mjs");
92
+ return hostedSessionCommand(cmd, rest, { store: sessionStore() });
93
+ }
79
94
  case "status":
80
95
  // A legacy build ID, or a folder that only ever built with the old pipeline, keeps build status.
81
96
  if (/^bld_/.test(rest[0] ?? "") || (loadProjectState().last_build && !(await import("./session-store.mjs")).sessionStore().load())) {
package/src/desktop.mjs CHANGED
@@ -1,14 +1,22 @@
1
+ import { spawn } from "node:child_process";
2
+ import net from "node:net";
3
+ import { api, CliError } from "./api.mjs";
1
4
  import { loadApiUrl } from "./config.mjs";
2
5
  import { openUrl } from "./mac-credits.mjs";
6
+ import { runProcess, sshArgs } from "./session-ssh.mjs";
3
7
 
4
8
  /**
5
9
  * `nomac desktop`: the Mac's full macOS desktop in the browser. The page on
6
10
  * nomac.app connects through the gateway to the VM's own display, so there is
7
11
  * nothing to install and no password to type. macOS has no RDP.
8
12
  */
9
- export const DESKTOP_HELP = `nomac desktop [--no-open] [--json]
13
+ export const DESKTOP_HELP = `nomac desktop [--app] [--no-open] [--json]
10
14
  Open the Mac's full macOS desktop in your browser: mouse and keyboard,
11
15
  nothing to install. Sign in to nomac.app if asked. (macOS has no RDP.)
16
+ --app, on a Mac: open it in Apple's Screen Sharing instead, through the
17
+ session's SSH tunnel, signed in with the Mac's password: shared clipboard,
18
+ and Cmd shortcuts reach the cloud Mac. The tunnel stays open until the
19
+ Screen Sharing window closes or Ctrl-C.
12
20
  The session stays busy while the desktop is open; the page disconnects after
13
21
  15 minutes without input. Credit is used while the Mac is ready: nomac stop when done.`;
14
22
 
@@ -24,3 +32,72 @@ export function desktop(local, { values = {}, print = console.log, open = openUr
24
32
  "Credit is used while the Mac is ready: nomac stop when done.",
25
33
  ].join("\n"));
26
34
  }
35
+
36
+ /** Put text on the clipboard: pbcopy on a Mac, clip on Windows, wl-copy, xclip or xsel on Linux. */
37
+ export async function copyToClipboard(text, run = runProcess, platform = process.platform) {
38
+ const tools = platform === "darwin" ? [["pbcopy", []]] : platform === "win32" ? [["clip", []]]
39
+ : [["wl-copy", []], ["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]];
40
+ for (const [tool, args] of tools) {
41
+ try { await run(tool, args, { input: text }); return tool; } catch { /* try the next one */ }
42
+ }
43
+ throw new CliError("No clipboard tool was found.", "Run nomac password without --copy to print the password.");
44
+ }
45
+
46
+ /** A local port nothing listens on. */
47
+ export function freePort() {
48
+ return new Promise((resolve, reject) => {
49
+ const server = net.createServer();
50
+ server.once("error", reject);
51
+ server.listen(0, "127.0.0.1", () => { const { port } = server.address(); server.close(() => resolve(port)); });
52
+ });
53
+ }
54
+
55
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
56
+ const accepts = (port) => new Promise((resolve) => {
57
+ const socket = net.connect(port, "127.0.0.1");
58
+ socket.once("connect", () => { socket.destroy(); resolve(true); });
59
+ socket.once("error", () => resolve(false));
60
+ });
61
+ /** Established connections to the tunnel's local port (Screen Sharing's), from lsof. */
62
+ async function connections(port, run) {
63
+ try { return (await run("lsof", ["-nP", `-iTCP@127.0.0.1:${port}`, "-sTCP:ESTABLISHED", "-t"])).stdout.split("\n").filter(Boolean).length; }
64
+ catch { return 0; } // lsof exits 1 when it finds none
65
+ }
66
+
67
+ /**
68
+ * `nomac desktop --app`: Apple's Screen Sharing, on a Mac, into the cloud Mac's
69
+ * own Screen Sharing, through the session's SSH tunnel (nothing else can reach
70
+ * it), signed in as agent with the session's password. It returns once the
71
+ * window has been closed, or the tunnel ends.
72
+ */
73
+ export async function screenSharing(local, { request = api, print = console.log, platform = process.platform, spawnSsh = (args) => spawn("ssh", args, { stdio: ["ignore", "ignore", "pipe"] }),
74
+ open = openUrl, run = runProcess, wait = sleep, port: chosen, pollMs = 3000 } = {}) {
75
+ if (platform !== "darwin") throw new CliError("Apple's Screen Sharing is on Macs only.", "Use nomac desktop to open the Mac's desktop in your browser.");
76
+ const login = await request("GET", `/api/v1/mac/sessions/${local.id}/password`);
77
+ const port = chosen ?? await freePort();
78
+ const tunnel = spawnSsh([...sshArgs(local, { batch: false }), "-N", "-o", "ExitOnForwardFailure=yes", "-L", `127.0.0.1:${port}:127.0.0.1:5900`, local.target]);
79
+ let ended = false, problem = "";
80
+ tunnel.stderr?.on("data", (chunk) => { problem += chunk; });
81
+ tunnel.on("exit", () => { ended = true; });
82
+ const close = () => { if (!ended) tunnel.kill("SIGTERM"); };
83
+ process.once("SIGINT", close);
84
+ try {
85
+ let open_ = false;
86
+ for (let i = 0; i < 40 && !ended && !(open_ = await accepts(port)); i++) await wait(500);
87
+ if (!open_) throw new CliError("The tunnel to the Mac's Screen Sharing did not open.", problem.trim() || "Check the session with nomac status, then try again.");
88
+ open(`vnc://${encodeURIComponent(login.user)}:${encodeURIComponent(login.password)}@127.0.0.1:${port}`);
89
+ print("Screen Sharing is opening, signed in to the cloud Mac. The tunnel stays open until you close its window (or press Ctrl-C).");
90
+ // Wait for Screen Sharing to connect, then for it to leave; give up waiting after two minutes unconnected.
91
+ let seen = false, quiet = 0;
92
+ for (let waited = 0; !ended; waited += pollMs) {
93
+ await wait(pollMs);
94
+ const count = await connections(port, run);
95
+ if (count) { seen = true; quiet = 0; } else if (seen && ++quiet >= 2) break;
96
+ if (!seen && waited >= 120_000) { print("Screen Sharing did not connect. Open it again with nomac desktop --app."); break; }
97
+ }
98
+ return { port, connected: seen };
99
+ } finally {
100
+ process.removeListener("SIGINT", close);
101
+ close();
102
+ }
103
+ }
@@ -7,15 +7,33 @@ import { sessionStore } from "./session-store.mjs";
7
7
  import { forwardingArg, runProcess, sshArgs, workspaceCommand } from "./session-ssh.mjs";
8
8
  import { pullProject, syncProject } from "./session-transfer.mjs";
9
9
  import { macRequest, sessionId, transportOptions } from "./mac-transport.mjs";
10
- import { desktop, DESKTOP_HELP } from "./desktop.mjs";
10
+ import { copyToClipboard, desktop, DESKTOP_HELP, screenSharing } from "./desktop.mjs";
11
11
  import { screenshot, startRecording, stopRecording } from "./mac-screen.mjs";
12
12
 
13
13
  const HELP = `Nomac cloud Mac sessions
14
14
  nomac start [--wait 300] [--max-duration 7200] [--idle-timeout 600] [--max-cost 5] [--json]
15
+ [--prompts ask]
16
+ The Mac is fully open to your agent: no administrator
17
+ password prompts, its login password in
18
+ $NOMAC_MAC_PASSWORD. --prompts ask keeps macOS's
19
+ usual prompts.
15
20
  nomac sync . [--dry-run] upload changed files
16
21
  nomac ssh [--forward 8080:3000] full Mac shell and port forwarding
17
22
  nomac ssh -- command args run any command over SSH
18
23
  nomac desktop [--no-open] [--json] the full macOS desktop in your browser
24
+ nomac desktop --app on a Mac: Apple's Screen Sharing, shared clipboard
25
+ nomac password [--copy] the Mac's login password for this session
26
+ nomac input type TEXT | key cmd+q | click X Y [--right] [--double] | move X Y
27
+ | scroll X Y up|down [N] | password | size [-o FILE] [--no-screenshot]
28
+ real keyboard and mouse, also on the lock screen
29
+ and in password dialogs; saves a screenshot after
30
+ nomac unlock unlock the Mac's lock screen
31
+ nomac permit APP PERMISSION... grant camera, microphone, contacts, photos... (or all)
32
+ to an app by bundle ID, so it never asks
33
+ nomac defaults [--prompts ask] [--permit APP=camera,microphone] [--reset]
34
+ what every new Mac starts with (shows them with no options)
35
+ nomac start --permit APP=PERMISSIONS
36
+ grant permissions on this Mac at start
19
37
  nomac exec [--wait 30] -- command args
20
38
  runs it, prints its output, exits with its code.
21
39
  Waits up to 30 s; for builds and tests use --wait 600.
@@ -47,7 +65,68 @@ const options = {
47
65
  overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" },
48
66
  forward: { type: "string", multiple: true }, cursor: { type: "string" }, stream: { type: "string" },
49
67
  "no-open": { type: "boolean" }, simulator: { type: "boolean" }, output: { type: "string", short: "o" }, max: { type: "string" },
68
+ prompts: { type: "string" }, gatekeeper: { type: "string" }, copy: { type: "boolean" }, app: { type: "boolean" },
69
+ right: { type: "boolean" }, double: { type: "boolean" }, "no-screenshot": { type: "boolean" },
70
+ permit: { type: "string", multiple: true }, reset: { type: "boolean" },
50
71
  };
72
+ /** --permit com.example.App=camera,microphone (or =all), as the permissions option. */
73
+ export function permitOption(values) {
74
+ if (!values?.length) return undefined;
75
+ const permissions = {};
76
+ for (const value of values) {
77
+ const match = /^([A-Za-z0-9][A-Za-z0-9.-]{0,154})=([a-z,-]+)$/.exec(value);
78
+ if (!match) throw new CliError(`--permit takes APP=PERMISSION,PERMISSION, like com.example.App=camera,microphone (or =all). Got: ${value}`);
79
+ permissions[match[1]] = match[2] === "all" ? "all" : match[2].split(",").filter(Boolean);
80
+ }
81
+ return permissions;
82
+ }
83
+ const oneOf = (value, allowed, flag) => {
84
+ if (!allowed.includes(value)) throw new CliError(`${flag} takes ${allowed.join(" or ")}.`);
85
+ return value;
86
+ };
87
+ /** The access the person chose at start, or none: the server's defaults then apply (fully open). */
88
+ function accessOption(values) {
89
+ const permissions = permitOption(values.permit);
90
+ if (values.prompts === undefined && values.gatekeeper === undefined && !permissions) return undefined;
91
+ return {
92
+ ...(values.prompts !== undefined ? { prompts: oneOf(values.prompts, ["auto", "ask"], "--prompts") } : {}),
93
+ ...(values.gatekeeper !== undefined ? { gatekeeper: oneOf(values.gatekeeper, ["off", "on"], "--gatekeeper") } : {}),
94
+ ...(permissions ? { permissions } : {}),
95
+ };
96
+ }
97
+ const point = (value, name) => {
98
+ if (!/^\d{1,5}$/.test(value ?? "")) throw new CliError(`Give ${name} as a whole number of screen points, like 720.`);
99
+ return Number(value);
100
+ };
101
+ /** nomac input's arguments as the input route's request. */
102
+ export function inputRequest(positionals, values) {
103
+ const [action, ...rest] = positionals;
104
+ const usage = "Usage: nomac input type TEXT | key NAME... | click X Y [--right] [--double] | move X Y | scroll X Y up|down [N] | password | size";
105
+ switch (action) {
106
+ case "type": if (!rest.length) throw new CliError(usage); return { action, text: rest.join(" ") };
107
+ case "key": if (!rest.length) throw new CliError(usage); return { action, keys: rest };
108
+ case "click": return { action, x: point(rest[0], "X"), y: point(rest[1], "Y"), ...(values.right ? { button: "right" } : {}), ...(values.double ? { double: true } : {}) };
109
+ case "move": return { action, x: point(rest[0], "X"), y: point(rest[1], "Y") };
110
+ case "scroll": {
111
+ const direction = oneOf(rest[2], ["up", "down"], "scroll"), steps = rest[3] === undefined ? 3 : point(rest[3], "N");
112
+ if (steps < 1 || steps > 50) throw new CliError("Scroll 1 to 50 steps.");
113
+ return { action, x: point(rest[0], "X"), y: point(rest[1], "Y"), steps: direction === "up" ? -steps : steps };
114
+ }
115
+ case "password": case "size": if (rest.length) throw new CliError(usage); return { action };
116
+ default: throw new CliError(usage);
117
+ }
118
+ }
119
+ /** One line saying what the input did and what the screen shows now. */
120
+ function inputSummary(answer, saved) {
121
+ const did = answer.action === "unlock" ? (answer.unlocked ? "Unlocked the screen." : answer.message ?? "The screen was not locked.")
122
+ : answer.action === "type" ? `Typed ${answer.typed} characters.` : answer.action === "key" ? `Pressed ${answer.pressed.join(", ")}.`
123
+ : answer.action === "click" ? `Clicked at ${answer.clicked.join(",")}.` : answer.action === "password" ? "Typed the Mac's password."
124
+ : answer.action === "size" ? `The screen is ${answer.width} x ${answer.height} points.` : `Done: ${answer.action}.`;
125
+ const shot = answer.screenshot;
126
+ if (!shot) return answer.screenshot_error ? `${did}\n${answer.screenshot_error}` : did;
127
+ const seen = shot.screen_locked ? "the screen is locked" : shot.dialog ? `a dialog is open: ${shot.dialog}` : shot.front_app ? `${shot.front_app} in front` : "";
128
+ return `${did}\nSaved ${saved}${seen ? ` (${seen})` : ""}.`;
129
+ }
51
130
  function budget(value = "5") {
52
131
  if (!/^(?:0|[1-9][0-9]{0,5})(?:\.[0-9]{1,6})?$/.test(value)) throw new CliError("--max-cost must be a positive USD amount, for example 5 or 2.50.");
53
132
  const [whole, fraction = ""] = value.split(".");
@@ -90,7 +169,7 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
90
169
  let job = started;
91
170
  const deadline = now() + waitSeconds * 1000;
92
171
  const begun = now();
93
- let announced = false, busy = false;
172
+ let announced = false, busy = false, shownWaiting = "";
94
173
  for (let polls = 0; waitSeconds && !["exited", "unknown"].includes(job.state) && now() < deadline; polls++) {
95
174
  // A slow command can outlive the shell waiting for it (agent shells time out); leave a way back.
96
175
  if (!json && !announced && now() - begun >= NOTICE_MS && deadline - now() > 1000) {
@@ -101,6 +180,12 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
101
180
  // A saturated Mac (a booting Simulator) answers late; the command still runs, so keep waiting.
102
181
  try { job = await hostedRequest(id, "GET", `${base}/jobs/${job.id}`, {}, { request, transport, cache }); busy = false; }
103
182
  catch (error) { if (error?.code !== "guest_busy") throw error; busy = true; }
183
+ // A quiet command the Mac sees waiting on macOS (a dialog, a locked screen): say what and how to go on, once each.
184
+ const waiting = job.waiting_on;
185
+ if (!json && waiting?.kind && `${waiting.kind}:${waiting.text}` !== shownWaiting) {
186
+ shownWaiting = `${waiting.kind}:${waiting.text}`;
187
+ stderr(`nomac: ${job.id} is waiting on macOS: ${waiting.text}\n ${waiting.fix}\n`);
188
+ }
104
189
  }
105
190
  if (job.state !== "exited") {
106
191
  if (!waitSeconds) { print(JSON.stringify(job, null, 2)); return job; }
@@ -165,6 +250,9 @@ export function statusSummary(session, image) {
165
250
  const stops = [a.idle_deadline_at ? `when idle, at ${clockUtc(a.idle_deadline_at)}` : null, a.hard_deadline_at ? `at ${clockUtc(a.hard_deadline_at)} at the latest` : null].filter(Boolean);
166
251
  if (stops.length) lines.push(`Stops by itself ${stops.join(", or ")}. Stop now: nomac stop`);
167
252
  }
253
+ if (session.state === "ready" && session.access) {
254
+ lines.push(`Open to your agent: ${session.access.prompts === "auto" ? "no administrator password prompts" : "macOS's usual prompts"}. Login password: nomac password ($NOMAC_MAC_PASSWORD on the Mac).`);
255
+ }
168
256
  if (session.failure_code) lines.push(`Ended: ${session.failure_code}`);
169
257
  return lines.join("\n");
170
258
  }
@@ -195,7 +283,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
195
283
  const next = result.session.state === "ready" ? "Next: nomac sync . → nomac ssh"
196
284
  : result.session.failure_code === "capacity_unavailable" ? "No Mac was free within 5 minutes; nothing was charged. Run nomac start again in a few minutes."
197
285
  : result.session.state === "queued" ? "Waiting for a free Mac (waiting is free). Check again: nomac start" : "Check: nomac session status --json";
198
- print(`${result.session.id}: ${result.session.state}\n${next}\n${usage}`);
286
+ print(`${result.session.id}: ${result.session.state}\n${next}\n${usage}${result.warning ? `\n${result.warning}` : ""}`);
199
287
  return result;
200
288
  };
201
289
  const saveView = (local, view) => {
@@ -291,8 +379,58 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
291
379
  return started;
292
380
  }
293
381
  if (command === "desktop") {
294
- if (positionals.length) throw new CliError("Usage: nomac desktop [--no-open] [--json]");
295
- return desktop(await ready(localSession()), { values, print });
382
+ if (positionals.length) throw new CliError("Usage: nomac desktop [--app] [--no-open] [--json]");
383
+ const local = await ready(localSession());
384
+ if (values.app) return screenSharing(local, { request, print });
385
+ return desktop(local, { values, print });
386
+ }
387
+ if (command === "defaults") {
388
+ if (positionals.length) throw new CliError("Usage: nomac defaults [--prompts auto|ask] [--permit APP=PERMISSIONS]... [--reset] [--json]");
389
+ let answer;
390
+ if (values.reset) answer = await request("DELETE", "/api/v1/mac/defaults");
391
+ else {
392
+ const change = accessOption(values);
393
+ answer = await request("GET", "/api/v1/mac/defaults");
394
+ // Only the options given change; the rest of the saved defaults stay.
395
+ if (change) answer = await request("PUT", "/api/v1/mac/defaults", { body: { ...answer.defaults, ...change, ...(change.permissions ? { permissions: { ...answer.defaults.permissions, ...change.permissions } } : {}) } });
396
+ }
397
+ if (values.json) { print(JSON.stringify(answer, null, 2)); return answer; }
398
+ const e = answer.effective, apps = Object.entries(e.permissions ?? {});
399
+ print([`Every new Mac starts with: ${e.prompts === "auto" ? "no administrator password prompts, privacy prompts and password dialogs answered on the Mac" : "macOS's usual prompts"}.`,
400
+ apps.length ? `Permissions granted at start: ${apps.map(([app, list]) => `${app} (${list === "all" ? "all" : list.join(", ")})`).join("; ")}.` : "No app is granted permissions at start (nomac defaults --permit APP=camera,microphone).",
401
+ "A start's own --prompts or --permit wins for that Mac. nomac defaults --reset goes back to fully open."].join("\n"));
402
+ return answer;
403
+ }
404
+ if (command === "permit") {
405
+ if (positionals.length < 2) throw new CliError("Usage: nomac permit APP PERMISSION... (or all), for example nomac permit com.example.App camera microphone");
406
+ const id = sessionId(localSession().id);
407
+ const job = await request("POST", `/api/v1/mac/sessions/${id}/jobs?wait=30`, { idempotencyKey: `cli-${randomUUID()}`, body: { argv: ["/opt/homebrew/bin/node", "/usr/local/bin/nomac-permit", ...positionals], timeout_seconds: 60 } });
408
+ let answer;
409
+ try { answer = JSON.parse(String(job.stdout ?? "").trim().split("\n").pop()); } catch { throw new CliError("The Mac gave no answer for the grant.", "Check that the Mac is ready (nomac status), then try again."); }
410
+ if (!answer.ok) throw new CliError(answer.message, undefined, undefined, answer.error);
411
+ print(values.json ? JSON.stringify(answer, null, 2) : `Granted ${answer.granted.join(", ")} to ${answer.app}. It will not ask.`);
412
+ return answer;
413
+ }
414
+ if (command === "password") {
415
+ if (positionals.length) throw new CliError("Usage: nomac password [--copy] [--json]");
416
+ const answer = await request("GET", `/api/v1/mac/sessions/${sessionId(localSession().id)}/password`);
417
+ if (values.copy) await copyToClipboard(answer.password, run);
418
+ if (values.json) print(JSON.stringify(values.copy ? { ...answer, password: undefined, copied: true } : answer, null, 2));
419
+ else print(values.copy ? `Copied the Mac's login password (user ${answer.user}) to the clipboard.` : `${answer.password}\nUser ${answer.user}. On the Mac it is in $${answer.variable}.`);
420
+ return answer;
421
+ }
422
+ if (command === "input" || command === "unlock") {
423
+ if (command === "unlock" && positionals.length) throw new CliError("Usage: nomac unlock");
424
+ const body = { ...(command === "unlock" ? { action: "unlock" } : inputRequest(positionals, values)), ...(values["no-screenshot"] ? { screenshot: false } : {}) };
425
+ const answer = await request("POST", `/api/v1/mac/sessions/${sessionId(localSession().id)}/input`, { body });
426
+ let saved;
427
+ if (typeof answer.screenshot?.image_base64 === "string") {
428
+ saved = values.output ?? "nomac-screenshot.jpg";
429
+ writeFileSync(saved, Buffer.from(answer.screenshot.image_base64, "base64"));
430
+ answer.screenshot = { ...answer.screenshot, image_base64: undefined, saved_to: saved };
431
+ }
432
+ print(values.json ? JSON.stringify(answer, null, 2) : inputSummary(answer, saved));
433
+ return answer;
296
434
  }
297
435
  if (command === "ssh") {
298
436
  if (values.json) throw new CliError("SSH streams bytes. Use session status --json for metadata.");
@@ -305,6 +443,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
305
443
  let local = store.load();
306
444
  if (local && local.mode !== "hosted") throw new CliError("An existing Mac workspace is attached.");
307
445
  const limits = { max_duration_seconds: integer(values["max-duration"], 7200, 60, 28800), idle_timeout_seconds: integer(values["idle-timeout"], 600, 60, 3600), spend_cap_microunits: budget(values["max-cost"]) };
446
+ const access = accessOption(values);
308
447
  // Older CLIs persisted invalid keys before contacting the API. Such a key
309
448
  // cannot pass server validation and therefore cannot have allocated a VM.
310
449
  if (local && !local.id && !validRequestKey(local.requestKey)) { local.state = "failed"; store.save(local); }
@@ -313,11 +452,15 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
313
452
  if (values[flag] !== undefined && limits[field] !== local.request.limits[field]) throw new CliError("This session already has different limits. Stop it and start a fresh session to change them.");
314
453
  }
315
454
  if (values["request-key"] !== undefined && values["request-key"] !== local.requestKey) throw new CliError("A session is already attached under a different request key. Inspect it or stop it before starting another.");
455
+ if (access && JSON.stringify({ prompts: "auto", gatekeeper: "off", ...access }) !== JSON.stringify({ prompts: "auto", gatekeeper: "off", ...local.request.access })) {
456
+ throw new CliError("This session was started with different --prompts, --gatekeeper or --permit. Stop it and start a fresh session to change them; nomac permit grants more on the running Mac.");
457
+ }
316
458
  }
317
459
  if (!local || ["stopped", "failed"].includes(local.state)) {
318
460
  const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
319
461
  await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
320
- local = { mode: "hosted", state: "requesting", outcomeUnknown: false, identity, requestKey: values["request-key"] ?? `cli-${randomUUID()}`, request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits } };
462
+ local = { mode: "hosted", state: "requesting", outcomeUnknown: false, identity, requestKey: values["request-key"] ?? `cli-${randomUUID()}`,
463
+ request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits, ...(access ? { access } : {}) } };
321
464
  store.save(local); store.saveBaseline({});
322
465
  }
323
466
  if (!local.id) {
@@ -331,6 +474,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
331
474
  }
332
475
  throw error;
333
476
  }
477
+ // The start's warning (what the Mac allows, how to opt out) is shown with every start of this session.
478
+ if (result.warning) local.warning = result.warning;
334
479
  saveView(local, result.session);
335
480
  }
336
481
  const deadline = now() + wait * 1000;
@@ -340,7 +485,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
340
485
  await sleep(2000);
341
486
  }
342
487
  if (local.state === "ready") await ready(local);
343
- return output({ session: local.snapshot, request_key: local.requestKey });
488
+ return output({ session: local.snapshot, request_key: local.requestKey, ...(local.warning ? { warning: local.warning } : {}) });
344
489
  }
345
490
  const local = localSession();
346
491
  if (command === "stop") {
package/src/mac-tools.mjs CHANGED
@@ -20,9 +20,11 @@ export const MAC_TOOLS = [
20
20
  return text({ ...result, opened_in_browser: opened });
21
21
  } },
22
22
  { name: "get_mac_credits", description: "Read the prepaid Mac credit balance: spendable dollars, spendable_seconds (how long a Mac can run on it), amounts held by a running session, pricing (including bonus_tiers for bigger purchases), recent purchases and first_purchase (true until the first paid top-up). Credit never expires. If spendable_seconds is below what the task needs, call get_mac_checkout.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/credits")) },
23
- { name: "start_mac", description: "Allocate a full cloud Mac VM, billed from prepaid credit at $0.80 an hour by the minute. If it returns insufficient_credit, call get_mac_checkout, have the human pay, then retry with the same request_key. Supply a stable request_key; SSH public_key is optional for HTTPS-only agents; reuse both after a lost response. Inspect the returned offer and limits. Poll get_mac_session until ready. No project pipeline is imposed; stop_mac deletes the VM and files.", inputSchema: schema({ public_key: { type: "string" }, request_key: key, limits: { type: "object", properties: { max_duration_seconds: { type: "integer", minimum: 60, maximum: 28800 }, idle_timeout_seconds: { type: "integer", minimum: 60, maximum: 3600 }, spend_cap_microunits: { type: "string", pattern: "^[1-9][0-9]{0,17}$" } }, additionalProperties: false } }, ["request_key"]), handler: async ({ request_key, ...body }) => text(await api("POST", "/api/v1/mac/sessions", { body, idempotencyKey: request_key })) },
23
+ { name: "start_mac", description: "Allocate a full cloud Mac VM, billed from prepaid credit at $0.80 an hour by the minute. If it returns insufficient_credit, call get_mac_checkout, have the human pay, then retry with the same request_key. Supply a stable request_key; SSH public_key is optional for HTTPS-only agents; reuse both after a lost response. Inspect the returned offer and limits. Poll get_mac_session until ready. No project pipeline is imposed; stop_mac deletes the VM and files. The Mac is fully enabled for you: macOS asks for no administrator password, and the Mac's login password is in $NOMAC_MAC_PASSWORD on it; the answer's warning says so. access opts out: prompts ask keeps macOS's usual prompts.", inputSchema: schema({ public_key: { type: "string" }, request_key: key, limits: { type: "object", properties: { max_duration_seconds: { type: "integer", minimum: 60, maximum: 28800 }, idle_timeout_seconds: { type: "integer", minimum: 60, maximum: 3600 }, spend_cap_microunits: { type: "string", pattern: "^[1-9][0-9]{0,17}$" } }, additionalProperties: false },
24
+ access: { type: "object", properties: { prompts: { type: "string", enum: ["auto", "ask"] }, gatekeeper: { type: "string", enum: ["off", "on"] },
25
+ permissions: { type: "object", description: "By bundle ID: a list of permissions (camera, microphone, contacts, calendars, reminders, photos, desktop, documents, downloads and more) or \"all\"", additionalProperties: { anyOf: [{ type: "string", enum: ["all"] }, { type: "array", items: { type: "string" }, minItems: 1 }] } } }, additionalProperties: false } }, ["request_key"]), handler: async ({ request_key, ...body }) => text(await api("POST", "/api/v1/mac/sessions", { body, idempotencyKey: request_key })) },
24
26
  { name: "list_mac_sessions", description: "Find existing Mac sessions, including after losing a response. Shows state, cost, limits and cleanup.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/sessions")) },
25
- { name: "get_mac_session", description: "Read live Mac allocation state, cost, funded time and cleanup confirmation, and once ready the credentials installed on it (secrets, signing identities and profiles, by name and path, never values). queued/provisioning means wait; stopping is not yet confirmed cleanup.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("GET", base(args))) },
27
+ { name: "get_mac_session", description: "Read live Mac allocation state, cost, funded time and cleanup confirmation, and once ready the credentials installed on it (secrets, signing identities and profiles, by name and path, never values) and account: the Mac's user, whether its login password is set (in $NOMAC_MAC_PASSWORD on the Mac), the access choices, and how to type and click (mac_input). queued/provisioning means wait; stopping is not yet confirmed cleanup.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("GET", base(args))) },
26
28
  { name: "list_secrets", description: "List the account's secrets by name: variables (set in every shell and command on the Mac) and files (with their path on the Mac), whether every Mac receives them or only when asked, sizes, and secrets an agent asked for that the person has not entered yet. Values are never returned.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/secrets")) },
27
29
  { name: "request_secret", description: "Get a link where the person enters a secret in their browser, so its value never passes through this chat: an API token, a .env value, a .p12 certificate or a config file. kind variable is one line of text, set as an environment variable on the Mac; kind file is stored at destination (under the home folder, like ~/.netrc; default ~/.nomac/secrets/<name>). Give the person the url. Then list_secrets shows it, new Macs receive it, and install_secrets puts it on a running Mac. With a shell on this machine, the person can also run nomac secrets set NAME themselves. Never ask for a secret in the chat.", inputSchema: schema({ name: { type: "string", minLength: 1, maxLength: 128 }, kind: { type: "string", enum: ["variable", "file"] }, description: { type: "string", minLength: 1, maxLength: 500, description: "What the person should enter and why; shown to them" }, destination: { type: "string", maxLength: 256 }, delivery: { type: "string", enum: ["every_mac", "on_request"] } }, ["name", "kind", "description"]), handler: async body => text(await api("POST", "/api/v1/secrets/requests", { body })) },
28
30
  { name: "set_up_signing", description: "Set up App Store signing for a project on the Mac, with the App Store Connect key kept on NoMac's server: reads the project at path (its scheme, app and every extension, with entitlements), registers missing bundle IDs, switches on the capabilities Apple's API can, makes the App Store profiles, and installs them with the account's certificate in a keychain on the Mac. The project never changes. Returns a build settings file (.xcconfig) and export options with the exact xcodebuild, Flutter and fastlane commands. Creates a distribution certificate at Apple only when the account has none. Expo and Capacitor apps need their ios folder first. A step only a person can take comes back as needs_human with its link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The project folder on the Mac, relative to the workspace" }, scheme: { type: "string", minLength: 1, maxLength: 256 }, container: { type: "string", minLength: 1, maxLength: 1024 }, issuer_id: { type: "string", maxLength: 64 } }, ["session_id"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/signing`, { body })) },
@@ -76,12 +78,22 @@ export const MAC_TOOLS = [
76
78
  : "This binary file is over 1 MiB. Pass save_to to download it to this machine, or max_bytes to read its first bytes as base64." });
77
79
  return text(file);
78
80
  } },
79
- { name: "screenshot_mac", description: "See the Mac's screen: returns a screenshot as an image you can look at. target screen (default) is the whole macOS desktop at 1440x900, where image pixels are screen coordinates; target simulator is the booted iOS Simulator's display. Use it to check an app's UI, Xcode or system dialogs and error messages, or what the human sees in open_mac_desktop. Also returns front_app (the app in front) and path, the JPEG on the Mac. save_to writes the image to this machine instead of returning it.", inputSchema: schema({ session_id: session, target: { type: "string", enum: ["screen", "simulator"] }, save_to: { type: "string" } }, ["session_id"]), handler: async ({ session_id, target, save_to }) => {
81
+ { name: "screenshot_mac", description: "See the Mac's screen: returns a screenshot as an image you can look at. target screen (default) is the whole macOS desktop at 1440x900, where image pixels are screen coordinates; target simulator is the booted iOS Simulator's display. Use it to check an app's UI, Xcode or system dialogs and error messages, or what the human sees in open_mac_desktop. Also returns front_app (the app in front), screen_locked, dialog (an open macOS dialog with its text and buttons) with next_step, and path, the JPEG on the Mac. save_to writes the image to this machine instead of returning it.", inputSchema: schema({ session_id: session, target: { type: "string", enum: ["screen", "simulator"] }, save_to: { type: "string" } }, ["session_id"]), handler: async ({ session_id, target, save_to }) => {
80
82
  const shot = await screenshot(session_id, { target, saveTo: save_to });
81
83
  if (save_to) return text(shot);
82
84
  const { image_base64: data, ...meta } = shot;
83
85
  return { content: [{ type: "image", data, mimeType: meta.mime_type }, { type: "text", text: JSON.stringify(meta, null, 2) }] };
84
86
  } },
87
+ { name: "mac_input", description: "Send real keyboard and mouse input to the Mac, as a person at it would: macOS accepts it everywhere, including the lock screen, the login window and password dialogs, where scripted keystrokes cannot type. action type types text; key presses keys or combinations (return, tab, escape, cmd+q, ctrl+cmd+q); click, move and scroll take x and y in screen points from the top left, as in screenshot_mac's image; password types the Mac's login password into whatever has focus; unlock opens a locked screen (only when it is locked); size returns the screen size. Answers with a screenshot taken right after, unless screenshot is false. Not safe to repeat blindly: a retry types again.",
88
+ inputSchema: schema({ session_id: session, action: { type: "string", enum: ["type", "key", "click", "move", "scroll", "password", "unlock", "size"] }, text: { type: "string", minLength: 1, maxLength: 500, description: "For type: up to 500 characters; for longer text, pbcopy it with exec_mac, then key cmd+v" },
89
+ keys: { type: "array", items: { type: "string", minLength: 1, maxLength: 40 }, minItems: 1, maxItems: 20 }, x: { type: "integer", minimum: 0 }, y: { type: "integer", minimum: 0 },
90
+ steps: { type: "integer", minimum: -50, maximum: 50 }, button: { type: "string", enum: ["left", "right"] }, double: { type: "boolean" }, screenshot: { type: "boolean" } }, ["session_id", "action"]),
91
+ handler: async ({ session_id, ...body }) => {
92
+ const answer = await api("POST", `${base({ session_id })}/input`, { body });
93
+ if (typeof answer.screenshot?.image_base64 !== "string") return text(answer);
94
+ const { image_base64: data, ...meta } = answer.screenshot;
95
+ return { content: [{ type: "image", data, mimeType: meta.mime_type }, { type: "text", text: JSON.stringify({ ...answer, screenshot: meta }, null, 2) }] };
96
+ } },
85
97
  { name: "start_mac_recording", description: "Start recording a video of the Mac. target screen (default) records the whole desktop; target simulator records the iOS Simulator's window. Returns at once while it records: then do what should be shown (with exec_mac: launch the app, run UI tests, open a URL) and call stop_mac_recording, which saves the MP4 and can download it here. Stops by itself after max_seconds (default 300, max 1800). One recording at a time per session; a stopped Mac deletes it.", inputSchema: schema({ session_id: session, target: { type: "string", enum: ["screen", "simulator"] }, max_seconds: { type: "integer", minimum: 1, maximum: 1800 }, path: { type: "string", minLength: 1, maxLength: 1024 } }, ["session_id"]), handler: async ({ session_id, target, max_seconds, path }) => text(await startRecording(session_id, { target, maxSeconds: max_seconds, path })) },
86
98
  { name: "stop_mac_recording", description: "Stop the recording started with start_mac_recording and save it as an MP4. Returns its path, length in seconds and size, and a download link for the human (url). save_to downloads the video to this machine. If the recording already reached its max_seconds, returns that video. Safe to repeat.", inputSchema: schema({ session_id: session, save_to: { type: "string" } }, ["session_id"]), handler: async ({ session_id, save_to }) => text(await stopRecording(session_id, { saveTo: save_to })) },
87
99
  { name: "fetch_to_mac", description: "Download a URL straight onto the Mac: any size, as fast as the Mac's connection. path is the file to create, or with extract (zip or tar, including .tar.gz) the folder to unpack into. Relative paths are in the session workspace. Waits up to wait_seconds (default 30, max 50) like exec_mac; a longer download keeps running as a job. For a git repository, run git clone with exec_mac instead.", inputSchema: schema({ session_id: session, request_key: key, url: { type: "string", pattern: "^https?://", maxLength: 4096 }, path: { type: "string", minLength: 1, maxLength: 1024 }, extract: { type: "string", enum: ["zip", "tar"] }, wait_seconds: { type: "integer", minimum: 0, maximum: 50 } }, ["session_id", "request_key", "url", "path"]), handler: async ({ session_id, request_key, url, path, extract, wait_seconds }) => text(await api("POST", `${base({ session_id })}/jobs?wait=${wait_seconds ?? 30}`, { body: { argv: ["/bin/bash", "-c", FETCH_SCRIPT], env: { NOMAC_URL: url, NOMAC_PATH: path, NOMAC_EXTRACT: extract ?? "" }, timeout_seconds: 600 }, idempotencyKey: request_key })) },
package/src/mcp.mjs CHANGED
@@ -24,6 +24,7 @@ const INSTRUCTIONS = [
24
24
  "Never rerun a command to get its output; after a lost response, repeat the call with the same request_key and arguments.",
25
25
  "Write files with write_mac_file, not heredocs in exec_mac: a command is at most 64 KiB.",
26
26
  "Shipping without the Apple key on the Mac: set_up_signing on the project returns the files and commands that sign a build with the agent's own tools; send_to_testflight sends the signed .ipa to TestFlight; publish submits it for App Review; notarize notarizes and staples a Developer ID-signed Mac app. Never ask for a secret in the chat: request_secret gives the human a link, and every Mac receives the account's secrets.",
27
+ "The Mac is fully enabled for you: sudo needs no password, macOS asks for no administrator password, the privacy prompts and password dialogs it still shows are answered on the Mac within seconds, the screen never locks, and the Mac's login password is in $NOMAC_MAC_PASSWORD on it (for the login keychain or any prompt). nomac-permit APP camera (run with exec_mac) grants an app privacy permissions before it asks. To type or click as a person would, also on the lock screen and in password dialogs, call mac_input: its answer has a screenshot. When screenshot_mac reports screen_locked or a dialog, follow its next_step.",
27
28
  "Always stop_mac when finished, after reading any output you need, so credit is not spent on an idle Mac.",
28
29
  ].join(" ");
29
30