@@ -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}})