on master help sign in
Handle fm's one-time license prompt

On a real Mac every fm command exits 69 until an admin runs `sudo fm license`. The host now reports that as LICENSE_REQUIRED with the fix, instead of MODEL_UNAVAILABLE or a generic FM_ERROR, and --install-schemas stops at the first refusal (exit 3) instead of concluding the nested organize schema is unsupported. Ping writes any missing schemas once fm works, so accepting the terms is the only step. The installer, self-test, popup and README name the command. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LdKDAYoqmGehyHsV42LhJW

commit6ff63e48dcf5d161b128d5d72d99b94a2f225fea authorClaude <noreply@anthropic.com> dateWed 23 Sep 2026, 20:01 (2 weeks ago) parentd21879b
9 files changed+139−21
README.md+7−5 host/diagonal-host.py+61−11 host/tests/fake_fm.py+8−1 host/tests/test_host.py+42 scripts/install-manifest.sh+11−1 src/background/host.ts+4−2 src/background/index.ts+3−1 src/background/state.ts+2 src/popup/popup.ts+1
README.md+7−5
@@ -28,11 +28,12 @@ The model sees a tab's title, address and `<meta name="description">`, and nothi
2828brew install iko-soy/tap/diagonal
2929```
3030
31−This downloads the latest release, puts the extension at `~/Library/Application Support/Diagonal/extension`, and installs the native host and registers it with Brave. Then load the extension in Brave once:
31+This downloads the latest release, puts the extension at `~/Library/Application Support/Diagonal/extension`, and installs the native host and registers it with Brave. Then, once:
3232
33−1. Open `brave://extensions` and turn on **Developer mode**.
34−2. Click **Load unpacked**, press **Cmd+Shift+G** and paste `~/Library/Application Support/Diagonal/extension`.
35−3. Open Diagonal's settings and click **Run self-test**.
33+1. Accept Apple's terms for the `fm` tool, which it asks for once per Mac before it runs at all: `sudo fm license`. Diagonal writes its model schemas on its own after that.
34+2. Open `brave://extensions` and turn on **Developer mode**.
35+3. Click **Load unpacked**, press **Cmd+Shift+G** and paste `~/Library/Application Support/Diagonal/extension`.
36+4. Open Diagonal's settings and click **Run self-test**.
3637
3738To update: `brew upgrade --cask --greedy diagonal`, then click reload on Diagonal at `brave://extensions`. `brew uninstall diagonal` removes the extension folder, the host and its Brave manifest; `brew uninstall --zap diagonal` also removes the fm schemas and logs.
3839
@@ -48,7 +49,7 @@ Brave only installs extensions on its own from the Chrome Web Store, so the one
4849 bash ~/Applications/Diagonal/install-host.command
4950 ```
5051
51− (Type `bash `, then drag `install-host.command` from the folder into the Terminal window.) If it says python3 is not set up, run `xcode-select --install` and try again.
52+ (Type `bash `, then drag `install-host.command` from the folder into the Terminal window.) If it says python3 is not set up, run `xcode-select --install` and try again. If it asks you to, run `sudo fm license` once to accept Apple's terms for `fm`.
52534. Open Diagonal's settings and click **Run self-test**. Brave does not need a restart.
5354
5455The zip carries the host (`host/`), `extension-id` and `install-host.command`, which is the same script as `scripts/install-manifest.sh`. When you update to a newer release, rerun step 3.
@@ -128,6 +129,7 @@ Every push to `master` runs `.github/workflows/release.yml`: it typechecks, runs
128129
129130Everything was tested against a fake `fm`. These are open until someone runs it on macOS 27 (section 16 of the spec):
130131
132+- **Confirmed on a Mac:** `fm` is at `/usr/bin/fm`, and until an admin runs `sudo fm license` every `fm` command exits 69 with a terms notice. Diagonal reports that as `LICENSE_REQUIRED` with the fix, and the host writes its schemas on the next ping once `fm` works.
131133- The `fm schema` flags. `diagonal-host --install-schemas` tries the nested organize schema and falls back to the two flat schemas plus two calls if that fails. The flags are in `SCHEMA_COMMANDS` at the top of `host/diagonal-host.py`.
132134- `fm` stderr wording. `classify()` matches keywords, so an unexpected message shows as `FM_ERROR` with the raw text on the settings page.
133135- Whether `fm respond` can read the prompt from stdin. Until then, the prompt is an argument and is briefly visible in `ps`.
host/diagonal-host.py+61−11
@@ -11,6 +11,8 @@ Other entry points:
1111 diagonal-host --print-manifest P print the host manifest JSON for executable path P
1212 diagonal-host --version
1313"""
14+import contextlib
15+import io
1416import json
1517import os
1618import struct
@@ -149,8 +151,21 @@ def validate_request(req):
149151
150152# ----- fm ---------------------------------------------------------------------------------------
151153
152−def classify(stderr):
154+# fm refuses every command with exit 69 until an admin accepts Apple's terms once per machine.
155+LICENSE_EXIT = 69
156+LICENSE_FIX = "sudo fm license"
157+LICENSE_MESSAGE = f"Apple's fm tool needs its terms accepted once on this Mac. In Terminal, run: {LICENSE_FIX}"
158+
159+
160+def license_needed(returncode, text):
161+ t = (text or "").lower()
162+ return returncode == LICENSE_EXIT or "legal notice" in t or "fm license" in t
163+
164+
165+def classify(stderr, returncode=None):
153166 s = (stderr or "").lower()
167+ if license_needed(returncode, s):
168+ return "LICENSE_REQUIRED"
154169 if "not available" in s or "unavailable" in s or "apple intelligence" in s or "download" in s or "not supported" in s:
155170 return "MODEL_UNAVAILABLE"
156171 if "rate" in s and "limit" in s:
@@ -203,7 +218,10 @@ def run_fm(prompt, schema, model, timeout_s):
203218 except subprocess.TimeoutExpired:
204219 raise Fail("TIMEOUT", f"fm exceeded {timeout_s:g}s", retryable=True)
205220 if p.returncode != 0:
206− raise Fail(classify(p.stderr), (p.stderr or f"fm exited {p.returncode}").strip()[:500], retryable=True, raw=(p.stderr or "")[:2000])
221+ code = classify(p.stderr, p.returncode)
222+ if code == "LICENSE_REQUIRED":
223+ raise Fail(code, LICENSE_MESSAGE, raw=(p.stderr or "")[:2000])
224+ raise Fail(code, (p.stderr or f"fm exited {p.returncode}").strip()[:500], retryable=True, raw=(p.stderr or "")[:2000])
207225 return extract_json(p.stdout)
208226
209227
@@ -221,14 +239,26 @@ def op_ping(_payload, _opts):
221239 if not os.path.exists(FM):
222240 return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False,
223241 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
242+ license_required = False
224243 try:
225244 avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, timeout=15, env=fm_env())
226245 ok, msg = avail.returncode == 0, (avail.stdout + avail.stderr).strip()[:300]
246+ if not ok and license_needed(avail.returncode, avail.stdout + avail.stderr):
247+ license_required, msg = True, LICENSE_MESSAGE
227248 except subprocess.TimeoutExpired:
228249 ok, msg = False, "fm available timed out"
250+ if ok and not schemas_ok():
251+ # The installer could not write them (typically: terms not yet accepted). Now fm works, so do it here;
252+ # stdout is the native-messaging channel, so the installer's progress lines are swallowed.
253+ with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
254+ install_schemas()
229255 mode = organize_mode()
230256 return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": ok, "fmMessage": msg,
231− "schemasOk": bool(schema_path("name.json")) and mode != "missing", "organizeMode": mode}
257+ "licenseRequired": license_required, "schemasOk": schemas_ok(), "organizeMode": mode}
258+
259+
260+def schemas_ok():
261+ return bool(schema_path("name.json")) and organize_mode() != "missing"
232262
233263
234264def op_name(payload, opts):
@@ -317,31 +347,47 @@ def log_request(req, reply, ms, debug_opt):
317347
318348# ----- CLI entry points ---------------------------------------------------------------------------
319349
350+INSTALL_LICENSE_EXIT = 3
351+
352+
320353def install_schemas():
354+ """0 = written; 3 = fm's terms are not accepted yet (nothing tried past that); 1 = anything else."""
321355 os.makedirs(SCHEMAS, exist_ok=True)
322356 ok = True
323357
324358 def write(name, args):
359+ """True when written; "license" or "rejected" (fm ran and refused) or "error" otherwise."""
325360 try:
326361 p = subprocess.run([FM, *args], capture_output=True, text=True, timeout=30, env=fm_env())
327362 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
328363 print(f" {name}: fm failed ({e})", file=sys.stderr)
329− return False
364+ return "error"
365+ if license_needed(p.returncode, p.stdout + p.stderr):
366+ return "license"
330367 if p.returncode != 0 or not p.stdout.strip():
331368 print(f" {name}: fm schema exited {p.returncode}: {p.stderr.strip()[:200]}", file=sys.stderr)
332− return False
369+ return "rejected"
333370 with open(os.path.join(SCHEMAS, name), "w", encoding="utf-8") as f:
334371 f.write(p.stdout)
335372 print(f" wrote {os.path.join(SCHEMAS, name)}")
336373 return True
337374
338− ok &= write("name.json", SCHEMA_COMMANDS["name.json"])
339− if write("organize.json", SCHEMA_COMMANDS["organize.json"]):
375+ first = write("name.json", SCHEMA_COMMANDS["name.json"])
376+ if first == "license":
377+ print(f" {LICENSE_MESSAGE}", file=sys.stderr)
378+ print(" Diagonal writes its schemas on its own once that is done.", file=sys.stderr)
379+ return INSTALL_LICENSE_EXIT
380+ ok &= first is True
381+ nested = write("organize.json", SCHEMA_COMMANDS["organize.json"])
382+ if nested is True:
340383 for name in FALLBACK_SCHEMA_COMMANDS:
341384 try:
342385 os.remove(os.path.join(SCHEMAS, name))
343386 except FileNotFoundError:
344387 pass
388+ elif nested != "rejected":
389+ # fm did not get as far as judging the schema: no reason to think nesting is unsupported.
390+ return INSTALL_LICENSE_EXIT if nested == "license" else 1
345391 else:
346392 print(" nested organize schema unsupported; using the two-call fallback", file=sys.stderr)
347393 try:
@@ -349,7 +395,7 @@ def install_schemas():
349395 except FileNotFoundError:
350396 pass
351397 for name, args in FALLBACK_SCHEMA_COMMANDS.items():
352− ok &= write(name, args)
398+ ok &= write(name, args) is True
353399 return 0 if ok else 1
354400
355401
@@ -374,11 +420,15 @@ def selftest():
374420
375421 ping = op_ping({}, {})
376422 check("fm found", os.path.exists(FM), FM)
377− check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
378− for name in ["name.json", *(["organize.json"] if organize_mode() == "nested" else list(FALLBACK_SCHEMA_COMMANDS))]:
423+ if ping["licenseRequired"]:
424+ check("fm terms accepted", False, f"run: {LICENSE_FIX}")
425+ else:
426+ check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
427+ for name in ["name.json", *(list(FALLBACK_SCHEMA_COMMANDS) if organize_mode() == "two-call" else ["organize.json"])]:
379428 path = os.path.join(SCHEMAS, name)
380429 readable = os.path.isfile(path) and os.path.getsize(path) > 0
381− check(f"schema {name}", readable, path if readable else "missing: run diagonal-host --install-schemas")
430+ hint = f"written on their own after {LICENSE_FIX}" if ping["licenseRequired"] else "missing: run diagonal-host --install-schemas"
431+ check(f"schema {name}", readable, path if readable else hint)
382432 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
383433 if ping["fmAvailable"] and schema_path("name.json"):
384434 reply = handle({"v": 1, "id": "selftest", "op": "name", "payload": json.loads(json.dumps(FIXTURE)), "opts": {"timeoutMs": 45000}})
host/tests/fake_fm.py+8−1
@@ -5,6 +5,7 @@ control.json keys:
55 respond_queue: list of the same, consumed one per call before `respond` is used
66 available: {"stdout": str, "stderr": str, "exit": int} for `fm available`
77 schema_nested: false → `fm schema` with --object fails (simulates no nested-array support)
8+ license: true → every command exits 69 with the terms notice, as fm does before `sudo fm license`
89Every invocation's argv is appended to argv.log as one JSON line.
910"""
1011import json
@@ -13,6 +14,10 @@ import sys
1314import time
1415
1516HERE = os.path.dirname(os.path.realpath(__file__))
17+# Copied from fm on macOS 27 (reported by a user's installer run).
18+LICENSE_NOTICE = ("YOU HAVE NOT AGREED TO THE APPLE FOUNDATION MODELS CLI LEGAL NOTICE & TERMS.\n"
19+ "Agreeing to the Apple Foundation Models CLI Legal Notice & Terms applies to every user on the machine, "
20+ "so it must be run as a privileged user (e.g. 'sudo fm license').\n")
1621
1722
1823def main():
@@ -22,7 +27,9 @@ def main():
2227 path = os.path.join(HERE, "control.json")
2328 control = json.load(open(path, encoding="utf-8")) if os.path.exists(path) else {}
2429 cmd = argv[0] if argv else ""
25− if cmd == "available":
30+ if control.get("license"):
31+ spec = {"stderr": LICENSE_NOTICE, "exit": 69}
32+ elif cmd == "available":
2633 spec = control.get("available", {"stdout": "available", "exit": 0})
2734 elif cmd == "schema":
2835 if "--object" in argv and control.get("schema_nested", True) is False:
host/tests/test_host.py+42
@@ -71,6 +71,21 @@ class Ping(HostCase):
7171 self.assertFalse(r["result"]["fmAvailable"])
7272 self.assertIn("Apple Intelligence", r["result"]["fmMessage"])
7373
74+ def test_ping_license_not_accepted(self):
75+ self.control({"license": True})
76+ r = self.call("ping")
77+ self.assertFalse(r["result"]["fmAvailable"])
78+ self.assertTrue(r["result"]["licenseRequired"])
79+ self.assertIn("sudo fm license", r["result"]["fmMessage"])
80+
81+ def test_ping_writes_missing_schemas_once_fm_works(self):
82+ for f in os.listdir(os.path.join(self.support, "schemas")):
83+ os.remove(os.path.join(self.support, "schemas", f))
84+ r = self.call("ping")
85+ self.assertTrue(r["ok"], r)
86+ self.assertTrue(r["result"]["schemasOk"])
87+ self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize.json"])
88+
7489 def test_ping_without_fm(self):
7590 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
7691 r = self.call("ping")
@@ -132,6 +147,13 @@ class ErrorCodes(HostCase):
132147 self.assertEqual(r["error"]["code"], "MODEL_UNAVAILABLE")
133148 self.assertIn("requires macOS 27", r["error"]["message"])
134149
150+ def test_license_required(self):
151+ self.control({"license": True})
152+ r = self.call("name", {"items": ITEMS3})
153+ self.assertEqual(r["error"]["code"], "LICENSE_REQUIRED", r)
154+ self.assertIn("sudo fm license", r["error"]["message"])
155+ self.assertFalse(r["error"]["retryable"])
156+
135157 def test_rate_limited(self):
136158 self.expect("RATE_LIMITED", stderr="rate limit exceeded, try later", exit=1)
137159
@@ -236,6 +258,19 @@ class InstallSchemas(HostCase):
236258 self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize-assign.json", "organize-labels.json"])
237259
238260
261+ def test_license_stops_early_without_claiming_nesting_is_unsupported(self):
262+ for f in os.listdir(os.path.join(self.support, "schemas")):
263+ os.remove(os.path.join(self.support, "schemas", f))
264+ self.control({"license": True})
265+ code, _, p = self.run_host(args=["--install-schemas"])
266+ err = p.stderr.decode()
267+ self.assertEqual(code, 3, err)
268+ self.assertIn("sudo fm license", err)
269+ self.assertNotIn("unsupported", err)
270+ self.assertEqual(os.listdir(os.path.join(self.support, "schemas")), [])
271+ self.assertEqual(len(self.argv_log()), 1) # stopped after the first refusal
272+
273+
239274class SelfTest(HostCase):
240275 def test_selftest_passes_with_a_working_fm(self):
241276 self.respond(json.dumps({"title": "Rust async runtimes", "emoji": "🦀"}))
@@ -250,6 +285,13 @@ class SelfTest(HostCase):
250285 self.assertEqual(code, 1)
251286 self.assertIn("FAIL model available", p.stdout.decode())
252287
288+ def test_selftest_names_the_license_fix(self):
289+ self.control({"license": True})
290+ code, _, p = self.run_host(args=["--selftest"])
291+ out = p.stdout.decode()
292+ self.assertEqual(code, 1)
293+ self.assertIn("FAIL fm terms accepted: run: sudo fm license", out)
294+
253295 def test_print_manifest(self):
254296 code, _, p = self.run_host(args=["--print-manifest", "/Users/me/.local/bin/diagonal-host"])
255297 m = json.loads(p.stdout)
scripts/install-manifest.sh+11−1
@@ -53,7 +53,17 @@ for ch in "${CHANNELS[@]}"; do
5353done
5454
5555echo "schemas:"
56−"$BIN" --install-schemas || echo " (schema install failed: is Apple Intelligence on and fm present?)"
56+rc=0
57+"$BIN" --install-schemas || rc=$?
58+if [[ $rc -eq 3 ]]; then
59+ echo
60+ echo "One more step: accept Apple's terms for the fm tool once (asks for your password):"
61+ echo " sudo fm license"
62+ echo "Diagonal finishes setting itself up the next time Brave talks to it."
63+ echo
64+elif [[ $rc -ne 0 ]]; then
65+ echo " (schema install failed: is Apple Intelligence on? Details above.)"
66+fi
5767echo "self-test:"
5868"$BIN" --selftest || true
5969echo "extension ID: $ID"
src/background/host.ts+4−2
@@ -46,7 +46,7 @@ export function mapLastError(message: string): HostError {
4646
4747const KNOWN_CODES = new Set<HostErrorCode>([
4848 "HOST_NOT_FOUND", "HOST_FORBIDDEN", "HOST_CRASHED", "FORBIDDEN_ORIGIN", "BAD_REQUEST", "SCHEMA_MISSING",
49− "MODEL_UNAVAILABLE", "RATE_LIMITED", "OVER_BUDGET", "GUARDRAIL", "TIMEOUT", "BAD_MODEL_OUTPUT", "FM_ERROR",
49+ "MODEL_UNAVAILABLE", "LICENSE_REQUIRED", "RATE_LIMITED", "OVER_BUDGET", "GUARDRAIL", "TIMEOUT", "BAD_MODEL_OUTPUT", "FM_ERROR",
5050]);
5151
5252export async function callHost<T>(
@@ -103,7 +103,7 @@ export function normalizeReply<T>(reply: unknown, at: number): HostReply<T> {
103103
104104/** Errors that mean "fix the setup", not "try this group again later". */
105105export const SETUP_ERRORS = new Set<HostErrorCode>([
106− "HOST_NOT_FOUND", "HOST_FORBIDDEN", "FORBIDDEN_ORIGIN", "SCHEMA_MISSING", "MODEL_UNAVAILABLE",
106+ "HOST_NOT_FOUND", "HOST_FORBIDDEN", "FORBIDDEN_ORIGIN", "SCHEMA_MISSING", "MODEL_UNAVAILABLE", "LICENSE_REQUIRED",
107107]);
108108
109109/** Popup copy for each error: what is wrong and what fixes it (section 12). */
@@ -115,6 +115,8 @@ export function explain(error: HostError | undefined, extensionId = ""): string
115115 case "HOST_FORBIDDEN":
116116 case "FORBIDDEN_ORIGIN":
117117 return `The host does not allow this extension. Its manifest needs "allowed_origins": ["chrome-extension://${extensionId}/"]`;
118+ case "LICENSE_REQUIRED":
119+ return "Apple's fm tool needs its terms accepted once on this Mac. In Terminal, run: sudo fm license. Diagonal picks up from there on its own.";
118120 case "SCHEMA_MISSING":
119121 return "Schemas missing. Run: diagonal-host --install-schemas";
120122 case "MODEL_UNAVAILABLE":
src/background/index.ts+3−1
@@ -124,7 +124,9 @@ async function ping(): Promise<HostReply<PingResult>> {
124124 const h = state.host;
125125 if (reply.ok) {
126126 h.lastPing = { ...reply.result, at: Date.now() };
127− if (!reply.result.fmAvailable) {
127+ if (reply.result.licenseRequired) {
128+ recordHostError({ code: "LICENSE_REQUIRED", message: reply.result.fmMessage });
129+ } else if (!reply.result.fmAvailable) {
128130 recordHostError({ code: "MODEL_UNAVAILABLE", message: reply.result.fmMessage || "fm reports the model unavailable" });
129131 } else if (!reply.result.schemasOk) {
130132 recordHostError({ code: "SCHEMA_MISSING", message: "schema files missing" });
src/background/state.ts+2
@@ -62,6 +62,7 @@ export type HostErrorCode =
6262 | "BAD_REQUEST"
6363 | "SCHEMA_MISSING"
6464 | "MODEL_UNAVAILABLE"
65+ | "LICENSE_REQUIRED"
6566 | "RATE_LIMITED"
6667 | "OVER_BUDGET"
6768 | "GUARDRAIL"
@@ -83,6 +84,7 @@ export interface PingResult {
8384 fmPath: string;
8485 fmAvailable: boolean;
8586 fmMessage: string;
87+ licenseRequired?: boolean;
8688 schemasOk: boolean;
8789 organizeMode?: string;
8890}
src/popup/popup.ts+1
@@ -32,6 +32,7 @@ function renderStatus(s: Status): void {
3232 text.textContent =
3333 code === "HOST_NOT_FOUND" ? "Host not installed" :
3434 code === "MODEL_UNAVAILABLE" ? "Model unavailable" :
35+ code === "LICENSE_REQUIRED" ? "Run sudo fm license" :
3536 code === "HOST_FORBIDDEN" || code === "FORBIDDEN_ORIGIN" ? "Host refuses this ID" :
3637 code === "SCHEMA_MISSING" ? "Schemas missing" : "Host unavailable";
3738 } else if (s.health === "ok") {