@nomac/cli 0.9.1 → 0.10.1
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 +1 -1
- package/skill/SKILL.md +19 -3
- package/src/cli.mjs +16 -1
- package/src/desktop.mjs +78 -1
- package/src/hosted-sessions.mjs +158 -8
- package/src/mac-tools.mjs +16 -4
- package/src/mcp.mjs +1 -0
package/package.json
CHANGED
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -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; }
|
|
@@ -158,13 +243,21 @@ const clockUtc = ms => new Date(ms).toISOString().slice(11, 16) + " UTC";
|
|
|
158
243
|
/** One readable paragraph for nomac status. */
|
|
159
244
|
export function statusSummary(session, image) {
|
|
160
245
|
const lines = [`${session.id}: ${session.state}`];
|
|
161
|
-
if (image?.macos)
|
|
246
|
+
if (image?.macos) {
|
|
247
|
+
// An image with a second Xcode names it and its path, as the API's image line does.
|
|
248
|
+
const others = (image.xcodes ?? []).filter(x => !x.default);
|
|
249
|
+
const xcode = others.length ? `Xcode ${image.xcode} (default) and ${others.map(x => `Xcode ${x.version} (${x.path})`).join(" and ")}` : `Xcode ${image.xcode}`;
|
|
250
|
+
lines.push(`Mac: macOS ${image.macos.split(" ")[0]}, ${xcode}${(image.simulator_runtimes ?? []).map(r => `, ${r} Simulator`).join("")}`);
|
|
251
|
+
}
|
|
162
252
|
if (["ready", "provisioning", "queued"].includes(session.state)) lines.push(`Cost so far: ${usd(session.cost?.accrued_microunits)} ($0.80/hour, billed by the minute while ready). Credit left: nomac credits`);
|
|
163
253
|
const a = session.activity ?? {};
|
|
164
254
|
if (session.state === "ready") {
|
|
165
255
|
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
256
|
if (stops.length) lines.push(`Stops by itself ${stops.join(", or ")}. Stop now: nomac stop`);
|
|
167
257
|
}
|
|
258
|
+
if (session.state === "ready" && session.access) {
|
|
259
|
+
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).`);
|
|
260
|
+
}
|
|
168
261
|
if (session.failure_code) lines.push(`Ended: ${session.failure_code}`);
|
|
169
262
|
return lines.join("\n");
|
|
170
263
|
}
|
|
@@ -195,7 +288,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
195
288
|
const next = result.session.state === "ready" ? "Next: nomac sync . → nomac ssh"
|
|
196
289
|
: 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
290
|
: 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}`);
|
|
291
|
+
print(`${result.session.id}: ${result.session.state}\n${next}\n${usage}${result.warning ? `\n${result.warning}` : ""}`);
|
|
199
292
|
return result;
|
|
200
293
|
};
|
|
201
294
|
const saveView = (local, view) => {
|
|
@@ -291,8 +384,58 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
291
384
|
return started;
|
|
292
385
|
}
|
|
293
386
|
if (command === "desktop") {
|
|
294
|
-
if (positionals.length) throw new CliError("Usage: nomac desktop [--no-open] [--json]");
|
|
295
|
-
|
|
387
|
+
if (positionals.length) throw new CliError("Usage: nomac desktop [--app] [--no-open] [--json]");
|
|
388
|
+
const local = await ready(localSession());
|
|
389
|
+
if (values.app) return screenSharing(local, { request, print });
|
|
390
|
+
return desktop(local, { values, print });
|
|
391
|
+
}
|
|
392
|
+
if (command === "defaults") {
|
|
393
|
+
if (positionals.length) throw new CliError("Usage: nomac defaults [--prompts auto|ask] [--permit APP=PERMISSIONS]... [--reset] [--json]");
|
|
394
|
+
let answer;
|
|
395
|
+
if (values.reset) answer = await request("DELETE", "/api/v1/mac/defaults");
|
|
396
|
+
else {
|
|
397
|
+
const change = accessOption(values);
|
|
398
|
+
answer = await request("GET", "/api/v1/mac/defaults");
|
|
399
|
+
// Only the options given change; the rest of the saved defaults stay.
|
|
400
|
+
if (change) answer = await request("PUT", "/api/v1/mac/defaults", { body: { ...answer.defaults, ...change, ...(change.permissions ? { permissions: { ...answer.defaults.permissions, ...change.permissions } } : {}) } });
|
|
401
|
+
}
|
|
402
|
+
if (values.json) { print(JSON.stringify(answer, null, 2)); return answer; }
|
|
403
|
+
const e = answer.effective, apps = Object.entries(e.permissions ?? {});
|
|
404
|
+
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"}.`,
|
|
405
|
+
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).",
|
|
406
|
+
"A start's own --prompts or --permit wins for that Mac. nomac defaults --reset goes back to fully open."].join("\n"));
|
|
407
|
+
return answer;
|
|
408
|
+
}
|
|
409
|
+
if (command === "permit") {
|
|
410
|
+
if (positionals.length < 2) throw new CliError("Usage: nomac permit APP PERMISSION... (or all), for example nomac permit com.example.App camera microphone");
|
|
411
|
+
const id = sessionId(localSession().id);
|
|
412
|
+
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 } });
|
|
413
|
+
let answer;
|
|
414
|
+
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."); }
|
|
415
|
+
if (!answer.ok) throw new CliError(answer.message, undefined, undefined, answer.error);
|
|
416
|
+
print(values.json ? JSON.stringify(answer, null, 2) : `Granted ${answer.granted.join(", ")} to ${answer.app}. It will not ask.`);
|
|
417
|
+
return answer;
|
|
418
|
+
}
|
|
419
|
+
if (command === "password") {
|
|
420
|
+
if (positionals.length) throw new CliError("Usage: nomac password [--copy] [--json]");
|
|
421
|
+
const answer = await request("GET", `/api/v1/mac/sessions/${sessionId(localSession().id)}/password`);
|
|
422
|
+
if (values.copy) await copyToClipboard(answer.password, run);
|
|
423
|
+
if (values.json) print(JSON.stringify(values.copy ? { ...answer, password: undefined, copied: true } : answer, null, 2));
|
|
424
|
+
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}.`);
|
|
425
|
+
return answer;
|
|
426
|
+
}
|
|
427
|
+
if (command === "input" || command === "unlock") {
|
|
428
|
+
if (command === "unlock" && positionals.length) throw new CliError("Usage: nomac unlock");
|
|
429
|
+
const body = { ...(command === "unlock" ? { action: "unlock" } : inputRequest(positionals, values)), ...(values["no-screenshot"] ? { screenshot: false } : {}) };
|
|
430
|
+
const answer = await request("POST", `/api/v1/mac/sessions/${sessionId(localSession().id)}/input`, { body });
|
|
431
|
+
let saved;
|
|
432
|
+
if (typeof answer.screenshot?.image_base64 === "string") {
|
|
433
|
+
saved = values.output ?? "nomac-screenshot.jpg";
|
|
434
|
+
writeFileSync(saved, Buffer.from(answer.screenshot.image_base64, "base64"));
|
|
435
|
+
answer.screenshot = { ...answer.screenshot, image_base64: undefined, saved_to: saved };
|
|
436
|
+
}
|
|
437
|
+
print(values.json ? JSON.stringify(answer, null, 2) : inputSummary(answer, saved));
|
|
438
|
+
return answer;
|
|
296
439
|
}
|
|
297
440
|
if (command === "ssh") {
|
|
298
441
|
if (values.json) throw new CliError("SSH streams bytes. Use session status --json for metadata.");
|
|
@@ -305,6 +448,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
305
448
|
let local = store.load();
|
|
306
449
|
if (local && local.mode !== "hosted") throw new CliError("An existing Mac workspace is attached.");
|
|
307
450
|
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"]) };
|
|
451
|
+
const access = accessOption(values);
|
|
308
452
|
// Older CLIs persisted invalid keys before contacting the API. Such a key
|
|
309
453
|
// cannot pass server validation and therefore cannot have allocated a VM.
|
|
310
454
|
if (local && !local.id && !validRequestKey(local.requestKey)) { local.state = "failed"; store.save(local); }
|
|
@@ -313,11 +457,15 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
313
457
|
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
458
|
}
|
|
315
459
|
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.");
|
|
460
|
+
if (access && JSON.stringify({ prompts: "auto", gatekeeper: "off", ...access }) !== JSON.stringify({ prompts: "auto", gatekeeper: "off", ...local.request.access })) {
|
|
461
|
+
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.");
|
|
462
|
+
}
|
|
316
463
|
}
|
|
317
464
|
if (!local || ["stopped", "failed"].includes(local.state)) {
|
|
318
465
|
const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
|
|
319
466
|
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()}`,
|
|
467
|
+
local = { mode: "hosted", state: "requesting", outcomeUnknown: false, identity, requestKey: values["request-key"] ?? `cli-${randomUUID()}`,
|
|
468
|
+
request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits, ...(access ? { access } : {}) } };
|
|
321
469
|
store.save(local); store.saveBaseline({});
|
|
322
470
|
}
|
|
323
471
|
if (!local.id) {
|
|
@@ -331,6 +479,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
331
479
|
}
|
|
332
480
|
throw error;
|
|
333
481
|
}
|
|
482
|
+
// The start's warning (what the Mac allows, how to opt out) is shown with every start of this session.
|
|
483
|
+
if (result.warning) local.warning = result.warning;
|
|
334
484
|
saveView(local, result.session);
|
|
335
485
|
}
|
|
336
486
|
const deadline = now() + wait * 1000;
|
|
@@ -340,7 +490,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
340
490
|
await sleep(2000);
|
|
341
491
|
}
|
|
342
492
|
if (local.state === "ready") await ready(local);
|
|
343
|
-
return output({ session: local.snapshot, request_key: local.requestKey });
|
|
493
|
+
return output({ session: local.snapshot, request_key: local.requestKey, ...(local.warning ? { warning: local.warning } : {}) });
|
|
344
494
|
}
|
|
345
495
|
const local = localSession();
|
|
346
496
|
if (command === "stop") {
|
package/src/mac-tools.mjs
CHANGED
|
@@ -20,16 +20,18 @@ 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 }
|
|
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 })) },
|
|
29
31
|
{ name: "send_to_testflight", description: "Send a signed .ipa on the Mac to TestFlight, with the App Store Connect key kept on NoMac's server: NoMac creates the upload at Apple and the Mac sends only the file. Apple then processes the build, usually for 5 to 15 minutes with no Mac needed, and NoMac sets the app's export-compliance answer and What to Test and puts it on TestFlight for the internal testers. Repeat the call with the returned request_key to follow it: sending, processing, on_testflight or failed. Once on_testflight, publish with the returned project_id and build_id submits it for App Review. Apple asks once per app whether it uses encryption beyond HTTPS and Apple's own: pass the person's answer as uses_non_exempt_encryption, never a guess. A build number Apple already has comes back with the next free one. A step only a person can take, such as creating the app in App Store Connect, comes back as needs_human with its link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The signed .ipa on the Mac, relative to the workspace or absolute" }, notes: { type: "string", minLength: 1, maxLength: 4000, description: "What to Test, shown to testers" }, uses_non_exempt_encryption: { type: "boolean", description: "The person's answer, asked once per app: does it use encryption beyond HTTPS and Apple's own?" }, issuer_id: { type: "string", maxLength: 64 }, request_key: { ...key, description: "Repeat with the request_key from the first answer to follow that send" }, wait_seconds: { type: "integer", minimum: 0, maximum: 50, description: "Wait up to this long for progress (50 while following)" } }, ["session_id", "path"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/testflight`, { body })) },
|
|
30
32
|
{ name: "notarize", description: "Notarize a Developer ID-signed Mac app, disk image or installer on the Mac (.app, .dmg, .pkg or .zip), with the App Store Connect key kept on NoMac's server: the Mac zips an .app and sends the file to Apple with temporary credentials for this one submission. Apple checks it, usually for a few minutes, then the Mac staples the ticket so it opens without a warning, even offline. Repeat the call with the returned request_key to follow it: preparing, uploading, in_progress, stapling, then accepted, invalid or rejected with Apple's issues. Sign first with the Developer ID Application certificate (the person uploads it at nomac.app/apple), the hardened runtime and a secure timestamp: codesign --force --options runtime --timestamp --sign \"Developer ID Application: …\". Keep the Mac running until it is stapled.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The .app, .dmg, .pkg or .zip on the Mac, relative to the workspace or absolute" }, issuer_id: { type: "string", maxLength: 64 }, request_key: { ...key, description: "Repeat with the request_key from the first answer to follow that notarization" }, wait_seconds: { type: "integer", minimum: 0, maximum: 50, description: "Wait up to this long for progress (50 while following)" } }, ["session_id", "path"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/notarize`, { body })) },
|
|
31
33
|
{ name: "install_secrets", description: "Install the account's secrets and signing identities on a running Mac now: every-Mac secrets, the on-request secrets named in names, and what the Mac already has; a deleted secret is removed. Macs receive every-Mac secrets on their own when they become ready, so call this after the person adds or changes one.", inputSchema: schema({ session_id: session, names: { type: "array", items: { type: "string", minLength: 1, maxLength: 128 }, maxItems: 100 } }, ["session_id"]), handler: async ({ session_id, names = [] }) => text(await api("POST", `${base({ session_id })}/secrets`, { body: { names } })) },
|
|
32
|
-
{ name: "open_mac_desktop", description: "Open the Mac's full macOS desktop in the human's browser on this machine, with mouse and keyboard and nothing to install. Use it when the human wants to see or click something on the Mac, for example Xcode, the Simulator or a sign-in window. The session must be ready. If opened_in_browser is false, give the human the returned url; it opens after they sign in to nomac.app. The session stays busy while the desktop is open and the page disconnects after 15 minutes without input. To show the iOS Simulator there, boot a device with simctl, then run open
|
|
34
|
+
{ name: "open_mac_desktop", description: "Open the Mac's full macOS desktop in the human's browser on this machine, with mouse and keyboard and nothing to install. Use it when the human wants to see or click something on the Mac, for example Xcode, the Simulator or a sign-in window. The session must be ready. If opened_in_browser is false, give the human the returned url; it opens after they sign in to nomac.app. The session stays busy while the desktop is open and the page disconnects after 15 minutes without input. To show the iOS Simulator there, boot a device with simctl, then run open -a Simulator || open -a DeviceHub in a shell with exec_mac (Xcode 27 replaced Simulator.app with Device Hub; Xcode 26's Simulator.app, kept beside it, shows iOS 27 devices too). macOS has no RDP.", inputSchema: schema({ session_id: session, open: { type: "boolean", default: true, description: "Open the link in this machine's browser." } }, ["session_id"]), handler: async ({ session_id, open = true }) => {
|
|
33
35
|
const view = await api("GET", base({ session_id }));
|
|
34
36
|
if (view?.session?.state !== "ready") return text({ error: { code: "session_not_ready", message: "The Mac is not ready. Wait until get_mac_session shows ready, then call open_mac_desktop again." } });
|
|
35
37
|
const url = desktopUrl(session_id), opened = open ? openUrl(url) : false;
|
|
@@ -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
|
|