refactor(mac): make app node a CLI capability superset (#105642)

* refactor(mac): reuse CLI node-host runtime

* fix(mac): prefer checkout CLI in debug builds

* chore: leave release notes to release automation

* chore(mac): sync native string inventory

* chore(mac): refresh native locale artifacts

* fix(node): satisfy native and deadcode gates
This commit is contained in:
Peter Steinberger
2026-07-12 13:16:07 -07:00
committed by GitHub
parent f15a5e566b
commit d287c9b414
62 changed files with 2195 additions and 3130 deletions
+71 -71
View File
@@ -24819,7 +24819,7 @@
},
{
"kind": "conditional-branch",
"line": 177,
"line": 190,
"path": "apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift",
"source": "CLI install failed",
"surface": "apple",
@@ -24827,7 +24827,7 @@
},
{
"kind": "conditional-branch",
"line": 177,
"line": 190,
"path": "apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift",
"source": "CLI install finished",
"surface": "apple",
@@ -24835,7 +24835,7 @@
},
{
"kind": "conditional-branch",
"line": 50,
"line": 54,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Stable",
"surface": "apple",
@@ -24843,7 +24843,7 @@
},
{
"kind": "conditional-branch",
"line": 51,
"line": 55,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Beta",
"surface": "apple",
@@ -24851,7 +24851,7 @@
},
{
"kind": "conditional-branch",
"line": 52,
"line": 56,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Dev (Git main)",
"surface": "apple",
@@ -24859,7 +24859,7 @@
},
{
"kind": "conditional-branch",
"line": 106,
"line": 110,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "OpenClaw Gateway \\(version) is ready.",
"surface": "apple",
@@ -24867,7 +24867,7 @@
},
{
"kind": "conditional-branch",
"line": 108,
"line": 112,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "OpenClaw Gateway is not installed yet.",
"surface": "apple",
@@ -24875,7 +24875,7 @@
},
{
"kind": "conditional-branch",
"line": 110,
"line": 114,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "The OpenClaw Gateway could not be verified. Setup will repair it.",
"surface": "apple",
@@ -24883,7 +24883,7 @@
},
{
"kind": "conditional-branch",
"line": 112,
"line": 116,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Gateway \\(found) does not match app \\(required). Setup will update it.",
"surface": "apple",
@@ -28641,22 +28641,6 @@
"surface": "apple",
"id": "native.apple.737e72233a7b88e6"
},
{
"kind": "conditional-branch",
"line": 983,
"path": "apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift",
"source": "approvalSource requires matching systemRunPlan",
"surface": "apple",
"id": "native.apple.386595fa18585dbb"
},
{
"kind": "conditional-branch",
"line": 984,
"path": "apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift",
"source": "explicit approval requires matching systemRunPlan",
"surface": "apple",
"id": "native.apple.014ff905cecd753b"
},
{
"kind": "conditional-branch",
"line": 426,
@@ -28683,7 +28667,7 @@
},
{
"kind": "conditional-branch",
"line": 683,
"line": 687,
"path": "apps/macos/Sources/OpenClaw/Onboarding.swift",
"source": "Finish",
"surface": "apple",
@@ -28691,7 +28675,7 @@
},
{
"kind": "conditional-branch",
"line": 683,
"line": 687,
"path": "apps/macos/Sources/OpenClaw/Onboarding.swift",
"source": "Next",
"surface": "apple",
@@ -29619,39 +29603,47 @@
},
{
"kind": "ui-call",
"line": 752,
"line": 760,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Getting things ready",
"surface": "apple",
"id": "native.apple.42af85cdf91d0264"
},
{
"kind": "ui-call-concatenated",
"line": 754,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"surface": "apple",
"id": "native.apple.183aee003b44ede6"
},
{
"kind": "ui-named-argument",
"line": 765,
"line": 771,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Install OpenClaw",
"surface": "apple",
"id": "native.apple.d0b8cf59f852b3d0"
},
{
"kind": "ui-named-argument",
"line": 772,
"kind": "conditional-branch",
"line": 778,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Prepare the Mac node",
"surface": "apple",
"id": "native.apple.0b0fc42b8827e01f"
},
{
"kind": "conditional-branch",
"line": 778,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Start the background service",
"surface": "apple",
"id": "native.apple.0112e18fa97e7c0c"
},
{
"kind": "ui-named-argument",
"line": 773,
"kind": "conditional-branch",
"line": 780,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Runs inside the app and uses its macOS permissions.",
"surface": "apple",
"id": "native.apple.9230c2a15ba6da94"
},
{
"kind": "conditional-branch",
"line": 781,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Runs quietly and starts again after a restart.",
"surface": "apple",
@@ -29659,15 +29651,23 @@
},
{
"kind": "ui-named-argument",
"line": 776,
"line": 784,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Ready for the next step",
"surface": "apple",
"id": "native.apple.0357f1eea66db068"
},
{
"kind": "ui-named-argument",
"line": 777,
"kind": "conditional-branch",
"line": 786,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Once ready, this Mac connects to your selected Gateway.",
"surface": "apple",
"id": "native.apple.b4ba6f183309593d"
},
{
"kind": "conditional-branch",
"line": 787,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Once the service answers, youll connect your AI.",
"surface": "apple",
@@ -29675,7 +29675,7 @@
},
{
"kind": "ui-named-argument",
"line": 782,
"line": 792,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "The Gateway didnt start",
"surface": "apple",
@@ -29683,7 +29683,7 @@
},
{
"kind": "ui-named-argument",
"line": 785,
"line": 795,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Try again",
"surface": "apple",
@@ -29691,7 +29691,7 @@
},
{
"kind": "ui-call",
"line": 876,
"line": 886,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Youre all set!",
"surface": "apple",
@@ -29699,7 +29699,7 @@
},
{
"kind": "ui-call",
"line": 879,
"line": 889,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Finish opens the chat — say hi to your new agent.",
"surface": "apple",
@@ -29707,7 +29707,7 @@
},
{
"kind": "ui-named-argument",
"line": 887,
"line": 897,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Configure later",
"surface": "apple",
@@ -29715,7 +29715,7 @@
},
{
"kind": "ui-named-argument",
"line": 888,
"line": 898,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Pick Local or Remote in Settings → General whenever youre ready.",
"surface": "apple",
@@ -29723,7 +29723,7 @@
},
{
"kind": "ui-named-argument",
"line": 895,
"line": 905,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Remote gateway checklist",
"surface": "apple",
@@ -29731,7 +29731,7 @@
},
{
"kind": "ui-named-argument-multiline",
"line": 896,
"line": 906,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "On your gateway host: install/update the `openclaw` package and make sure credentials exist\n(typically `~/.openclaw/credentials/oauth.json`). Then connect again if needed.",
"surface": "apple",
@@ -29739,7 +29739,7 @@
},
{
"kind": "ui-named-argument",
"line": 905,
"line": 915,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Open the menu bar panel",
"surface": "apple",
@@ -29747,7 +29747,7 @@
},
{
"kind": "ui-named-argument",
"line": 906,
"line": 916,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Click the OpenClaw menu bar icon for quick chat and status.",
"surface": "apple",
@@ -29755,7 +29755,7 @@
},
{
"kind": "ui-named-argument",
"line": 909,
"line": 919,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Connect Discord, Slack, Telegram, WhatsApp, …",
"surface": "apple",
@@ -29763,7 +29763,7 @@
},
{
"kind": "ui-named-argument",
"line": 910,
"line": 920,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Open Settings → Channels to link channels and monitor status.",
"surface": "apple",
@@ -29771,7 +29771,7 @@
},
{
"kind": "ui-named-argument",
"line": 912,
"line": 922,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Open Settings → Channels",
"surface": "apple",
@@ -29779,7 +29779,7 @@
},
{
"kind": "ui-named-argument",
"line": 917,
"line": 927,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Try Voice Wake",
"surface": "apple",
@@ -29787,7 +29787,7 @@
},
{
"kind": "ui-named-argument",
"line": 918,
"line": 928,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.",
"surface": "apple",
@@ -29795,7 +29795,7 @@
},
{
"kind": "ui-named-argument",
"line": 921,
"line": 931,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Use the panel + Canvas",
"surface": "apple",
@@ -29803,7 +29803,7 @@
},
{
"kind": "ui-named-argument-concatenated",
"line": 922,
"line": 932,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Open the menu bar panel for quick chat; the agent can show previews and richer visuals in Canvas.",
"surface": "apple",
@@ -29811,7 +29811,7 @@
},
{
"kind": "ui-named-argument",
"line": 926,
"line": 936,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Give your agent more powers",
"surface": "apple",
@@ -29819,7 +29819,7 @@
},
{
"kind": "ui-named-argument",
"line": 927,
"line": 937,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
"surface": "apple",
@@ -29827,7 +29827,7 @@
},
{
"kind": "ui-named-argument",
"line": 929,
"line": 939,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Open Settings → Skills",
"surface": "apple",
@@ -29835,7 +29835,7 @@
},
{
"kind": "ui-call",
"line": 934,
"line": 944,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Launch at login",
"surface": "apple",
@@ -29843,7 +29843,7 @@
},
{
"kind": "ui-call",
"line": 961,
"line": 971,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Skills included",
"surface": "apple",
@@ -29851,7 +29851,7 @@
},
{
"kind": "ui-call",
"line": 968,
"line": 978,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Refresh",
"surface": "apple",
@@ -29859,7 +29859,7 @@
},
{
"kind": "ui-call",
"line": 977,
"line": 987,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Couldnt load skills from the Gateway.",
"surface": "apple",
@@ -29867,7 +29867,7 @@
},
{
"kind": "ui-call-concatenated",
"line": 980,
"line": 990,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Make sure the Gateway is running and connected, then hit Refresh (or open Settings → Skills).",
"surface": "apple",
@@ -29875,7 +29875,7 @@
},
{
"kind": "ui-call",
"line": 986,
"line": 996,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "Details: \\(error)",
"surface": "apple",
@@ -29883,7 +29883,7 @@
},
{
"kind": "ui-call",
"line": 992,
"line": 1002,
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
"source": "No skills reported yet.",
"surface": "apple",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "الاستخدام"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "يتطلّب approvalSource وجود systemRunPlan مطابق"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "تتطلّب الموافقة الصريحة وجود systemRunPlan مطابق"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "جارٍ تجهيز الأمور"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "يقوم OpenClaw بإعداد خدمته في الخلفية على هذا الـ Mac. يستغرق ذلك عادةً أقل من دقيقة — من دون Terminal، ولا كلمة مرور مسؤول."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "ثبّت OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "إعداد عقدة Mac"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "ابدأ خدمة الخلفية"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "تعمل داخل التطبيق وتستخدم أذونات macOS الخاصة به."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "جاهز للخطوة التالية"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "بمجرد أن يصبح جاهزًا، يتصل جهاز Mac هذا بـ Gateway المحدد."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "Nutzung"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource erfordert einen passenden systemRunPlan"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "Eine explizite Genehmigung erfordert einen passenden systemRunPlan"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "Vorbereitung läuft"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw richtet seinen Hintergrunddienst auf diesem Mac ein. Das dauert normalerweise weniger als eine Minute — kein Terminal, kein Administratorpasswort."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "OpenClaw installieren"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Mac-Knoten vorbereiten"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "Hintergrunddienst starten"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "Wird innerhalb der App ausgeführt und nutzt deren macOS-Berechtigungen."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "Bereit für den nächsten Schritt"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "Sobald dieser Mac bereit ist, verbindet er sich mit dem ausgewählten Gateway."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "Uso"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource requiere un systemRunPlan coincidente"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "la aprobación explícita requiere un systemRunPlan coincidente"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "Preparando todo"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw está configurando su servicio en segundo plano en esta Mac. Esto suele tardar menos de un minuto — sin Terminal, sin contraseña de administrador."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "Instalar OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Prepara el nodo Mac"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "Iniciar el servicio en segundo plano"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "Se ejecuta dentro de la app y usa sus permisos de macOS."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "Listo para el siguiente paso"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "Cuando esté listo, este Mac se conectará al Gateway seleccionado."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "استفاده"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource به systemRunPlan منطبق نیاز دارد"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "تأیید صریح به systemRunPlan منطبق نیاز دارد"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "در حال آماده‌سازی"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw در حال راه‌اندازی سرویس پس‌زمینهٔ خود روی این Mac است. این کار معمولاً کمتر از یک دقیقه طول می‌کشد — بدون Terminal، بدون گذرواژهٔ مدیر."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "نصب OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "آماده‌سازی گره Mac"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "شروع سرویس پس‌زمینه"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "درون برنامه اجرا می‌شود و از مجوزهای macOS آن استفاده می‌کند."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "آماده برای مرحلهٔ بعد"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "پس از آماده‌شدن، این Mac به Gateway انتخاب‌شده شما متصل می‌شود."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "Utilisation"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource nécessite un systemRunPlan correspondant"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "l'approbation explicite nécessite un systemRunPlan correspondant"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "Préparation en cours"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw configure son service darrière-plan sur ce Mac. Cela prend généralement moins dune minute — pas de Terminal, pas de mot de passe administrateur."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "Installer OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Préparer le nœud Mac"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "Démarrer le service darrière-plan"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "Sexécute dans lapp et utilise ses autorisations macOS."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "Prêt pour l’étape suivante"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "Une fois prêt, ce Mac se connecte au Gateway sélectionné."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "उपयोग"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource के लिए मेल खाने वाले systemRunPlan की आवश्यकता है"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "explicit approval के लिए मेल खाने वाले systemRunPlan की आवश्यकता है"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "चीज़ें तैयार की जा रही हैं"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw इस Mac पर अपनी background service सेट कर रहा है। इसमें आमतौर पर एक मिनट से कम समय लगता है — कोई Terminal नहीं, कोई administrator password नहीं।"
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "OpenClaw इंस्टॉल करें"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Mac नोड तैयार करें"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "background service शुरू करें"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "ऐप के भीतर चलता है और उसकी macOS अनुमतियों का उपयोग करता है।"
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "अगले चरण के लिए तैयार"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "तैयार होने के बाद, यह Mac आपके चुने हुए Gateway से कनेक्ट होता है।"
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "Penggunaan"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource memerlukan systemRunPlan yang cocok"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "persetujuan eksplisit memerlukan systemRunPlan yang cocok"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "Menyiapkan semuanya"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw sedang menyiapkan layanan latar belakangnya di Mac ini. Ini biasanya memakan waktu kurang dari satu menit — tanpa Terminal, tanpa kata sandi administrator."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "Instal OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Siapkan node Mac"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "Mulai layanan latar belakang"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "Berjalan di dalam aplikasi dan menggunakan izin macOS aplikasi tersebut."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "Siap untuk langkah berikutnya"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "Setelah siap, Mac ini akan terhubung ke Gateway yang Anda pilih."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "Utilizzo"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource richiede un systemRunPlan corrispondente"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "l'approvazione esplicita richiede un systemRunPlan corrispondente"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "Preparazione in corso"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw sta configurando il suo servizio in background su questo Mac. Di solito richiede meno di un minuto — niente Terminale, niente password di amministratore."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "Installa OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Prepara il nodo Mac"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "Avvia il servizio in background"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "Viene eseguito all'interno dell'app e utilizza le relative autorizzazioni macOS."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "Pronto per il passaggio successivo"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "Una volta pronto, questo Mac si connette al Gateway selezionato."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "使用状況"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSourceには一致するsystemRunPlanが必要です"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "明示的な承認には一致するsystemRunPlanが必要です"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "準備中"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw がこの Mac でバックグラウンドサービスを設定しています。通常は 1 分未満で完了します — Terminal も管理者パスワードも不要です。"
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "OpenClaw をインストール"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Macノードを準備"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "バックグラウンドサービスを開始"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "アプリ内で実行され、アプリのmacOS権限を使用します。"
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "次のステップの準備ができました"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "準備が完了すると、このMacは選択したGatewayに接続します。"
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "사용량"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource에는 일치하는 systemRunPlan이 필요합니다"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "명시적 승인에는 일치하는 systemRunPlan이 필요합니다"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "준비 중"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw가 이 Mac에서 백그라운드 서비스를 설정하고 있습니다. 일반적으로 1분 이내에 완료됩니다 — Terminal이나 관리자 비밀번호가 필요하지 않습니다."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "OpenClaw 설치"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Mac 노드 준비"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "백그라운드 서비스 시작"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "앱 내에서 실행되며 앱의 macOS 권한을 사용합니다."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "다음 단계 준비 완료"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "준비가 완료되면 이 Mac이 선택한 Gateway에 연결됩니다."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "Gebruik"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource vereist een overeenkomend systemRunPlan"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "expliciete goedkeuring vereist een overeenkomend systemRunPlan"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "Alles wordt voorbereid"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw stelt de achtergrondservice in op deze Mac. Dit duurt meestal minder dan een minuut — geen Terminal, geen beheerderswachtwoord."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "OpenClaw installeren"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Bereid het Mac-knooppunt voor"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "De achtergrondservice starten"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "Wordt binnen de app uitgevoerd en gebruikt de macOS-machtigingen van de app."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "Klaar voor de volgende stap"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "Zodra deze Mac gereed is, maakt deze verbinding met de geselecteerde Gateway."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "Użycie"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource wymaga zgodnego systemRunPlan"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "jawne zatwierdzenie wymaga zgodnego systemRunPlan"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "Przygotowywanie"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw konfiguruje swoją usługę w tle na tym Macu. Zwykle zajmuje to mniej niż minutę — bez Terminala i bez hasła administratora."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "Zainstaluj OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Przygotuj węzeł Mac"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "Uruchom usługę w tle"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "Działa wewnątrz aplikacji i korzysta z jej uprawnień systemu macOS."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "Gotowe do następnego kroku"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "Po przygotowaniu ten Mac połączy się z wybranym Gateway."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "Uso"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource requer systemRunPlan correspondente"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "a aprovação explícita requer systemRunPlan correspondente"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "Preparando tudo"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "O OpenClaw está configurando o serviço em segundo plano neste Mac. Isso geralmente leva menos de um minuto — sem Terminal, sem senha de administrador."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "Instalar OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Prepare o nó Mac"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "Iniciar o serviço em segundo plano"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "É executado dentro do aplicativo e usa as permissões do macOS."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "Pronto para a próxima etapa"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "Quando estiver pronto, este Mac se conectará ao Gateway selecionado."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "Использование"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource требует соответствующего systemRunPlan"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "явное одобрение требует соответствующего systemRunPlan"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "Подготовка"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw настраивает фоновую службу на этом Mac. Обычно это занимает меньше минуты — без Terminal и пароля администратора."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "Установить OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Подготовьте узел Mac"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "Запустить фоновую службу"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "Работает внутри приложения и использует его разрешения macOS."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "Готово к следующему шагу"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "После подготовки этот Mac подключится к выбранному Gateway."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "Användning"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource kräver matchande systemRunPlan"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "uttryckligt godkännande kräver matchande systemRunPlan"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "Förbereder"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw konfigurerar sin bakgrundstjänst på denna Mac. Det tar vanligtvis under en minut — ingen Terminal, inget administratörslösenord."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "Installera OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Förbered Mac-noden"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "Starta bakgrundstjänsten"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "Körs i appen och använder dess macOS-behörigheter."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "Redo för nästa steg"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "När den är redo ansluter denna Mac till din valda Gateway."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "การใช้งาน"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource ต้องมี systemRunPlan ที่ตรงกัน"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "การอนุมัติแบบชัดแจ้งต้องมี systemRunPlan ที่ตรงกัน"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "กำลังเตรียมสิ่งต่าง ๆ ให้พร้อม"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw กำลังตั้งค่าบริการเบื้องหลังบน Mac เครื่องนี้ โดยปกติใช้เวลาไม่ถึงหนึ่งนาที — ไม่ต้องใช้ Terminal ไม่ต้องใช้รหัสผ่านผู้ดูแลระบบ"
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "ติดตั้ง OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "เตรียมโหนด Mac"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "เริ่มบริการเบื้องหลัง"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "ทำงานภายในแอปและใช้สิทธิ์ macOS ของแอป"
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "พร้อมสำหรับขั้นตอนถัดไป"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "เมื่อพร้อมแล้ว Mac เครื่องนี้จะเชื่อมต่อกับ Gateway ที่คุณเลือก"
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "Kullanım"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource, eşleşen bir systemRunPlan gerektirir"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "açık onay, eşleşen bir systemRunPlan gerektirir"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "Hazırlanıyor"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw, bu Macte arka plan hizmetini kuruyor. Bu genellikle bir dakikadan kısa sürer — Terminal yok, yönetici parolası yok."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "OpenClaw’ı yükle"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Mac düğümünü hazırlayın"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "Arka plan hizmetini başlat"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "Uygulama içinde çalışır ve uygulamanın macOS izinlerini kullanır."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "Sonraki adım için hazır"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "Hazır olduğunda bu Mac, seçtiğiniz Gateway'e bağlanır."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "Використання"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource потребує відповідного systemRunPlan"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "явне схвалення потребує відповідного systemRunPlan"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "Усе готується"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw налаштовує свою фонову службу на цьому Mac. Зазвичай це займає менше хвилини — без Terminal і без пароля адміністратора."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "Установити OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Підготуйте вузол Mac"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "Запустити фонову службу"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "Працює всередині застосунку та використовує його дозволи macOS."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "Готово до наступного кроку"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "Після підготовки цей Mac підключиться до вибраного вами Gateway."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "Mức sử dụng"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource yêu cầu systemRunPlan khớp"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "phê duyệt tường minh yêu cầu systemRunPlan khớp"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "Đang chuẩn bị"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw đang thiết lập dịch vụ nền trên máy Mac này. Việc này thường mất chưa đến một phút — không cần Terminal, không cần mật khẩu quản trị viên."
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "Cài đặt OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "Chuẩn bị nút Mac"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "Khởi động dịch vụ nền"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "Chạy bên trong ứng dụng và sử dụng các quyền macOS của ứng dụng."
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "Sẵn sàng cho bước tiếp theo"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "Sau khi sẵn sàng, máy Mac này sẽ kết nối với Gateway bạn đã chọn."
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "用量"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource requires matching systemRunPlan"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "explicit approval requires matching systemRunPlan"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "正在准备"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw 正在此 Mac 上设置其后台服务。这通常不到一分钟 — 无需 Terminal,也无需管理员密码。"
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "安装 OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "准备 Mac 节点"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "启动后台服务"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "在应用内运行,并使用其 macOS 权限。"
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "已准备好进行下一步"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "准备就绪后,此 Mac 将连接到你选择的 Gateway。"
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
+15 -15
View File
@@ -17903,16 +17903,6 @@
"source": "Usage",
"translated": "用量"
},
{
"id": "native.apple.386595fa18585dbb",
"source": "approvalSource requires matching systemRunPlan",
"translated": "approvalSource requires matching systemRunPlan"
},
{
"id": "native.apple.014ff905cecd753b",
"source": "explicit approval requires matching systemRunPlan",
"translated": "explicit approval requires matching systemRunPlan"
},
{
"id": "native.apple.7eb0a88b8274ced8",
"source": "Node pairing approved",
@@ -18518,21 +18508,26 @@
"source": "Getting things ready",
"translated": "正在準備"
},
{
"id": "native.apple.183aee003b44ede6",
"source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.",
"translated": "OpenClaw 正在此 Mac 上設定其背景服務。這通常不到一分鐘即可完成 — 不需要 Terminal,也不需要管理員密碼。"
},
{
"id": "native.apple.d0b8cf59f852b3d0",
"source": "Install OpenClaw",
"translated": "安裝 OpenClaw"
},
{
"id": "native.apple.0b0fc42b8827e01f",
"source": "Prepare the Mac node",
"translated": "準備 Mac 節點"
},
{
"id": "native.apple.0112e18fa97e7c0c",
"source": "Start the background service",
"translated": "啟動背景服務"
},
{
"id": "native.apple.9230c2a15ba6da94",
"source": "Runs inside the app and uses its macOS permissions.",
"translated": "在應用程式內執行,並使用其 macOS 權限。"
},
{
"id": "native.apple.d1f9f278b55fe9b0",
"source": "Runs quietly and starts again after a restart.",
@@ -18543,6 +18538,11 @@
"source": "Ready for the next step",
"translated": "已準備好進行下一步"
},
{
"id": "native.apple.b4ba6f183309593d",
"source": "Once ready, this Mac connects to your selected Gateway.",
"translated": "準備就緒後,這台 Mac 會連線至您選取的 Gateway。"
},
{
"id": "native.apple.9f58badc863cbf2b",
"source": "Once the service answers, youll connect your AI.",
@@ -19,12 +19,12 @@ final class CLIInstallPrompter {
private func checkAndPromptIfNeededAsync(reason: String) async {
guard AppStateStore.shared.onboardingSeen else { return }
guard AppStateStore.shared.connectionMode == .local else { return }
let connectionMode = AppStateStore.shared.connectionMode
guard Self.shouldManageCLI(connectionMode: connectionMode) else { return }
guard let version = Self.appVersion() else { return }
let status = await CLIInstaller.status()
let managedStatus = await CLIInstaller.managedStatus()
guard AppStateStore.shared.onboardingSeen else { return }
guard AppStateStore.shared.connectionMode == .local else { return }
let shouldRepairManaged = Self.shouldAutomaticallyRepair(
status: managedStatus,
launchAgentUsesManagedCLI: Self.launchAgentUsesManagedCLI(
@@ -45,7 +45,9 @@ final class CLIInstallPrompter {
if await self.installCLI(
target: .exact(version),
showCompletionAlert: false,
restartManagedGateway: !AppStateStore.shared.isPaused)
restartManagedGateway: Self.shouldRestartManagedGateway(
requested: !AppStateStore.shared.isPaused,
connectionMode: connectionMode))
{
return
}
@@ -74,7 +76,7 @@ final class CLIInstallPrompter {
guard confirmStable else { return target }
let alert = NSAlert()
alert.messageText = "Install OpenClaw CLI?"
alert.informativeText = "Local mode needs the CLI so launchd can run the Gateway."
alert.informativeText = "The Mac node needs the matching CLI runtime."
alert.addButton(withTitle: "Install CLI")
alert.addButton(withTitle: "Not Now")
alert.addButton(withTitle: "Open Settings")
@@ -119,7 +121,11 @@ final class CLIInstallPrompter {
restartManagedGateway: Bool = false) async -> Bool
{
let status = StatusBox()
let previousPID = restartManagedGateway
let usesLocalGateway = AppStateStore.shared.connectionMode == .local
let shouldRestartManagedGateway = Self.shouldRestartManagedGateway(
requested: restartManagedGateway,
connectionMode: AppStateStore.shared.connectionMode)
let previousPID = shouldRestartManagedGateway
? await GatewayLaunchAgentManager.runningGatewayPID()
: nil
let installed = await CLIInstaller.install(target: target) { message in
@@ -130,7 +136,7 @@ final class CLIInstallPrompter {
}
var activated = false
if installed {
if restartManagedGateway {
if shouldRestartManagedGateway {
let restarted = await self.ensureManagedGatewayRestarted(
previousPID: previousPID,
status: status)
@@ -143,13 +149,18 @@ final class CLIInstallPrompter {
return false
}
}
await status.set("Starting OpenClaw Gateway…")
if !showCompletionAlert {
self.logger.info("managed CLI repair: Starting OpenClaw Gateway…")
let activation: CLIInstaller.LocalGatewayActivation?
if usesLocalGateway {
await status.set("Starting OpenClaw Gateway…")
if !showCompletionAlert {
self.logger.info("managed CLI repair: Starting OpenClaw Gateway…")
}
activation = await CLIInstaller.activateLocalGateway()
} else {
activation = nil
}
let activation = await CLIInstaller.activateLocalGateway()
activated = activation != .failed
if restartManagedGateway {
if shouldRestartManagedGateway {
// Only proven gateway health closes the recovery loop; the
// on-disk CLI already reads ready, so a lost marker here means
// no later trigger would ever restart a failed gateway.
@@ -166,6 +177,8 @@ final class CLIInstallPrompter {
"OpenClaw is installed. The Gateway will start when This Mac is active and resumed."
case .failed:
"OpenClaw was installed, but the Gateway did not start. Open Settings to retry."
case nil:
"OpenClaw CLI is ready for the Mac node."
}
await status.set(message)
if !showCompletionAlert {
@@ -193,6 +206,7 @@ final class CLIInstallPrompter {
}
guard Self.launchAgentUsesManagedCLI(
programArguments: GatewayLaunchAgentManager.launchdConfigSnapshot()?.programArguments ?? []),
AppStateStore.shared.connectionMode == .local,
!GatewayLaunchAgentManager.isLaunchAgentWriteDisabled(),
!AppStateStore.shared.isPaused
else { return false }
@@ -219,6 +233,17 @@ final class CLIInstallPrompter {
UserDefaults.standard.removeObject(forKey: cliManagedRestartPendingKey)
}
static func shouldManageCLI(connectionMode: AppState.ConnectionMode) -> Bool {
connectionMode == .local || connectionMode == .remote
}
static func shouldRestartManagedGateway(
requested: Bool,
connectionMode: AppState.ConnectionMode) -> Bool
{
requested && connectionMode == .local
}
private func ensureManagedGatewayRestarted(previousPID: Int32?, status: StatusBox) async -> Bool {
guard previousPID != nil else {
await GatewayConnection.shared.shutdown()
@@ -1,5 +1,9 @@
import Foundation
extension Notification.Name {
static let openclawCLIInstalled = Notification.Name("openclaw.cli.installed")
}
enum CLIInstallBuild {
static var isDebug: Bool {
#if DEBUG
@@ -307,6 +311,7 @@ enum CLIInstaller {
let summary = installedVersion.map { "Installed openclaw \($0)." } ?? "Installed openclaw."
self.rememberInstallPolicy(target)
await statusHandler(summary)
NotificationCenter.default.post(name: .openclawCLIInstalled, object: nil)
return true
}
@@ -234,7 +234,14 @@ enum CommandResolver {
#if DEBUG
let root = projectRoot ?? self.projectRoot()
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
if FileManager().isExecutableFile(atPath: candidate) {
return candidate
}
// pnpm does not create a self-referential node_modules/.bin link for
// this package. Source builds still need the checkout CLI, not a stale
// globally installed binary with a different private command surface.
let sourceEntrypoint = root.appendingPathComponent("openclaw.mjs").path
return FileManager().isExecutableFile(atPath: sourceEntrypoint) ? sourceEntrypoint : nil
#else
return nil
#endif
@@ -1,256 +0,0 @@
import Foundation
import OpenClawProtocol
import UniformTypeIdentifiers
actor MacNodeBrowserProxy {
static let shared = MacNodeBrowserProxy()
struct Endpoint {
let baseURL: URL
let token: String?
let password: String?
}
private struct RequestParams: Decodable {
let method: String?
let path: String?
let query: [String: OpenClawProtocol.AnyCodable]?
let body: OpenClawProtocol.AnyCodable?
let timeoutMs: Int?
let profile: String?
}
private struct ProxyFilePayload {
let path: String
let base64: String
let mimeType: String?
func asJSON() -> [String: Any] {
var json: [String: Any] = [
"path": self.path,
"base64": self.base64,
]
if let mimeType = self.mimeType {
json["mimeType"] = mimeType
}
return json
}
}
private static let maxProxyFileBytes = 10 * 1024 * 1024
private let endpointProvider: @Sendable () -> Endpoint
private let performRequest: @Sendable (URLRequest) async throws -> (Data, URLResponse)
init(
session: URLSession = .shared,
endpointProvider: (@Sendable () -> Endpoint)? = nil,
performRequest: (@Sendable (URLRequest) async throws -> (Data, URLResponse))? = nil)
{
self.endpointProvider = endpointProvider ?? MacNodeBrowserProxy.defaultEndpoint
self.performRequest = performRequest ?? { request in
try await session.data(for: request)
}
}
func request(paramsJSON: String?) async throws -> String {
let params = try Self.decodeRequestParams(from: paramsJSON)
let endpoint = self.endpointProvider()
let request = try Self.makeRequest(params: params, endpoint: endpoint)
let data: Data
let response: URLResponse
do {
(data, response) = try await self.performRequest(request)
} catch {
throw Self.unavailableError(endpoint: endpoint, cause: error)
}
let http = try Self.requireHTTPResponse(response)
guard (200..<300).contains(http.statusCode) else {
throw NSError(domain: "MacNodeBrowserProxy", code: http.statusCode, userInfo: [
NSLocalizedDescriptionKey: Self.httpErrorMessage(statusCode: http.statusCode, data: data),
])
}
let result = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
let files = try Self.loadProxyFiles(from: result)
var payload: [String: Any] = ["result": result]
if !files.isEmpty {
payload["files"] = files.map { $0.asJSON() }
}
let payloadData = try JSONSerialization.data(withJSONObject: payload)
guard let payloadJSON = String(data: payloadData, encoding: .utf8) else {
throw NSError(domain: "MacNodeBrowserProxy", code: 2, userInfo: [
NSLocalizedDescriptionKey: "browser proxy returned invalid UTF-8",
])
}
return payloadJSON
}
private static func defaultEndpoint() -> Endpoint {
let config = GatewayEndpointStore.localConfig()
let controlPort = GatewayEnvironment.gatewayPort() + 2
let baseURL = URL(string: "http://127.0.0.1:\(controlPort)")!
return Endpoint(baseURL: baseURL, token: config.token, password: config.password)
}
private static func decodeRequestParams(from raw: String?) throws -> RequestParams {
guard let raw else {
throw NSError(domain: "MacNodeBrowserProxy", code: 3, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
])
}
return try JSONDecoder().decode(RequestParams.self, from: Data(raw.utf8))
}
private static func makeRequest(params: RequestParams, endpoint: Endpoint) throws -> URLRequest {
let method = (params.method ?? "GET").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
let path = (params.path ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !path.isEmpty else {
throw NSError(domain: "MacNodeBrowserProxy", code: 1, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: path required",
])
}
let normalizedPath = path.hasPrefix("/") ? path : "/\(path)"
guard var components = URLComponents(
url: endpoint.baseURL.appendingPathComponent(String(normalizedPath.dropFirst())),
resolvingAgainstBaseURL: false)
else {
throw NSError(domain: "MacNodeBrowserProxy", code: 4, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: invalid browser proxy URL",
])
}
var queryItems: [URLQueryItem] = []
if let query = params.query {
for key in query.keys.sorted() {
let value = query[key]?.value
guard value != nil, !(value is NSNull) else { continue }
queryItems.append(URLQueryItem(name: key, value: Self.stringValue(for: value)))
}
}
let profile = params.profile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !profile.isEmpty, !queryItems.contains(where: { $0.name == "profile" }) {
queryItems.append(URLQueryItem(name: "profile", value: profile))
}
if !queryItems.isEmpty {
components.queryItems = queryItems
}
guard let url = components.url else {
throw NSError(domain: "MacNodeBrowserProxy", code: 5, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: invalid browser proxy URL",
])
}
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = params.timeoutMs.map { TimeInterval(max($0, 1)) / 1000 } ?? 5
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token = endpoint.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
} else if let password = endpoint.password?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
{
request.setValue(password, forHTTPHeaderField: "x-openclaw-password")
}
if method != "GET", let body = params.body {
request.httpBody = try JSONSerialization.data(
withJSONObject: body.foundationValue,
options: [.fragmentsAllowed])
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
private static func requireHTTPResponse(_ response: URLResponse) throws -> HTTPURLResponse {
guard let http = response as? HTTPURLResponse else {
throw NSError(domain: "MacNodeBrowserProxy", code: 6, userInfo: [
NSLocalizedDescriptionKey: "browser proxy returned a non-HTTP response",
])
}
return http
}
private static func unavailableError(endpoint: Endpoint, cause: Error) -> NSError {
let url = endpoint.baseURL.absoluteString
let message = """
UNAVAILABLE: macOS app node could not reach the local browser control service at \(url). \
In remote mode, browser control is owned by the CLI node-host; start `openclaw node start` \
on this Mac and target that browser node. Underlying error: \(cause.localizedDescription)
"""
return NSError(domain: "MacNodeBrowserProxy", code: 9, userInfo: [
NSLocalizedDescriptionKey: message,
NSUnderlyingErrorKey: cause,
])
}
private static func httpErrorMessage(statusCode: Int, data: Data) -> String {
if let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? [String: Any],
let error = object["error"] as? String,
!error.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return error
}
if let text = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty
{
return text
}
return "HTTP \(statusCode)"
}
private static func stringValue(for value: Any?) -> String? {
guard let value else { return nil }
if let string = value as? String { return string }
if let bool = value as? Bool { return bool ? "true" : "false" }
if let number = value as? NSNumber { return number.stringValue }
return String(describing: value)
}
private static func loadProxyFiles(from result: Any) throws -> [ProxyFilePayload] {
let paths = self.collectProxyPaths(from: result)
return try paths.map(self.loadProxyFile)
}
private static func collectProxyPaths(from payload: Any) -> [String] {
guard let object = payload as? [String: Any] else { return [] }
var paths = Set<String>()
if let path = object["path"] as? String, !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
paths.insert(path.trimmingCharacters(in: .whitespacesAndNewlines))
}
if let imagePath = object["imagePath"] as? String,
!imagePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
paths.insert(imagePath.trimmingCharacters(in: .whitespacesAndNewlines))
}
if let download = object["download"] as? [String: Any],
let path = download["path"] as? String,
!path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
paths.insert(path.trimmingCharacters(in: .whitespacesAndNewlines))
}
return paths.sorted()
}
private static func loadProxyFile(path: String) throws -> ProxyFilePayload {
let url = URL(fileURLWithPath: path)
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
guard values.isRegularFile == true else {
throw NSError(domain: "MacNodeBrowserProxy", code: 7, userInfo: [
NSLocalizedDescriptionKey: "browser proxy file not found: \(path)",
])
}
if let fileSize = values.fileSize, fileSize > Self.maxProxyFileBytes {
throw NSError(domain: "MacNodeBrowserProxy", code: 8, userInfo: [
NSLocalizedDescriptionKey: "browser proxy file exceeds 10MB: \(path)",
])
}
let data = try Data(contentsOf: url)
let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType
return ProxyFilePayload(path: path, base64: data.base64EncodedString(), mimeType: mimeType)
}
}
@@ -1,103 +0,0 @@
import Foundation
import OpenClawKit
enum MacNodeFileSystemCommands {
private struct ListDirectoryParams: Decodable {
var path: String?
}
private struct DirectoryEntry: Encodable {
var name: String
var path: String
var hidden: Bool?
}
private struct ListDirectoryPayload: Encodable {
var path: String
var parent: String?
var home: String
var entries: [DirectoryEntry]
}
static func listDirectory(_ request: BridgeInvokeRequest) throws -> BridgeInvokeResponse {
let params: ListDirectoryParams = if let paramsJSON = request.paramsJSON {
try self.decodeParams(paramsJSON)
} else {
ListDirectoryParams(path: nil)
}
let home = FileManager.default.homeDirectoryForCurrentUser.path
let requested = params.path?.trimmingCharacters(in: .whitespacesAndNewlines)
let rawPath = requested.flatMap { $0.isEmpty ? nil : $0 } ?? home
guard NSString(string: rawPath).isAbsolutePath else {
return self.errorResponse(
request,
code: .invalidRequest,
message: "INVALID_REQUEST: fs.listDir path must be absolute")
}
let directory = URL(fileURLWithPath: rawPath, isDirectory: true).standardizedFileURL
let children = try FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.isDirectoryKey],
options: [])
var entries: [DirectoryEntry] = []
for child in children {
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: child.path, isDirectory: &isDirectory),
isDirectory.boolValue
else { continue }
let name = child.lastPathComponent
entries.append(DirectoryEntry(
name: name,
path: directory.appendingPathComponent(name, isDirectory: true).path,
hidden: name.hasPrefix(".") ? true : nil))
}
entries.sort { lhs, rhs in
if (lhs.hidden != nil) != (rhs.hidden != nil) {
return lhs.hidden == nil
}
return lhs.name.utf8.lexicographicallyPrecedes(rhs.name.utf8)
}
let parentPath = directory.deletingLastPathComponent().path
let payload = ListDirectoryPayload(
path: directory.path,
parent: parentPath == directory.path ? nil : parentPath,
home: home,
entries: entries)
return try BridgeInvokeResponse(
id: request.id,
ok: true,
payloadJSON: self.encodePayload(payload))
}
private static func decodeParams(_ paramsJSON: String) throws -> ListDirectoryParams {
guard let data = paramsJSON.data(using: .utf8) else {
throw NSError(domain: "Gateway", code: 20, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
])
}
return try JSONDecoder().decode(ListDirectoryParams.self, from: data)
}
private static func encodePayload(_ payload: ListDirectoryPayload) throws -> String {
let data = try JSONEncoder().encode(payload)
guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "Node", code: 21, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8",
])
}
return json
}
private static func errorResponse(
_ request: BridgeInvokeRequest,
code: OpenClawNodeErrorCode,
message: String) -> BridgeInvokeResponse
{
BridgeInvokeResponse(
id: request.id,
ok: false,
error: OpenClawNodeError(code: code, message: message))
}
}
@@ -0,0 +1,604 @@
import Darwin
import Foundation
import OpenClawKit
import OSLog
extension Notification.Name {
static let openclawNodeHostWorkerFailed = Notification.Name("openclaw.node-host-worker.failed")
}
struct MacNodeHostManifest: Equatable, Sendable {
let version: String
let caps: [String]
let commands: [String]
let pathEnv: String
}
protocol MacNodeHostWorking: Sendable {
func start(command: [String]) async throws -> MacNodeHostManifest
func supports(_ command: String) async -> Bool
func invoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse
func setRoute(_ route: GatewayNodeSessionRoute?, authorityGeneration: UInt64) async -> Bool
func publishInventory(ifCurrentRoute route: GatewayNodeSessionRoute) async
func stop() async
}
/// Runs the canonical TypeScript node-host runtime as an app-owned JSONL worker.
/// The worker never connects to Gateway; this app remains the sole node identity
/// and keeps TCC-sensitive execution behind the native exec-host socket.
final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable {
enum WorkerError: LocalizedError {
case unavailable(String)
var errorDescription: String? {
switch self {
case let .unavailable(message): message
}
}
}
private let logger = Logger(subsystem: "ai.openclaw", category: "node-host-worker")
private let queue = DispatchQueue(label: "ai.openclaw.node-host-worker")
private let writerQueue = DispatchQueue(label: "ai.openclaw.node-host-worker.writer")
private let session: GatewayNodeSession
private let onUnexpectedExit: @Sendable () -> Void
private var process: Process?
private var stdinPipe: Pipe?
private var stdoutPipe: Pipe?
private var stderrPipe: Pipe?
private var stdoutSource: DispatchSourceRead?
private var stderrSource: DispatchSourceRead?
private var processGeneration: UUID?
private var launchedCommand: [String]?
private var stdoutBuffer = Data()
private var manifest: MacNodeHostManifest?
private var inventoryData: Data?
private var route: GatewayNodeSessionRoute?
private var routeAuthorityGeneration: UInt64 = 0
private var startContinuation: CheckedContinuation<MacNodeHostManifest, Error>?
private var invokeContinuations: [String: CheckedContinuation<BridgeInvokeResponse, Never>] = [:]
private var startTimer: DispatchSourceTimer?
private var eventDeliveryTask: Task<Void, Never>?
private var inventoryPublicationTask: Task<Void, Never>?
private var inventoryPublicationGeneration: UInt64 = 0
private var stopping = false
init(
session: GatewayNodeSession,
onUnexpectedExit: @escaping @Sendable () -> Void = {})
{
self.session = session
self.onUnexpectedExit = onUnexpectedExit
}
func start(command: [String]) async throws -> MacNodeHostManifest {
try await withCheckedThrowingContinuation { continuation in
self.queue.async {
if let manifest = self.manifest,
self.process?.isRunning == true,
self.launchedCommand == command
{
continuation.resume(returning: manifest)
return
}
guard self.startContinuation == nil else {
continuation.resume(throwing: WorkerError.unavailable("node-host worker is already starting"))
return
}
self.startContinuation = continuation
self.startLocked(command: command)
}
}
}
func supports(_ command: String) async -> Bool {
await withCheckedContinuation { continuation in
self.queue.async {
continuation.resume(returning: self.manifest?.commands.contains(command) == true)
}
}
}
func invoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse {
await withCheckedContinuation { continuation in
self.queue.async {
guard self.process?.isRunning == true, self.manifest != nil else {
continuation.resume(returning: Self.unavailableResponse(
request.id,
"UNAVAILABLE: node-host worker is not running"))
return
}
guard self.invokeContinuations[request.id] == nil else {
continuation.resume(returning: Self.unavailableResponse(
request.id,
"UNAVAILABLE: duplicate node-host worker request"))
return
}
self.invokeContinuations[request.id] = continuation
do {
let workerRequest: [String: Any] = [
"id": request.id,
"nodeId": request.nodeId ?? "",
"command": request.command,
"paramsJSON": request.paramsJSON ?? NSNull(),
]
try self.enqueueWriteLocked([
"type": "invoke",
"request": workerRequest,
])
} catch {
self.invokeContinuations.removeValue(forKey: request.id)?.resume(returning:
Self.unavailableResponse(request.id, "UNAVAILABLE: node-host worker write failed"))
}
}
}
}
func setRoute(_ route: GatewayNodeSessionRoute?, authorityGeneration: UInt64) async -> Bool {
await withCheckedContinuation { continuation in
self.queue.async {
guard Self.routeUpdateIsCurrent(
candidateGeneration: authorityGeneration,
currentGeneration: self.routeAuthorityGeneration)
else {
continuation.resume(returning: false)
return
}
self.routeAuthorityGeneration = authorityGeneration
self.route = route
self.inventoryPublicationGeneration &+= 1
self.inventoryPublicationTask?.cancel()
self.inventoryPublicationTask = nil
self.eventDeliveryTask?.cancel()
self.eventDeliveryTask = nil
continuation.resume(returning: true)
}
}
}
nonisolated static func routeUpdateIsCurrent(
candidateGeneration: UInt64,
currentGeneration: UInt64) -> Bool
{
candidateGeneration >= currentGeneration
}
func publishInventory(ifCurrentRoute route: GatewayNodeSessionRoute) async {
let publication: Task<Void, Never>? = await withCheckedContinuation { continuation in
self.queue.async {
guard let inventoryData = self.inventoryData else {
continuation.resume(returning: nil)
return
}
continuation.resume(returning: self.scheduleInventoryPublicationLocked(
inventoryData,
route: route))
}
}
await publication?.value
}
func stop() async {
await withCheckedContinuation { continuation in
self.queue.async {
self.stopLocked(reason: "worker stopped")
continuation.resume()
}
}
}
private func startLocked(command: [String]) {
guard let executable = command.first, !executable.isEmpty else {
self.finishStartLocked(.failure(WorkerError.unavailable("node-host worker command missing")))
return
}
self.stopLocked(reason: "worker restarted", preserveStart: true)
self.stopping = false
let process = Process()
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = Array(command.dropFirst())
var environment = ProcessInfo.processInfo.environment
environment["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
environment["OPENCLAW_NODE_EXEC_HOST"] = "app"
environment["OPENCLAW_NODE_EXEC_FALLBACK"] = "0"
process.environment = environment
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
self.process = process
self.launchedCommand = command
self.stdinPipe = stdinPipe
self.stdoutPipe = stdoutPipe
self.stderrPipe = stderrPipe
let processGeneration = UUID()
self.processGeneration = processGeneration
process.terminationHandler = { [weak self] process in
guard let self else { return }
self.queue.async {
guard self.process === process else { return }
self.stopLocked(
reason: "worker exited with status \(process.terminationStatus)",
notifyUnexpectedExit: true)
}
}
let timer = DispatchSource.makeTimerSource(queue: self.queue)
timer.schedule(deadline: .now() + 20)
timer.setEventHandler { [weak self] in
guard let self else { return }
let state = self.process?.isRunning == true ? "running" : "exited"
self.finishStartLocked(.failure(WorkerError.unavailable(
"node-host worker startup timed out (process \(state), buffered \(self.stdoutBuffer.count) bytes)")))
self.stopLocked(reason: "worker startup timed out")
}
self.startTimer = timer
timer.resume()
do {
try process.run()
let stdoutSource = DispatchSource.makeReadSource(
fileDescriptor: stdoutPipe.fileHandleForReading.fileDescriptor,
queue: self.queue)
stdoutSource.setEventHandler { [weak self] in
guard let self, self.processGeneration == processGeneration else { return }
let data = Self.readAvailable(
fileDescriptor: stdoutPipe.fileHandleForReading.fileDescriptor,
byteCount: stdoutSource.data)
if data.isEmpty {
self.stdoutSource?.cancel()
} else {
self.consumeStdoutLocked(data)
}
}
self.stdoutSource = stdoutSource
stdoutSource.resume()
let stderrSource = DispatchSource.makeReadSource(
fileDescriptor: stderrPipe.fileHandleForReading.fileDescriptor,
queue: self.queue)
stderrSource.setEventHandler { [weak self] in
guard let self, self.processGeneration == processGeneration else { return }
let data = Self.readAvailable(
fileDescriptor: stderrPipe.fileHandleForReading.fileDescriptor,
byteCount: stderrSource.data)
guard !data.isEmpty else {
self.stderrSource?.cancel()
return
}
if let message = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!message.isEmpty
{
self.logger.error("node-host worker stderr: \(message, privacy: .private)")
}
}
self.stderrSource = stderrSource
stderrSource.resume()
try? stdinPipe.fileHandleForReading.close()
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForWriting.close()
} catch {
self.finishStartLocked(.failure(WorkerError.unavailable("node-host worker launch failed")))
self.stopLocked(reason: "worker launch failed")
}
}
private func consumeStdoutLocked(_ data: Data) {
self.stdoutBuffer.append(data)
guard self.stdoutBuffer.count <= 25 * 1024 * 1024 else {
self.stopLocked(reason: "worker response exceeded limit", notifyUnexpectedExit: true)
return
}
while let newline = self.stdoutBuffer.firstIndex(of: 0x0A) {
let line = self.stdoutBuffer.prefix(upTo: newline)
self.stdoutBuffer.removeSubrange(...newline)
guard !line.isEmpty,
let message = try? JSONSerialization.jsonObject(with: Data(line)) as? [String: Any]
else { continue }
self.handleMessageLocked(message)
}
}
private func handleMessageLocked(_ message: [String: Any]) {
switch message["type"] as? String {
case "ready":
guard let version = message["version"] as? String,
let rawManifest = message["manifest"] as? [String: Any],
let caps = rawManifest["caps"] as? [String],
let commands = rawManifest["commands"] as? [String],
let pathEnv = rawManifest["pathEnv"] as? String
else {
self.stopLocked(reason: "worker returned invalid manifest")
return
}
let manifest = MacNodeHostManifest(version: version, caps: caps, commands: commands, pathEnv: pathEnv)
self.manifest = manifest
self.inventoryData = (message["inventory"] as? [String: Any]).flatMap(Self.jsonData)
self.finishStartLocked(.success(manifest))
case "inventory":
guard let inventory = message["inventory"] as? [String: Any],
let inventoryData = Self.jsonData(inventory)
else { return }
self.inventoryData = inventoryData
if let route = self.route {
self.scheduleInventoryPublicationLocked(inventoryData, route: route)
}
case "invoke-result":
guard let result = message["result"] as? [String: Any],
let id = result["id"] as? String,
let continuation = self.invokeContinuations.removeValue(forKey: id)
else { return }
continuation.resume(returning: Self.decodeInvokeResponse(result, id: id))
case "node-event":
guard let event = message["event"] as? [String: Any],
let name = event["event"] as? String,
let route = self.route
else { return }
let payload = event["payloadJSON"] as? String
let previous = self.eventDeliveryTask
let session = self.session
let delivery = Task {
await previous?.value
guard !Task.isCancelled else { return }
_ = await session.sendEvent(
event: name,
payloadJSON: payload,
ifCurrentRoute: route)
}
self.eventDeliveryTask = delivery
case "gateway-request":
guard let id = message["id"] as? String,
let method = message["method"] as? String
else { return }
guard let route = self.route else {
self.writeGatewayUnavailableLocked(id: id)
return
}
guard let paramsData = Self.jsonData(message["params"] ?? [:]),
let processGeneration = self.processGeneration
else {
self.writeGatewayUnavailableLocked(id: id)
return
}
let timeoutMs = (message["timeoutMs"] as? NSNumber)?.intValue ?? 15000
Task {
await self.handleGatewayRequest(
id: id,
method: method,
paramsData: paramsData,
timeoutMs: timeoutMs,
route: route,
processGeneration: processGeneration)
}
case "protocol-error":
self.logger.error("node-host worker rejected a protocol frame")
default:
break
}
}
private func handleGatewayRequest(
id: String,
method: String,
paramsData: Data,
timeoutMs: Int,
route: GatewayNodeSessionRoute,
processGeneration: UUID) async
{
do {
guard let paramsJSON = String(bytes: paramsData, encoding: .utf8) else {
throw WorkerError.unavailable("node-host worker gateway request was not UTF-8")
}
let data = try await self.session.request(
method: method,
paramsJSON: paramsJSON,
timeoutSeconds: max(1, Int(ceil(Double(timeoutMs) / 1000.0))),
ifCurrentRoute: route,
distinguishPreDispatchRouteChange: true)
self.queue.async {
// A replacement worker restarts request ids. Never deliver an old
// route response into the replacement process.
guard self.processGeneration == processGeneration else { return }
guard let result = try? JSONSerialization.jsonObject(with: data) else { return }
try? self.enqueueWriteLocked([
"type": "gateway-response",
"id": id,
"ok": true,
"result": result,
])
}
} catch {
self.queue.async {
guard self.processGeneration == processGeneration else { return }
self.writeGatewayUnavailableLocked(id: id)
}
}
}
private func writeGatewayUnavailableLocked(id: String) {
try? self.enqueueWriteLocked([
"type": "gateway-response",
"id": id,
"ok": false,
"error": "Gateway request unavailable",
])
}
@discardableResult
private func scheduleInventoryPublicationLocked(
_ inventoryData: Data,
route: GatewayNodeSessionRoute) -> Task<Void, Never>
{
self.inventoryPublicationGeneration &+= 1
let generation = self.inventoryPublicationGeneration
let previous = self.inventoryPublicationTask
let publication = Task { [weak self] in
await previous?.value
guard let self,
!Task.isCancelled,
await self.inventoryPublicationIsCurrent(generation, route: route)
else { return }
await self.sendInventory(inventoryData, route: route)
}
self.inventoryPublicationTask = publication
return publication
}
private func inventoryPublicationIsCurrent(
_ generation: UInt64,
route: GatewayNodeSessionRoute) async -> Bool
{
await withCheckedContinuation { continuation in
self.queue.async {
continuation.resume(returning:
self.inventoryPublicationGeneration == generation && self.route == route)
}
}
}
private func sendInventory(_ inventoryData: Data, route: GatewayNodeSessionRoute) async {
guard let inventory = try? JSONSerialization.jsonObject(with: inventoryData) as? [String: Any] else { return }
if let skills = inventory["skills"], !(skills is NSNull),
let paramsJSON = Self.paramsJSON(["skills": skills])
{
_ = try? await self.session.request(
method: "node.skills.update",
paramsJSON: paramsJSON,
ifCurrentRoute: route)
}
if let tools = inventory["pluginTools"] as? [Any],
let paramsJSON = Self.paramsJSON(["tools": tools])
{
_ = try? await self.session.request(
method: "node.pluginTools.update",
paramsJSON: paramsJSON,
ifCurrentRoute: route)
}
}
private func enqueueWriteLocked(_ object: [String: Any]) throws {
guard let handle = self.stdinPipe?.fileHandleForWriting,
self.process?.isRunning == true,
let processGeneration = self.processGeneration
else {
throw WorkerError.unavailable("node-host worker is not running")
}
var data = try JSONSerialization.data(withJSONObject: object)
data.append(0x0A)
let frame = data
self.writerQueue.async { [weak self] in
do {
try handle.write(contentsOf: frame)
} catch {
self?.queue.async { [weak self] in
guard let self, self.processGeneration == processGeneration else { return }
self.stopLocked(reason: "worker input write failed", notifyUnexpectedExit: true)
}
}
}
}
private func finishStartLocked(_ result: Result<MacNodeHostManifest, Error>) {
self.startTimer?.cancel()
self.startTimer = nil
self.eventDeliveryTask?.cancel()
self.eventDeliveryTask = nil
self.inventoryPublicationGeneration &+= 1
self.inventoryPublicationTask?.cancel()
self.inventoryPublicationTask = nil
guard let continuation = self.startContinuation else { return }
self.startContinuation = nil
continuation.resume(with: result)
}
private func stopLocked(
reason: String,
preserveStart: Bool = false,
notifyUnexpectedExit: Bool = false)
{
guard !self.stopping else { return }
let wasReady = self.manifest != nil
self.stopping = true
self.startTimer?.cancel()
self.startTimer = nil
self.stdoutSource?.cancel()
self.stdoutSource = nil
self.stderrSource?.cancel()
self.stderrSource = nil
try? self.stdinPipe?.fileHandleForWriting.close()
try? self.stdinPipe?.fileHandleForReading.close()
try? self.stdoutPipe?.fileHandleForReading.close()
try? self.stdoutPipe?.fileHandleForWriting.close()
try? self.stderrPipe?.fileHandleForReading.close()
try? self.stderrPipe?.fileHandleForWriting.close()
if self.process?.isRunning == true {
self.process?.terminate()
}
self.process = nil
self.launchedCommand = nil
self.stdinPipe = nil
self.stdoutPipe = nil
self.stderrPipe = nil
self.processGeneration = nil
self.stdoutBuffer.removeAll(keepingCapacity: false)
self.manifest = nil
self.inventoryData = nil
self.route = nil
if !preserveStart {
self.finishStartLocked(.failure(WorkerError.unavailable(reason)))
}
let pending = self.invokeContinuations
self.invokeContinuations.removeAll()
for (id, continuation) in pending {
continuation.resume(returning: Self.unavailableResponse(id, "UNAVAILABLE: node-host worker stopped"))
}
if notifyUnexpectedExit, wasReady {
self.onUnexpectedExit()
}
}
private static func decodeInvokeResponse(_ result: [String: Any], id: String) -> BridgeInvokeResponse {
let ok = result["ok"] as? Bool ?? false
let payload = result["payload"].map(AnyCodable.init)
let payloadJSON = result["payloadJSON"] as? String
let rawError = result["error"] as? [String: Any]
let code = OpenClawNodeErrorCode(rawValue: rawError?["code"] as? String ?? "UNAVAILABLE") ?? .unavailable
let error = ok ? nil : OpenClawNodeError(
code: code,
message: rawError?["message"] as? String ?? "UNAVAILABLE: node-host worker failed")
return BridgeInvokeResponse(id: id, ok: ok, payload: payload, payloadJSON: payloadJSON, error: error)
}
private static func unavailableResponse(_ id: String, _ message: String) -> BridgeInvokeResponse {
BridgeInvokeResponse(
id: id,
ok: false,
error: OpenClawNodeError(code: .unavailable, message: message))
}
private static func paramsJSON(_ object: [String: Any]) -> String? {
guard let data = self.jsonData(object) else { return nil }
return String(bytes: data, encoding: .utf8)
}
private static func jsonData(_ object: Any) -> Data? {
guard JSONSerialization.isValidJSONObject(object) else { return nil }
return try? JSONSerialization.data(withJSONObject: object)
}
private static func readAvailable(fileDescriptor: Int32, byteCount: UInt) -> Data {
let count = max(1, min(Int(byteCount), 64 * 1024))
var data = Data(count: count)
let bytesRead = data.withUnsafeMutableBytes { buffer in
Darwin.read(fileDescriptor, buffer.baseAddress, count)
}
guard bytesRead > 0 else { return Data() }
data.removeSubrange(bytesRead..<data.count)
return data
}
}
@@ -103,6 +103,7 @@ final class MacNodeModeCoordinator: NSObject {
private var lastObservedComputerControlEnabled: Bool
private let runtime: MacNodeRuntime
private let session: GatewayNodeSession
private let nodeHostWorker: (any MacNodeHostWorking)?
private let presenceReporter: MacNodePresenceReporter
private let routeInvalidationHook: (@Sendable () async -> Void)?
private let refreshEvents: AsyncStream<Void>
@@ -112,11 +113,16 @@ final class MacNodeModeCoordinator: NSObject {
override private convenience init() {
let session = GatewayNodeSession()
let nodeHostWorker = MacNodeHostWorker(session: session) {
NotificationCenter.default.post(name: .openclawNodeHostWorkerFailed, object: nil)
}
self.init(
session: session,
runtime: MacNodeRuntime(
nodeHostWorker: nodeHostWorker,
canvasSurfaceUrl: { await session.currentCanvasHostUrl() },
refreshCanvasSurfaceUrl: { await session.refreshCanvasHostUrl() }),
nodeHostWorker: nodeHostWorker,
presenceReporter: MacNodePresenceReporter(),
observeNotifications: true,
initialPaused: nil,
@@ -127,6 +133,7 @@ final class MacNodeModeCoordinator: NSObject {
init(
session: GatewayNodeSession,
runtime: MacNodeRuntime,
nodeHostWorker: (any MacNodeHostWorking)? = nil,
presenceReporter: MacNodePresenceReporter = MacNodePresenceReporter(),
observeNotifications: Bool = false,
initialPaused: Bool? = nil,
@@ -136,6 +143,7 @@ final class MacNodeModeCoordinator: NSObject {
let refreshEvents = AsyncStream.makeStream(of: Void.self, bufferingPolicy: .bufferingNewest(1))
self.session = session
self.runtime = runtime
self.nodeHostWorker = nodeHostWorker
self.presenceReporter = presenceReporter
self.routeInvalidationHook = routeInvalidationHook
self.refreshEvents = refreshEvents.stream
@@ -161,6 +169,21 @@ final class MacNodeModeCoordinator: NSObject {
selector: #selector(self.refreshNodeConfiguration),
name: .openclawPermissionsChanged,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.nodeHostWorkerFailed),
name: .openclawNodeHostWorkerFailed,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.nodeHostConfigurationChanged),
name: .openclawConfigDidChange,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.nodeHostConfigurationChanged),
name: .openclawCLIInstalled,
object: nil)
}
deinit {
@@ -196,11 +219,13 @@ final class MacNodeModeCoordinator: NSObject {
func stop() {
self.cancelCoordinatorTasks()
_ = self.enqueueRouteInvalidation(yieldRefresh: false)
Task { await self.nodeHostWorker?.stop() }
}
func stopAndWait() async {
self.cancelCoordinatorTasks()
await self.enqueueRouteInvalidation(yieldRefresh: false).value
await self.nodeHostWorker?.stop()
}
private func cancelCoordinatorTasks() {
@@ -266,7 +291,10 @@ final class MacNodeModeCoordinator: NSObject {
/// Generation advances synchronously; disconnect then cancels active computer
/// invokes and runs the held-input release hook before the latest refresh wakes.
@discardableResult
private func enqueueRouteInvalidation(yieldRefresh: Bool) -> Task<Void, Never> {
private func enqueueRouteInvalidation(
yieldRefresh: Bool,
restartNodeHostWorker: Bool = false) -> Task<Void, Never>
{
self.revokeRouteAuthority()
let invalidationGeneration = self.endpointAttemptGeneration
let invalidatedRouteAuthorityGeneration = self.routeAuthorityGeneration
@@ -275,7 +303,10 @@ final class MacNodeModeCoordinator: NSObject {
await previous?.value
guard let self else { return }
await self.session.disconnect()
await self.invalidateRuntimeRoute()
await self.invalidateRuntimeRoute(authorityGeneration: invalidatedRouteAuthorityGeneration)
if restartNodeHostWorker {
await self.nodeHostWorker?.stop()
}
self.completedRouteAuthorityGeneration = invalidatedRouteAuthorityGeneration
guard yieldRefresh,
invalidationGeneration == self.endpointAttemptGeneration,
@@ -392,9 +423,9 @@ final class MacNodeModeCoordinator: NSObject {
routeRevision: routeRevision)
}
private func invalidateRuntimeRoute() async {
private func invalidateRuntimeRoute(authorityGeneration: UInt64) async {
self.presenceReporter.stop()
await self.runtime.setEventSender(nil)
_ = await self.nodeHostWorker?.setRoute(nil, authorityGeneration: authorityGeneration)
await self.runtime.releaseHeldComputerInput()
await self.routeInvalidationHook?()
}
@@ -486,7 +517,8 @@ final class MacNodeModeCoordinator: NSObject {
codexThreadCatalogEnabled: Bool,
claudeSessionCatalogEnabled: Bool) async throws -> ConnectionAttempt?
{
let caps = self.currentCaps(
let workerManifest = try await self.startNodeHostWorkerIfConfigured()
let nativeCaps = self.currentCaps(
browserControlEnabled: browserControlEnabled,
cameraEnabled: cameraEnabled,
codexThreadCatalogEnabled: codexThreadCatalogEnabled,
@@ -495,10 +527,13 @@ final class MacNodeModeCoordinator: NSObject {
// computer.act service is still holding rather than waiting for
// the idle watchdog. This refresh loop re-runs on the settings
// change that drops the cap.
if !caps.contains(OpenClawCapability.computer.rawValue) {
if !nativeCaps.contains(OpenClawCapability.computer.rawValue) {
await self.runtime.releaseHeldComputerInput()
}
let commands = self.currentCommands(caps: caps)
let caps = Self.mergingUnique(nativeCaps, workerManifest?.caps ?? [])
let commands = Self.mergingUnique(
self.currentCommands(caps: nativeCaps),
workerManifest?.commands ?? [])
let permissions = await self.currentPermissions()
// TCC queries suspend. An endpoint loss/replacement during that
// hop must not let this stale continuation install old credentials.
@@ -516,6 +551,7 @@ final class MacNodeModeCoordinator: NSObject {
scopes: [],
caps: caps,
commands: commands,
pathEnv: workerManifest?.pathEnv,
permissions: permissions,
clientId: "openclaw-macos",
clientMode: "node",
@@ -566,6 +602,12 @@ final class MacNodeModeCoordinator: NSObject {
// Capture this callback's admission before setup suspends. The
// sender lease then drops already-captured events after replacement.
guard let installedRoute = await self.session.currentRoute() else { return }
guard await self.routeAuthorityAllowsInvoke(attempt.routeAuthorityGeneration) else { return }
let workerRouteInstalled = await self.nodeHostWorker?.setRoute(
installedRoute,
authorityGeneration: attempt.routeAuthorityGeneration) ?? true
guard workerRouteInstalled else { return }
await self.nodeHostWorker?.publishInventory(ifCurrentRoute: installedRoute)
await self.cancelReconnectProbe()
self.logger.info("mac node connected to gateway")
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
@@ -573,13 +615,6 @@ final class MacNodeModeCoordinator: NSObject {
let routeStillAuthoritative = await self.routeAuthorityAllowsInvoke(attempt.routeAuthorityGeneration)
let currentRoute = await self.session.currentRoute()
guard routeStillAuthoritative, currentRoute == installedRoute else { return }
await self.runtime.setEventSender { [weak self] event, payload in
guard let self else { return }
await self.session.sendEvent(
event: event,
payloadJSON: payload,
ifCurrentRoute: installedRoute)
}
await self.presenceReporter.start { [weak self] event, payload in
guard let self else { return false }
return await self.session.sendEvent(
@@ -590,7 +625,7 @@ final class MacNodeModeCoordinator: NSObject {
},
onDisconnected: { [weak self] reason in
guard let self else { return }
await self.invalidateRuntimeRoute()
await self.invalidateRuntimeRoute(authorityGeneration: attempt.routeAuthorityGeneration)
await self.scheduleReconnectProbe()
self.logger.error("mac node disconnected: \(reason, privacy: .public)")
},
@@ -637,7 +672,7 @@ final class MacNodeModeCoordinator: NSObject {
return await self.runtime.handleInvoke(req)
},
onRouteInvalidated: { [weak self] in
await self?.invalidateRuntimeRoute()
await self?.invalidateRuntimeRoute(authorityGeneration: attempt.routeAuthorityGeneration)
})
}
@@ -730,38 +765,18 @@ final class MacNodeModeCoordinator: NSObject {
}
}
nonisolated static func resolvedCaps(
browserControlEnabled: Bool,
cameraEnabled: Bool,
computerControlEnabled: Bool,
locationMode: OpenClawLocationMode,
connectionMode: AppState.ConnectionMode,
codexThreadCatalogEnabled: Bool = false,
claudeSessionCatalogEnabled: Bool = false) -> [String]
{
var caps: [String] = [
OpenClawCapability.canvas.rawValue,
OpenClawCapability.screen.rawValue,
]
if browserControlEnabled, connectionMode == .local {
caps.append(OpenClawCapability.browser.rawValue)
@objc private nonisolated func nodeHostWorkerFailed(_: Notification) {
Task { @MainActor [weak self] in
self?.enqueueRouteInvalidation(yieldRefresh: true)
}
if cameraEnabled { caps.append(OpenClawCapability.camera.rawValue) }
// Advertised only when the operator has enabled Computer Control; the
// command is dangerous and stays disarmed until allowlisted on the gateway.
if computerControlEnabled {
caps.append(OpenClawCapability.computer.rawValue)
}
@objc private nonisolated func nodeHostConfigurationChanged(_: Notification) {
Task { @MainActor [weak self] in
// Worker code, plugin availability, and its manifest are startup-scoped.
// Replace the process before reconnecting so updates cannot leave a stale route.
self?.enqueueRouteInvalidation(yieldRefresh: true, restartNodeHostWorker: true)
}
if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) }
// A local Gateway already catalogs this user's Codex home. Advertise the
// node-owned catalog only when this Mac supplies it to a remote Gateway.
if codexThreadCatalogEnabled, connectionMode == .remote {
caps.append(MacNodeCodexThreadCatalogContract.capability)
}
if claudeSessionCatalogEnabled, connectionMode == .remote {
caps.append(MacNodeClaudeSessionCatalogContract.capability)
}
return caps
}
private func currentCaps(
@@ -788,55 +803,25 @@ final class MacNodeModeCoordinator: NSObject {
return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) })
}
nonisolated static func resolvedCommands(caps: [String]) -> [String] {
var commands: [String] = [
OpenClawCanvasCommand.present.rawValue,
OpenClawCanvasCommand.hide.rawValue,
OpenClawCanvasCommand.navigate.rawValue,
OpenClawCanvasCommand.evalJS.rawValue,
OpenClawCanvasCommand.snapshot.rawValue,
OpenClawCanvasA2UICommand.push.rawValue,
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
OpenClawCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.snapshot.rawValue,
MacNodeScreenCommand.record.rawValue,
OpenClawSystemCommand.notify.rawValue,
OpenClawSystemCommand.which.rawValue,
OpenClawSystemCommand.run.rawValue,
OpenClawSystemCommand.execApprovalsGet.rawValue,
OpenClawSystemCommand.execApprovalsSet.rawValue,
OpenClawFileSystemCommand.listDir.rawValue,
]
let capsSet = Set(caps)
if capsSet.contains(OpenClawCapability.browser.rawValue) {
commands.append(OpenClawBrowserCommand.proxy.rawValue)
}
if capsSet.contains(OpenClawCapability.camera.rawValue) {
commands.append(OpenClawCameraCommand.list.rawValue)
commands.append(OpenClawCameraCommand.snap.rawValue)
commands.append(OpenClawCameraCommand.clip.rawValue)
}
if capsSet.contains(OpenClawCapability.location.rawValue) {
commands.append(OpenClawLocationCommand.get.rawValue)
}
if capsSet.contains(MacNodeCodexThreadCatalogContract.capability) {
commands.append(contentsOf: MacNodeCodexThreadCatalogContract.commands)
}
if capsSet.contains(MacNodeClaudeSessionCatalogContract.capability) {
commands.append(contentsOf: MacNodeClaudeSessionCatalogContract.commands)
}
if capsSet.contains(OpenClawCapability.computer.rawValue) {
commands.append(OpenClawComputerCommand.act.rawValue)
}
return commands
}
private func currentCommands(caps: [String]) -> [String] {
Self.resolvedCommands(caps: caps)
}
private func startNodeHostWorkerIfConfigured() async throws -> MacNodeHostManifest? {
guard let nodeHostWorker else { return nil }
let executable: String
if let projectExecutable = CommandResolver.projectOpenClawExecutable() {
executable = projectExecutable
} else {
switch await CLIInstaller.status() {
case let .ready(location, _): executable = location
case let status:
throw MacNodeHostWorker.WorkerError.unavailable(status.message)
}
}
return try await nodeHostWorker.start(command: [executable, "node", "worker"])
}
nonisolated static func tlsPinStoreKey(for url: URL) -> String {
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "gateway"
let port = url.port ?? 443
@@ -915,3 +900,79 @@ final class MacNodeModeCoordinator: NSObject {
return self.tlsSessionCache.sessionBox(url: url, params: params)
}
}
extension MacNodeModeCoordinator {
nonisolated static func resolvedCaps(
browserControlEnabled: Bool,
cameraEnabled: Bool,
computerControlEnabled: Bool,
locationMode: OpenClawLocationMode,
connectionMode: AppState.ConnectionMode,
codexThreadCatalogEnabled: Bool = false,
claudeSessionCatalogEnabled: Bool = false) -> [String]
{
var caps: [String] = [
OpenClawCapability.canvas.rawValue,
OpenClawCapability.screen.rawValue,
]
_ = browserControlEnabled
if cameraEnabled { caps.append(OpenClawCapability.camera.rawValue) }
// Advertised only when the operator has enabled Computer Control; the
// command is dangerous and stays disarmed until allowlisted on the gateway.
if computerControlEnabled {
caps.append(OpenClawCapability.computer.rawValue)
}
if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) }
// A local Gateway already catalogs this user's Codex home. Advertise the
// node-owned catalog only when this Mac supplies it to a remote Gateway.
if codexThreadCatalogEnabled, connectionMode == .remote {
caps.append(MacNodeCodexThreadCatalogContract.capability)
}
if claudeSessionCatalogEnabled, connectionMode == .remote {
caps.append(MacNodeClaudeSessionCatalogContract.capability)
}
return caps
}
nonisolated static func resolvedCommands(caps: [String]) -> [String] {
var commands: [String] = [
OpenClawCanvasCommand.present.rawValue,
OpenClawCanvasCommand.hide.rawValue,
OpenClawCanvasCommand.navigate.rawValue,
OpenClawCanvasCommand.evalJS.rawValue,
OpenClawCanvasCommand.snapshot.rawValue,
OpenClawCanvasA2UICommand.push.rawValue,
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
OpenClawCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.snapshot.rawValue,
MacNodeScreenCommand.record.rawValue,
OpenClawSystemCommand.notify.rawValue,
]
let capsSet = Set(caps)
if capsSet.contains(OpenClawCapability.camera.rawValue) {
commands.append(OpenClawCameraCommand.list.rawValue)
commands.append(OpenClawCameraCommand.snap.rawValue)
commands.append(OpenClawCameraCommand.clip.rawValue)
}
if capsSet.contains(OpenClawCapability.location.rawValue) {
commands.append(OpenClawLocationCommand.get.rawValue)
}
if capsSet.contains(MacNodeCodexThreadCatalogContract.capability) {
commands.append(contentsOf: MacNodeCodexThreadCatalogContract.commands)
}
if capsSet.contains(MacNodeClaudeSessionCatalogContract.capability) {
commands.append(contentsOf: MacNodeClaudeSessionCatalogContract.commands)
}
if capsSet.contains(OpenClawCapability.computer.rawValue) {
commands.append(OpenClawComputerCommand.act.rawValue)
}
return commands
}
nonisolated static func mergingUnique(_ primary: [String], _ additional: [String]) -> [String] {
var seen = Set<String>()
return (primary + additional).filter { seen.insert($0).inserted }
}
}
@@ -6,20 +6,9 @@ import OpenClawKit
actor MacNodeRuntime {
private static let maxGatewayPayloadBytes = 25 * 1024 * 1024
private static let maxScreenSnapshotRawBytesBeforeBase64 = (maxGatewayPayloadBytes / 4) * 3
private struct ExecApprovalsNodeSnapshot: Encodable {
let path: String
let exists: Bool
let hash: String
let file: ExecApprovalsFile
let resolvedDefaults: ExecApprovalsResolvedDefaults
}
private let cameraCapture = CameraCaptureService()
private let nodeHostWorker: (any MacNodeHostWorking)?
private let makeMainActorServices: @Sendable () async -> any MacNodeRuntimeMainActorServices
private let browserProxyRequest: @Sendable (String?) async throws -> String
/// Injectable so tests can pin the gate instead of racing on process-global
/// OPENCLAW_CONFIG_PATH; config parsing is covered by OpenClawConfigFileTests.
private let browserControlEnabled: @Sendable () -> Bool
// Injectable so tests pin the gate instead of racing on process-global UserDefaults.
private let computerControlEnabled: @Sendable () -> Bool
private let canvasHostedSurfaceResolver: MacNodeCanvasHostedSurfaceResolver
@@ -29,12 +18,6 @@ actor MacNodeRuntime {
private let claudeSessionCatalogEnabled: @Sendable () -> Bool
private let claudeSessionListRequest: @Sendable (String?) async throws -> String
private let claudeSessionReadRequest: @Sendable (String?) async throws -> String
private let execApprovalStoreMutations: ExecApprovalStoreMutations
private let shellRunner: @Sendable (
_ command: [String],
_ cwd: String?,
_ env: [String: String]?,
_ timeout: Double?) async -> ShellExecutor.ShellResult
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
/// Single-flight lazy initialization. Separate service instances would split
/// ownership of held computer input and make lifecycle release incomplete.
@@ -43,18 +26,12 @@ actor MacNodeRuntime {
/// the first action while the shared main-actor services are still initializing.
private var computerInputReleaseGeneration: UInt64 = 0
private var mainSessionKey: String = "main"
private var eventSender: (@Sendable (String, String?) async -> Void)?
init(
nodeHostWorker: (any MacNodeHostWorking)? = nil,
makeMainActorServices: @escaping @Sendable () async -> any MacNodeRuntimeMainActorServices = {
await MainActor.run { LiveMacNodeRuntimeMainActorServices() }
},
browserProxyRequest: @escaping @Sendable (String?) async throws -> String = { paramsJSON in
try await MacNodeBrowserProxy.shared.request(paramsJSON: paramsJSON)
},
browserControlEnabled: @escaping @Sendable () -> Bool = {
OpenClawConfigFile.browserControlEnabled()
},
computerControlEnabled: @escaping @Sendable () -> Bool = {
MacNodeRuntime.computerControlEnabledDefault()
},
@@ -79,19 +56,10 @@ actor MacNodeRuntime {
},
claudeSessionReadRequest: @escaping @Sendable (String?) async throws -> String = { paramsJSON in
try MacNodeClaudeSessionCatalog.read(paramsJSON: paramsJSON)
},
execApprovalStoreMutations: ExecApprovalStoreMutations = .live,
shellRunner: @escaping @Sendable (
_ command: [String],
_ cwd: String?,
_ env: [String: String]?,
_ timeout: Double?) async -> ShellExecutor.ShellResult = { command, cwd, env, timeout in
await ShellExecutor.runDetailed(command: command, cwd: cwd, env: env, timeout: timeout)
})
{
self.nodeHostWorker = nodeHostWorker
self.makeMainActorServices = makeMainActorServices
self.browserProxyRequest = browserProxyRequest
self.browserControlEnabled = browserControlEnabled
self.computerControlEnabled = computerControlEnabled
self.canvasHostedSurfaceResolver = MacNodeCanvasHostedSurfaceResolver(
currentSurfaceURL: canvasSurfaceUrl,
@@ -102,8 +70,6 @@ actor MacNodeRuntime {
self.claudeSessionCatalogEnabled = claudeSessionCatalogEnabled
self.claudeSessionListRequest = claudeSessionListRequest
self.claudeSessionReadRequest = claudeSessionReadRequest
self.execApprovalStoreMutations = execApprovalStoreMutations
self.shellRunner = shellRunner
}
func updateMainSessionKey(_ sessionKey: String) {
@@ -112,14 +78,12 @@ actor MacNodeRuntime {
self.mainSessionKey = trimmed
}
func setEventSender(_ sender: (@Sendable (String, String?) async -> Void)?) {
self.eventSender = sender
}
// One branch per advertised node command keeps command ownership explicit.
// swiftlint:disable:next cyclomatic_complexity
/// One branch per advertised native command keeps command ownership explicit.
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command
if let nodeHostWorker, await nodeHostWorker.supports(command) {
return await nodeHostWorker.invoke(req)
}
if self.isCanvasCommand(command), !Self.canvasEnabled() {
return BridgeInvokeResponse(
id: req.id,
@@ -140,8 +104,6 @@ actor MacNodeRuntime {
OpenClawCanvasA2UICommand.push.rawValue,
OpenClawCanvasA2UICommand.pushJSONL.rawValue:
return try await self.handleA2UIInvoke(req)
case OpenClawBrowserCommand.proxy.rawValue:
return try await self.handleBrowserProxyInvoke(req)
case OpenClawCameraCommand.snap.rawValue,
OpenClawCameraCommand.clip.rawValue,
OpenClawCameraCommand.list.rawValue:
@@ -154,18 +116,8 @@ actor MacNodeRuntime {
return try await self.handleScreenRecordInvoke(req)
case OpenClawComputerCommand.act.rawValue:
return try await self.handleComputerActInvoke(req)
case OpenClawSystemCommand.run.rawValue:
return try await self.handleSystemRun(req)
case OpenClawSystemCommand.which.rawValue:
return try await self.handleSystemWhich(req)
case OpenClawSystemCommand.notify.rawValue:
return try await self.handleSystemNotify(req)
case OpenClawSystemCommand.execApprovalsGet.rawValue:
return try await self.handleSystemExecApprovalsGet(req)
case OpenClawSystemCommand.execApprovalsSet.rawValue:
return try await self.handleSystemExecApprovalsSet(req)
case OpenClawFileSystemCommand.listDir.rawValue:
return try MacNodeFileSystemCommands.listDirectory(req)
case MacNodeCodexThreadCatalogContract.listCommand,
MacNodeCodexThreadCatalogContract.turnsCommand:
return try await self.handleCodexThreadInvoke(req)
@@ -320,19 +272,6 @@ extension MacNodeRuntime {
Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
}
}
private func handleBrowserProxyInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
guard self.browserControlEnabled() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "BROWSER_DISABLED: enable Browser in Settings"))
}
let payloadJSON = try await browserProxyRequest(req.paramsJSON)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payloadJSON)
}
}
// MARK: - Device command handling
@@ -786,534 +725,9 @@ extension MacNodeRuntime {
}
}
// MARK: - System commands
// MARK: - Native system notifications
extension MacNodeRuntime {
private struct SystemRunPreparation {
let params: OpenClawSystemRunParams
let approvalSource: ExecApprovalRequestSource?
let validatedCommand: ExecHostValidatedRequest
let evaluation: ExecApprovalEvaluation
let security: ExecSecurity
let delayedPolicySnapshot: ExecApprovalPolicySnapshot?
let sessionKey: String
let runId: String
}
private enum SystemRunPreparationResult {
case prepared(SystemRunPreparation)
case response(BridgeInvokeResponse)
}
private func handleSystemRun(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let prepared: SystemRunPreparation
switch try await self.prepareSystemRun(req) {
case let .prepared(result):
prepared = result
case let .response(response):
return response
}
let params = prepared.params
let approvalSource = prepared.approvalSource
let command = prepared.validatedCommand.command
let evaluation = prepared.evaluation
let security = prepared.security
let sessionKey = prepared.sessionKey
let runId = prepared.runId
let approvedByAsk: Bool
let persistAllowlist: Bool
if approvalSource == .askFallback {
approvedByAsk = false
persistAllowlist = false
} else if approvalSource == .autoReview {
approvedByAsk = true
persistAllowlist = false
} else {
let approval = await self.resolveSystemRunApproval(
req: req,
params: params,
context: ExecRunContext(
displayCommand: evaluation.displayCommand,
security: evaluation.security,
ask: evaluation.ask,
agentId: evaluation.agentId,
resolution: evaluation.resolution,
allowlistMatch: evaluation.allowlistMatch,
skillAllow: evaluation.skillAllow,
allowAlwaysEligible: evaluation.canPersistAllowAlways,
sessionKey: sessionKey,
runId: runId))
if let response = approval.response {
return response
}
approvedByAsk = approval.approvedByAsk
persistAllowlist = approval.persistAllowlist
}
if security == .allowlist,
evaluation.authorizationBasis == nil,
!approvedByAsk
{
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: evaluation.displayCommand,
reason: "allowlist-miss"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: allowlist miss")
}
let reusableAuthorization = security == .allowlist &&
!approvedByAsk &&
evaluation.authorizationBasis != nil
let executionCommand: [String]
if reusableAuthorization {
guard let boundCommand = evaluation.boundCommand else {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: evaluation.displayCommand,
reason: "allowlist-unbound"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: reusable approval could not bind executable")
}
executionCommand = boundCommand
} else {
executionCommand = command
}
if let permissionResponse = await self.validateScreenRecordingIfNeeded(
req: req,
needsScreenRecording: params.needsScreenRecording,
sessionKey: sessionKey,
runId: runId,
displayCommand: evaluation.displayCommand)
{
return permissionResponse
}
let executionCommit = ExecApprovalExecutionCommit.build(
context: evaluation,
effectiveSecurity: security,
approvalSource: approvalSource,
explicitlyApproved: approvedByAsk,
persistAllowlist: persistAllowlist,
delayedPolicySnapshot: prepared.delayedPolicySnapshot)
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
let cwd = params.cwd
let executionEnv = evaluation.env
let shellRunner = self.shellRunner
if case .failure = self.execApprovalStoreMutations.commitExecution(executionCommit) {
return await self.execApprovalMutationFailure(
req: req,
sessionKey: sessionKey,
runId: runId,
displayCommand: evaluation.displayCommand)
}
// The locked store commit is the authorization linearization point.
// Enqueue execution synchronously next; later revocations govern later commits.
let execution = Task.detached {
await shellRunner(executionCommand, cwd, executionEnv, timeoutSec)
}
return try await self.completeSystemRun(
req: req,
sessionKey: sessionKey,
runId: runId,
displayCommand: evaluation.displayCommand,
execution: execution)
}
private func prepareSystemRun(_ req: BridgeInvokeRequest) async throws -> SystemRunPreparationResult {
let params = try Self.decodeParams(OpenClawSystemRunParams.self, from: req.paramsJSON)
let approvalSource: ExecApprovalRequestSource?
switch params.approvalSource {
case nil:
approvalSource = nil
case "ask-fallback":
approvalSource = .askFallback
case "auto-review":
approvalSource = .autoReview
default:
return .response(
Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: approvalSource invalid"))
}
if approvalSource != nil, params.approved != nil || params.approvalDecision != nil {
return .response(
Self.errorResponse(
req,
code: .invalidRequest,
message: "INVALID_REQUEST: approvalSource cannot be combined with explicit approval"))
}
let explicitDecision = ExecApprovalHelpers.parseDecision(params.approvalDecision)
let explicitApproval = params.approved == true ||
explicitDecision == .allowOnce ||
explicitDecision == .allowAlways
let validatedCommand: ExecHostValidatedRequest
switch ExecHostRequestEvaluator.validateCommand(
command: params.command,
rawCommand: params.rawCommand)
{
case let .success(resolved):
validatedCommand = resolved
case let .failure(error):
let message = error.message.hasPrefix("INVALID_REQUEST:")
? error.message
: "INVALID_REQUEST: \(error.message)"
return .response(Self.errorResponse(req, code: .invalidRequest, message: message))
}
if approvalSource != nil || explicitApproval {
guard let plan = params.systemRunPlan,
MacSystemRunApprovalPlanValidator.matches(
plan,
params: params,
validatedCommand: validatedCommand)
else {
let message = approvalSource != nil
? "approvalSource requires matching systemRunPlan"
: "explicit approval requires matching systemRunPlan"
return .response(Self.errorResponse(req, code: .invalidRequest, message: message))
}
}
let carriesDelayedAuthority = approvalSource == .autoReview || explicitApproval
let delayedPolicySnapshot: ExecApprovalPolicySnapshot?
if carriesDelayedAuthority {
if let operand = params.systemRunPlan?.mutableFileOperand,
!MacSystemRunApprovalPlanValidator.revalidateMutableFileOperand(
operand,
command: validatedCommand.command,
cwd: params.cwd)
{
return .response(
Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: approval script operand changed before execution"))
}
guard let policySnapshot = params.systemRunPlan?.policySnapshot else {
return .response(
Self.errorResponse(
req,
code: .invalidRequest,
message: "INVALID_REQUEST: delayed approval requires a prepared policy snapshot"))
}
delayedPolicySnapshot = ExecApprovalPolicySnapshot(portable: policySnapshot)
} else {
delayedPolicySnapshot = nil
}
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
: self.mainSessionKey
let providedRunId = params.runId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let runId = providedRunId.isEmpty ? UUID().uuidString : providedRunId
let envOverrideDiagnostics = HostEnvSanitizer.inspectOverrides(
overrides: params.env,
blockPathOverrides: true)
if !envOverrideDiagnostics.blockedKeys.isEmpty || !envOverrideDiagnostics.invalidKeys.isEmpty {
var details: [String] = []
if !envOverrideDiagnostics.blockedKeys.isEmpty {
details.append("blocked override keys: \(envOverrideDiagnostics.blockedKeys.joined(separator: ", "))")
}
if !envOverrideDiagnostics.invalidKeys.isEmpty {
details.append(
"invalid non-portable override keys: \(envOverrideDiagnostics.invalidKeys.joined(separator: ", "))")
}
return .response(
Self.errorResponse(
req,
code: .invalidRequest,
message: "SYSTEM_RUN_DENIED: environment override rejected (\(details.joined(separator: "; ")))"))
}
let evaluation = await ExecApprovalEvaluator.evaluate(
command: validatedCommand.command,
rawCommand: validatedCommand.evaluationRawCommand,
displayCommand: validatedCommand.displayCommand,
cwd: params.cwd,
envOverrides: params.env,
agentId: params.agentId)
let security = approvalSource == .askFallback
? ExecSecurity.narrower(evaluation.security, evaluation.askFallback)
: evaluation.security
if security == .deny {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: evaluation.displayCommand,
reason: "security=deny"))
return .response(
Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DISABLED: security=deny"))
}
if approvalSource == .autoReview, evaluation.ask == .always {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: evaluation.displayCommand,
reason: "ask=always"))
return .response(
Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: auto-review cannot bypass ask=always"))
}
return .prepared(SystemRunPreparation(
params: params,
approvalSource: approvalSource,
validatedCommand: validatedCommand,
evaluation: evaluation,
security: security,
delayedPolicySnapshot: delayedPolicySnapshot,
sessionKey: sessionKey,
runId: runId))
}
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(OpenClawSystemWhichParams.self, from: req.paramsJSON)
let bins = params.bins
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !bins.isEmpty else {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: bins required")
}
let searchPaths = CommandResolver.preferredPaths()
var matches: [String] = []
var paths: [String: String] = [:]
for bin in bins {
if let path = CommandResolver.findExecutable(named: bin, searchPaths: searchPaths) {
matches.append(bin)
paths[bin] = path
}
}
struct WhichPayload: Encodable {
let bins: [String]
let paths: [String: String]
}
let payload = try Self.encodePayload(WhichPayload(bins: matches, paths: paths))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private struct ExecApprovalOutcome {
var approvedByAsk: Bool
var persistAllowlist: Bool
var response: BridgeInvokeResponse?
}
private struct ExecRunContext {
var displayCommand: String
var security: ExecSecurity
var ask: ExecAsk
var agentId: String?
var resolution: ExecCommandResolution?
var allowlistMatch: ExecAllowlistEntry?
var skillAllow: Bool
var allowAlwaysEligible: Bool
var sessionKey: String
var runId: String
}
private func resolveSystemRunApproval(
req: BridgeInvokeRequest,
params: OpenClawSystemRunParams,
context: ExecRunContext) async -> ExecApprovalOutcome
{
let requiresAsk = ExecApprovalHelpers.requiresAsk(
ask: context.ask,
security: context.security,
allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow)
let decisionFromParams = ExecApprovalHelpers.parseDecision(params.approvalDecision)
var approvedByAsk = params.approved == true || decisionFromParams != nil
var persistAllowlist = decisionFromParams == .allowAlways
if decisionFromParams == .deny {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: context.sessionKey,
runId: context.runId,
host: "node",
command: context.displayCommand,
reason: "user-denied"))
return ExecApprovalOutcome(
approvedByAsk: approvedByAsk,
persistAllowlist: persistAllowlist,
response: Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied"))
}
if requiresAsk, !approvedByAsk {
let promptDecision = await ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: context.displayCommand,
cwd: params.cwd,
host: "node",
security: context.security.rawValue,
ask: context.ask.rawValue,
agentId: context.agentId,
resolvedPath: context.resolution?.resolvedPath,
sessionKey: context.sessionKey,
allowedDecisions: ExecApprovalPromptRequest.allowedDecisions(
forAsk: context.ask.rawValue,
allowAlwaysEligible: context.allowAlwaysEligible)))
guard let decision = promptDecision else {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: context.sessionKey,
runId: context.runId,
host: "node",
command: context.displayCommand,
reason: "approval-cancelled"))
return ExecApprovalOutcome(
approvedByAsk: approvedByAsk,
persistAllowlist: persistAllowlist,
response: Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: approval prompt closed without decision"))
}
switch decision {
case .deny:
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: context.sessionKey,
runId: context.runId,
host: "node",
command: context.displayCommand,
reason: "user-denied"))
return ExecApprovalOutcome(
approvedByAsk: approvedByAsk,
persistAllowlist: persistAllowlist,
response: Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied"))
case .allowAlways:
approvedByAsk = true
persistAllowlist = true
case .allowOnce:
approvedByAsk = true
}
}
return ExecApprovalOutcome(
approvedByAsk: approvedByAsk,
persistAllowlist: persistAllowlist,
response: nil)
}
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
struct GetParams: Decodable {
var includeResolvedDefaults: Bool?
}
let params = try req.paramsJSON.map { json in
try Self.decodeParams(GetParams.self, from: json)
} ?? GetParams(includeResolvedDefaults: nil)
let snapshot: ExecApprovalsSnapshot
switch ExecApprovalsStore.ensureSnapshotResult() {
case let .success(current):
snapshot = current
case .failure:
return Self.errorResponse(
req,
code: .unavailable,
message: "UNAVAILABLE: exec approvals store unavailable; retry")
}
guard params.includeResolvedDefaults == true else {
let redacted = ExecApprovalsSnapshot(
path: snapshot.path,
exists: snapshot.exists,
hash: snapshot.hash,
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
let payload = try Self.encodePayload(redacted)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
let redacted = ExecApprovalsNodeSnapshot(
path: snapshot.path,
exists: snapshot.exists,
hash: snapshot.hash,
file: ExecApprovalsStore.redactForSnapshot(snapshot.file),
resolvedDefaults: ExecApprovalsStore.resolveDefaults(from: snapshot.file))
let payload = try Self.encodePayload(redacted)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
struct SetParams: Decodable {
var file: ExecApprovalsFile
var baseHash: String?
}
let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON)
switch ExecApprovalsStore.saveFile(params.file, ifBaseHash: params.baseHash) {
case let .saved(snapshot):
let redacted = ExecApprovalsSnapshot(
path: snapshot.path,
exists: snapshot.exists,
hash: snapshot.hash,
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
let payload = try Self.encodePayload(redacted)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case .baseHashUnavailable:
return Self.errorResponse(
req,
code: .invalidRequest,
message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry")
case .baseHashRequired:
return Self.errorResponse(
req,
code: .invalidRequest,
message: "INVALID_REQUEST: exec approvals base hash required; reload and retry")
case .conflict:
return Self.errorResponse(
req,
code: .invalidRequest,
message: "INVALID_REQUEST: exec approvals changed; reload and retry")
case .unavailable:
return Self.errorResponse(
req,
code: .unavailable,
message: "UNAVAILABLE: exec approvals update lock unavailable; retry")
}
}
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
guard let sender = eventSender else { return }
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else {
return
}
await sender(event, json)
}
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(OpenClawSystemNotifyParams.self, from: req.paramsJSON)
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1354,104 +768,9 @@ extension MacNodeRuntime {
}
}
// MARK: - System command support
// MARK: - Shared command support
extension MacNodeRuntime {
private func execApprovalMutationFailure(
req: BridgeInvokeRequest,
sessionKey: String,
runId: String,
displayCommand: String) async -> BridgeInvokeResponse
{
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "approval-store-unavailable"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: exec approvals update unavailable")
}
private func validateScreenRecordingIfNeeded(
req: BridgeInvokeRequest,
needsScreenRecording: Bool?,
sessionKey: String,
runId: String,
displayCommand: String) async -> BridgeInvokeResponse?
{
guard needsScreenRecording == true else { return nil }
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if authorized { return nil }
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "permission:screenRecording"))
return Self.errorResponse(
req,
code: .unavailable,
message: "PERMISSION_MISSING: screenRecording")
}
private func completeSystemRun(
req: BridgeInvokeRequest,
sessionKey: String,
runId: String,
displayCommand: String,
execution: Task<ShellExecutor.ShellResult, Never>) async throws -> BridgeInvokeResponse
{
await self.emitExecEvent(
"exec.started",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand))
let result = await execution.value
let combined = [result.stdout, result.stderr, result.errorMessage]
.compactMap(\.self)
.filter { !$0.isEmpty }
.joined(separator: "\n")
await self.emitExecEvent(
"exec.finished",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
output: ExecEventPayload.truncateOutput(combined)))
struct RunPayload: Encodable {
var exitCode: Int?
var timedOut: Bool
var success: Bool
var stdout: String
var stderr: String
var error: String?
}
let runPayload = RunPayload(
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
stdout: result.stdout,
stderr: result.stderr,
error: result.errorMessage)
let payload = try Self.encodePayload(runPayload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
guard let json, let data = json.data(using: .utf8) else {
throw NSError(domain: "Gateway", code: 20, userInfo: [
+9 -5
View File
@@ -642,10 +642,10 @@ struct OnboardingView: View {
{
switch mode {
case .remote:
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
// but the AI check runs against the remote gateway so a broken
// remote model surfaces here, not in the first chat.
return [0, 1, 3, 5, 9]
// Remote mode skips local Gateway/workspace setup, but its Mac node
// still runs the matching CLI node-host runtime inside the app.
let setupPages = requiresCLIInstall ? [0, 1, 2, 3, 5] : [0, 1, 3, 5]
return setupPages + [9]
case .unconfigured:
return [0, 1, 9]
case .local:
@@ -654,6 +654,10 @@ struct OnboardingView: View {
}
}
static func shouldActivateLocalGateway(afterCLIInstallFor mode: AppState.ConnectionMode) -> Bool {
mode == .local
}
var selectedConnectionMode: AppState.ConnectionMode {
if self.isConnectionSelectionBlocking {
return .local
@@ -668,7 +672,7 @@ struct OnboardingView: View {
var pageOrder: [Int] {
Self.pageOrder(
for: self.state.connectionMode,
requiresCLIInstall: self.state.connectionMode == .local && !self.cliInstalled)
requiresCLIInstall: !self.cliInstalled)
}
var pageCount: Int {
@@ -163,6 +163,11 @@ extension OnboardingView {
guard installed else { return }
cliExecutableReady = true
cliInstallLocation = CLIInstaller.managedExecutableLocation()
if !Self.shouldActivateLocalGateway(afterCLIInstallFor: self.state.connectionMode) {
cliStatus = "OpenClaw CLI is ready for the Mac node."
cliInstalled = true
return
}
cliStatus = "Starting OpenClaw Gateway…"
// The step checklist shows one spinner at a time: install first,
// then the service start.
@@ -748,12 +748,18 @@ extension OnboardingView {
}
func cliPage() -> some View {
onboardingPage {
let remoteMode = self.state.connectionMode == .remote
let detail = if remoteMode {
"OpenClaw is installing the matching runtime for this Mac node. " +
"It will connect to your selected Gateway without starting another one here."
} else {
"OpenClaw is setting up its background service on this Mac. " +
"This usually takes under a minute — no Terminal, no administrator password."
}
return onboardingPage {
Text("Getting things ready")
.font(.largeTitle.weight(.semibold))
Text(
"OpenClaw is setting up its background service on this Mac. " +
"This usually takes under a minute — no Terminal, no administrator password.")
Text(detail)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -769,12 +775,16 @@ extension OnboardingView {
state: self.installStepStateForInstall,
monospacedDetail: self.cliInstalled && self.cliInstallLocation != nil)
self.installStepRow(
title: "Start the background service",
detail: "Runs quietly and starts again after a restart.",
title: remoteMode ? "Prepare the Mac node" : "Start the background service",
detail: remoteMode
? "Runs inside the app and uses its macOS permissions."
: "Runs quietly and starts again after a restart.",
state: self.installStepStateForService)
self.installStepRow(
title: "Ready for the next step",
detail: "Once the service answers, youll connect your AI.",
detail: remoteMode
? "Once ready, this Mac connects to your selected Gateway."
: "Once the service answers, youll connect your AI.",
state: self.cliInstalled ? .done : .pending)
if self.installFailed {
@@ -40,6 +40,25 @@ import Testing
#expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"]))
}
@Test func `source checkout entrypoint wins when package bin link is absent`() throws {
let defaults = self.makeLocalDefaults()
let tmp = try makeTempDirForTests()
let sourceEntrypoint = tmp.appendingPathComponent("openclaw.mjs")
let staleGlobalBin = tmp.appendingPathComponent("global/bin")
try makeExecutableForTests(at: sourceEntrypoint)
try makeExecutableForTests(at: staleGlobalBin.appendingPathComponent("openclaw"))
let cmd = CommandResolver.openclawCommand(
subcommand: "node",
extraArgs: ["worker"],
defaults: defaults,
configRoot: [:],
searchPaths: [staleGlobalBin.path],
projectRoot: tmp)
#expect(cmd == [sourceEntrypoint.path, "node", "worker"])
}
@Test func `falls back to node and script`() throws {
let defaults = self.makeLocalDefaults()
@@ -6,45 +6,6 @@ import Testing
@Suite(.serialized)
struct ExecApprovalsStoreRefactorTests {
private actor ShellRunProbe {
private var commands: [[String]] = []
func run(_ command: [String]) -> ShellExecutor.ShellResult {
self.commands.append(command)
return ShellExecutor.ShellResult(
stdout: "ok",
stderr: "",
exitCode: 0,
timedOut: false,
success: true,
errorMessage: nil)
}
func capturedCommands() -> [[String]] {
self.commands
}
}
private static func forwardedApprovalPlan(
command: [String],
rawCommand: String? = nil,
agentId: String? = "main",
policySnapshot: ExecApprovalPolicySnapshot? = nil,
mutableFileOperand: OpenClawSystemRunApprovalFileOperand? = nil) -> OpenClawSystemRunApprovalPlan
{
let snapshot = policySnapshot ?? ExecApprovalPolicySnapshot(
resolved: ExecApprovalsStore.resolve(agentId: agentId))
return OpenClawSystemRunApprovalPlan(
argv: command,
cwd: nil,
commandText: ExecCommandFormatter.displayString(for: command),
commandPreview: rawCommand,
agentId: agentId,
sessionKey: nil,
policySnapshot: snapshot.portable,
mutableFileOperand: mutableFileOperand)
}
private var realTemporaryDirectory: URL {
let path = FileManager().temporaryDirectory.path
if path.hasPrefix("/var/") {
@@ -552,890 +513,6 @@ struct ExecApprovalsStoreRefactorTests {
}
}
@Test
func `native approvals get reports held sidecar lock as unavailable`() async throws {
try await self.withTempStateDir { stateDir in
let lockURL = stateDir.appendingPathComponent("exec-approvals.json.lock")
try Data("held".utf8).write(to: lockURL)
let response = await MacNodeRuntime().handleInvoke(BridgeInvokeRequest(
id: "approvals-get-held-lock",
command: OpenClawSystemCommand.execApprovalsGet.rawValue))
#expect(!response.ok)
#expect(response.error?.code == .unavailable)
#expect(response.error?.message == "UNAVAILABLE: exec approvals store unavailable; retry")
#expect(FileManager().fileExists(atPath: lockURL.path))
}
}
@Test
func `native approvals get reports malformed store as unavailable`() async throws {
try await self.withTempStateDir { _ in
let url = ExecApprovalsStore.fileURL()
let malformed = Data("{".utf8)
try malformed.write(to: url, options: [.atomic])
let response = await MacNodeRuntime().handleInvoke(BridgeInvokeRequest(
id: "approvals-get-malformed",
command: OpenClawSystemCommand.execApprovalsGet.rawValue))
#expect(!response.ok)
#expect(response.error?.code == .unavailable)
#expect(response.error?.message == "UNAVAILABLE: exec approvals store unavailable; retry")
#expect(try Data(contentsOf: url) == malformed)
}
}
@Test
func `allow always atomic commit failure denies without persisting or executing`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .allowlist
entry.ask = .off
}.get()
let probe = ShellRunProbe()
let mutations = ExecApprovalStoreMutations(
commitExecution: { _ in .failure(.unavailable) })
let runtime = MacNodeRuntime(
execApprovalStoreMutations: mutations,
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/usr/bin/printf", "ok"],
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
approvalDecision: "allow-always")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "persist-failure",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
#expect(await probe.capturedCommands().isEmpty)
#expect(ExecApprovalsStore.resolve(agentId: "main").allowlist.isEmpty)
}
}
@Test
func `allowlist usage failure denies before shell execution`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .allowlist
entry.ask = .off
entry.allowlist = [ExecAllowlistEntry(pattern: "/usr/bin/printf")]
}.get()
let probe = ShellRunProbe()
let mutations = ExecApprovalStoreMutations(
commitExecution: { _ in .failure(.unavailable) })
let runtime = MacNodeRuntime(
execApprovalStoreMutations: mutations,
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(command: ["/usr/bin/printf", "ok"], agentId: "main")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "usage-failure",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `native runtime revalidates unprompted full policy before shell execution`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .full
entry.ask = .off
}.get()
let probe = ShellRunProbe()
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .deny
}
return ExecApprovalsStore.commitExecution(commit)
})
let runtime = MacNodeRuntime(
execApprovalStoreMutations: mutations,
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/usr/bin/printf", "ok"],
agentId: "main")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "full-policy-race",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `native runtime rejects ask tightening before shell execution`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .full
entry.ask = .off
}.get()
let probe = ShellRunProbe()
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.ask = .onMiss
}
return ExecApprovalsStore.commitExecution(commit)
})
let runtime = MacNodeRuntime(
execApprovalStoreMutations: mutations,
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/usr/bin/printf", "ok"],
agentId: "main")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "ask-policy-race",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `native runtime rejects explicit once when security tightens to allowlist`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .full
entry.ask = .always
}.get()
let probe = ShellRunProbe()
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .allowlist
}
return ExecApprovalsStore.commitExecution(commit)
})
let runtime = MacNodeRuntime(
execApprovalStoreMutations: mutations,
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/usr/bin/printf", "ok"],
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
approvalDecision: "allow-once")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "explicit-once-security-race",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `native runtime rejects explicit once after allowlist revocation`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .allowlist
entry.ask = .onMiss
entry.allowlist = [ExecAllowlistEntry(pattern: "/usr/bin/echo")]
}.get()
let probe = ShellRunProbe()
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.allowlist = []
}
return ExecApprovalsStore.commitExecution(commit)
})
let runtime = MacNodeRuntime(
execApprovalStoreMutations: mutations,
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/usr/bin/printf", "ok"],
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
approvalDecision: "allow-once")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "explicit-once-allowlist-race",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `native runtime rejects auto review when security tightens to allowlist`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .full
entry.ask = .onMiss
}.get()
let probe = ShellRunProbe()
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .allowlist
}
return ExecApprovalsStore.commitExecution(commit)
})
let runtime = MacNodeRuntime(
execApprovalStoreMutations: mutations,
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/usr/bin/printf", "ok"],
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
approvalSource: "auto-review")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "auto-review-security-race",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `native runtime rejects auto review when ask tightens from off to on miss`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .full
entry.ask = .off
}.get()
let probe = ShellRunProbe()
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.ask = .onMiss
}
return ExecApprovalsStore.commitExecution(commit)
})
let runtime = MacNodeRuntime(
execApprovalStoreMutations: mutations,
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/usr/bin/printf", "ok"],
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
approvalSource: "auto-review")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "auto-review-ask-tightening",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `native runtime treats a successful commit as the authorization linearization point`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .full
entry.ask = .off
}.get()
let probe = ShellRunProbe()
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
let result = ExecApprovalsStore.commitExecution(commit)
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .deny
}
return result
})
let runtime = MacNodeRuntime(
execApprovalStoreMutations: mutations,
shellRunner: { command, _, _, _ in await probe.run(command) })
let command = ["/usr/bin/printf", "ok"]
let params = OpenClawSystemRunParams(command: command, agentId: "main")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "authorization-linearization",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(response.ok)
#expect(await probe.capturedCommands() == [command])
#expect(ExecApprovalsStore.resolve(agentId: "main").agent.security == .deny)
}
}
@Test
func `native runtime requires the prepared snapshot for forwarded delayed authority`() async throws {
try await self.withTempStateDir { _ in
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let command = ["/usr/bin/printf", "ok"]
let planWithoutSnapshot = OpenClawSystemRunApprovalPlan(
argv: command,
cwd: nil,
commandText: ExecCommandFormatter.displayString(for: command),
agentId: "main",
sessionKey: nil)
let cases = [
OpenClawSystemRunParams(
command: command,
agentId: "main",
systemRunPlan: planWithoutSnapshot,
approvalDecision: "allow-once"),
OpenClawSystemRunParams(
command: command,
agentId: "main",
systemRunPlan: planWithoutSnapshot,
approvalSource: "auto-review"),
OpenClawSystemRunParams(
command: command,
agentId: "main",
systemRunPlan: planWithoutSnapshot,
approved: true),
]
for (index, params) in cases.enumerated() {
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "missing-delayed-snapshot-\(index)",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.code == .invalidRequest)
#expect(response.error?.message.contains("prepared policy snapshot") == true)
}
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `native runtime rejects forwarded delayed authority for a mismatched plan`() async throws {
try await self.withTempStateDir { _ in
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let command = ["/usr/bin/printf", "ok"]
let params = OpenClawSystemRunParams(
command: command,
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "different"]),
approvalDecision: "allow-once")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "mismatched-forwarded-plan",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.code == .invalidRequest)
#expect(response.error?.message.contains("matching systemRunPlan") == true)
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `forwarded node session approval cannot restore a rule revoked before Mac evaluation`() async throws {
try await self.withTempStateDir { _ in
let stale = ExecAllowlistEntry(
pattern: "/usr/bin/echo",
source: "allow-always")
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .allowlist
entry.ask = .onMiss
entry.allowlist = [stale]
}.get()
let forwardedSnapshot = ExecApprovalPolicySnapshot(
resolved: ExecApprovalsStore.resolve(agentId: "main"))
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.allowlist = []
}.get()
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let command = ["/usr/bin/printf", "ok"]
let params = OpenClawSystemRunParams(
command: command,
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(
command: command,
policySnapshot: forwardedSnapshot),
approvalDecision: "allow-always")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "revoked-node-session-approval",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
#expect(await probe.capturedCommands().isEmpty)
#expect(ExecApprovalsStore.resolve(agentId: "main").allowlist.isEmpty)
}
}
@Test
func `forwarded node session approval rejects a changed mutable script operand`() async throws {
try await self.withTempStateDir { stateDir in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .full
entry.ask = .off
}.get()
let scriptURL = stateDir.appendingPathComponent("script.py")
let approvedData = Data("print('approved')\n".utf8)
try approvedData.write(to: scriptURL)
let approvedHash = SHA256.hash(data: approvedData)
.map { String(format: "%02x", $0) }
.joined()
let command = ["/usr/bin/python3", scriptURL.path]
let operand = OpenClawSystemRunApprovalFileOperand(
argvIndex: 1,
path: scriptURL.resolvingSymlinksInPath().path,
sha256: approvedHash)
try Data("print('changed')\n".utf8).write(to: scriptURL)
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: command,
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(
command: command,
mutableFileOperand: operand),
approvalDecision: "allow-once")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "changed-script-operand",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("script operand changed") == true)
#expect(await probe.capturedCommands().isEmpty)
try approvedData.write(to: scriptURL)
let restoredResponse = await runtime.handleInvoke(BridgeInvokeRequest(
id: "restored-script-operand",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(restoredResponse.ok)
#expect(await probe.capturedCommands() == [command])
}
}
@Test
func `native runtime cannot persist allow always after concurrent policy revocation`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .allowlist
entry.ask = .onMiss
}.get()
let probe = ShellRunProbe()
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .deny
}
return ExecApprovalsStore.commitExecution(commit)
})
let runtime = MacNodeRuntime(
execApprovalStoreMutations: mutations,
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/usr/bin/printf", "ok"],
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
approvalDecision: "allow-always")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "allow-always-policy-race",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(await probe.capturedCommands().isEmpty)
#expect(ExecApprovalsStore.resolve(agentId: "main").allowlist.isEmpty)
}
}
@Test
func `revoked gateway timeout fallback denies before native shell execution`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .full
entry.ask = .always
entry.askFallback = .deny
}.get()
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/usr/bin/printf", "ok"],
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
approvalSource: "ask-fallback")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "revoked-timeout-fallback",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message == "SYSTEM_RUN_DISABLED: security=deny")
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `marker only full timeout fallback executes without a second prompt`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .full
entry.ask = .always
entry.askFallback = .full
}.get()
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let command = ["/usr/bin/printf", "ok"]
let params = OpenClawSystemRunParams(
command: command,
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: command),
approvalSource: "ask-fallback")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "full-timeout-fallback",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(response.ok)
#expect(await probe.capturedCommands() == [command])
}
}
@Test
func `timeout fallback rejects explicit approval fields`() async throws {
try await self.withTempStateDir { _ in
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/usr/bin/printf", "ok"],
agentId: "main",
approvalDecision: "allow-once",
approvalSource: "ask-fallback")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "mixed-timeout-fallback",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("cannot be combined") == true)
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `auto review marker executes one shot allowlist miss`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .allowlist
entry.ask = .onMiss
}.get()
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let command = ["/usr/bin/printf", "ok"]
let params = OpenClawSystemRunParams(
command: command,
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: command),
approvalSource: "auto-review")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "auto-review-once",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(response.ok)
#expect(await probe.capturedCommands() == [command])
#expect(ExecApprovalsStore.resolve(agentId: "main").allowlist.isEmpty)
}
}
@Test
func `auto review marker cannot bypass ask always`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .full
entry.ask = .always
}.get()
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/usr/bin/printf", "ok"],
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
approvalSource: "auto-review")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "auto-review-ask-always",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("ask=always") == true)
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `auto review revalidates concurrent ask always before shell execution`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .allowlist
entry.ask = .onMiss
}.get()
let probe = ShellRunProbe()
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.ask = .always
}
return ExecApprovalsStore.commitExecution(commit)
})
let runtime = MacNodeRuntime(
execApprovalStoreMutations: mutations,
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/usr/bin/printf", "ok"],
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
approvalSource: "auto-review")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "auto-review-ask-race",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `auto review marker preserves reviewed strict inline execution`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .allowlist
entry.ask = .onMiss
}.get()
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let payload = "/usr/bin/printf reviewed"
let command = ["/bin/sh", "-c", payload]
let params = OpenClawSystemRunParams(
command: command,
rawCommand: payload,
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: command, rawCommand: payload),
approvalSource: "auto-review")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "auto-review-inline",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(response.ok)
#expect(await probe.capturedCommands() == [command])
}
}
@Test
func `auto review marker rejects legacy approval fields`() async throws {
try await self.withTempStateDir { _ in
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/usr/bin/printf", "ok"],
agentId: "main",
approved: true,
approvalSource: "auto-review")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "mixed-auto-review",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("cannot be combined") == true)
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `allowlist timeout fallback executes the canonical bound command`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .full
entry.ask = .always
entry.askFallback = .allowlist
entry.allowlist = [ExecAllowlistEntry(pattern: "/usr/bin/printf")]
}.get()
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["printf", "ok"],
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: ["printf", "ok"]),
approvalSource: "ask-fallback")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "allowlist-timeout-fallback",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(response.ok)
#expect(await probe.capturedCommands() == [["/usr/bin/printf", "ok"]])
}
}
@Test
func `allowlist timeout fallback miss denies without shell execution`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .full
entry.ask = .always
entry.askFallback = .allowlist
entry.allowlist = [ExecAllowlistEntry(pattern: "/usr/bin/printf")]
}.get()
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/bin/echo", "miss"],
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: ["/bin/echo", "miss"]),
approvalSource: "ask-fallback")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "allowlist-timeout-fallback-miss",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.message.contains("allowlist miss") == true)
#expect(await probe.capturedCommands().isEmpty)
}
}
@Test
func `explicit allow once executes original unbound shell command`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .allowlist
entry.ask = .onMiss
entry.allowlist = [ExecAllowlistEntry(pattern: "/usr/bin/printf")]
}.get()
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let payload = "/usr/bin/printf one && /usr/bin/printf two"
let original = ["/bin/sh", "-c", payload]
let params = OpenClawSystemRunParams(
command: original,
rawCommand: payload,
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: original, rawCommand: payload),
approvalDecision: "allow-once")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "allow-once",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(response.ok)
#expect(await probe.capturedCommands() == [original])
}
}
@Test
func `allow always executes unbound shell command once without persisting inner grants`() async throws {
try await self.withTempStateDir { _ in
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
entry.security = .allowlist
entry.ask = .onMiss
}.get()
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let payload = "/usr/bin/printf one && /usr/bin/printf two"
let original = ["/bin/sh", "-c", payload]
let params = OpenClawSystemRunParams(
command: original,
rawCommand: payload,
agentId: "main",
systemRunPlan: Self.forwardedApprovalPlan(command: original, rawCommand: payload),
approvalDecision: "allow-always")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "allow-always-unbound",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(response.ok)
#expect(await probe.capturedCommands() == [original])
#expect(ExecApprovalsStore.resolve(agentId: "main").allowlist.isEmpty)
}
}
@Test
func `native rewrites preserve non-sensitive metadata and arbitrary ids`() async throws {
try await self.withTempStateDir { _ in
@@ -2052,7 +1129,6 @@ struct ExecApprovalsStoreRefactorTests {
}
}
}
extension ExecApprovalsStoreRefactorTests {
@Test
func `ensure file migrates default approvals into custom state dir`() async throws {
@@ -244,6 +244,39 @@ struct GatewayChannelConnectTests {
#expect(params["maxProtocol"] as? Int == GATEWAY_PROTOCOL_VERSION)
}
@Test func `node connect advertises worker path environment`() async throws {
let recorder = ConnectParamsRecorder()
let session = GatewayTestWebSocketSession(
taskFactory: {
GatewayTestWebSocketTask(
sendHook: { _, message, sendIndex in
guard sendIndex == 0 else { return }
recorder.record(message)
})
})
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: ["system"],
commands: ["system.run"],
pathEnv: "/opt/homebrew/bin:/usr/bin:/bin",
permissions: [:],
clientId: "openclaw-macos",
clientMode: "node",
clientDisplayName: "macOS Test",
includeDeviceIdentity: false)
let channel = try GatewayChannelActor(
url: #require(URL(string: "ws://example.invalid")),
token: nil,
session: WebSocketSessionBox(session: session),
connectOptions: options)
try await channel.connect()
let params = try #require(recorder.snapshot())
#expect(params["pathEnv"] as? String == "/opt/homebrew/bin:/usr/bin:/bin")
}
@Test func `concurrent connect shares failure`() async throws {
let session = self.makeSession(response: .invalid(delayMs: 200))
let channel = try GatewayChannelActor(
@@ -1,110 +0,0 @@
import Foundation
import Testing
@testable import OpenClaw
struct MacNodeBrowserProxyTests {
@Test func `request uses browser control endpoint and wraps result`() async throws {
let proxy = MacNodeBrowserProxy(
endpointProvider: {
MacNodeBrowserProxy.Endpoint(
baseURL: URL(string: "http://127.0.0.1:18791")!,
token: "test-token",
password: nil)
},
performRequest: { request in
#expect(request.url?.absoluteString == "http://127.0.0.1:18791/tabs?profile=work")
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
let body = Data(#"{"tabs":[{"id":"tab-1"}]}"#.utf8)
let url = try #require(request.url)
let response = try #require(
HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: ["Content-Type": "application/json"]))
return (body, response)
})
let payloadJSON = try await proxy.request(
paramsJSON: #"{"method":"GET","path":"/tabs","profile":"work"}"#)
let payload = try #require(
JSONSerialization.jsonObject(with: Data(payloadJSON.utf8)) as? [String: Any])
let result = try #require(payload["result"] as? [String: Any])
let tabs = try #require(result["tabs"] as? [[String: Any]])
#expect(payload["files"] == nil)
#expect(tabs.count == 1)
#expect(tabs[0]["id"] as? String == "tab-1")
}
/// Regression test: nested POST bodies must serialize without __SwiftValue crashes.
@Test func `post request serializes nested body without crash`() async throws {
actor BodyCapture {
private var body: Data?
func set(_ body: Data?) {
self.body = body
}
func get() -> Data? {
self.body
}
}
let capturedBody = BodyCapture()
let proxy = MacNodeBrowserProxy(
endpointProvider: {
MacNodeBrowserProxy.Endpoint(
baseURL: URL(string: "http://127.0.0.1:18791")!,
token: nil,
password: nil)
},
performRequest: { request in
await capturedBody.set(request.httpBody)
let url = try #require(request.url)
let response = try #require(
HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: nil))
return (Data(#"{"ok":true}"#.utf8), response)
})
_ = try await proxy.request(
paramsJSON: #"{"method":"POST","path":"/action","body":{"nested":{"key":"val"},"arr":[1,2]}}"#)
let bodyData = try #require(await capturedBody.get())
let parsed = try #require(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
let nested = try #require(parsed["nested"] as? [String: Any])
#expect(nested["key"] as? String == "val")
let arr = try #require(parsed["arr"] as? [Any])
#expect(arr.count == 2)
}
@Test func `request reports actionable unavailable when control service is missing`() async throws {
let proxy = MacNodeBrowserProxy(
endpointProvider: {
MacNodeBrowserProxy.Endpoint(
baseURL: URL(string: "http://127.0.0.1:18791")!,
token: nil,
password: nil)
},
performRequest: { _ in
throw URLError(.cannotConnectToHost)
})
do {
_ = try await proxy.request(paramsJSON: #"{"method":"GET","path":"/"}"#)
Issue.record("request should fail when browser control is unreachable")
} catch {
let message = error.localizedDescription
#expect(message.contains("UNAVAILABLE: macOS app node could not reach the local browser control service"))
#expect(message.contains("http://127.0.0.1:18791"))
#expect(message.contains("browser control is owned by the CLI node-host"))
#expect(message.contains("openclaw node start"))
}
}
}
@@ -0,0 +1,161 @@
import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
private struct WorkerBackpressureTimeout: Error {}
private actor StubMacNodeHostWorker: MacNodeHostWorking {
let manifest = MacNodeHostManifest(
version: "test",
caps: ["system", "mcp"],
commands: ["system.run", "mcp.tools.call.v1"],
pathEnv: "/usr/bin:/bin")
private var requests: [BridgeInvokeRequest] = []
func start(command _: [String]) async throws -> MacNodeHostManifest { self.manifest }
func supports(_ command: String) async -> Bool { self.manifest.commands.contains(command) }
func invoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse {
self.requests.append(request)
return BridgeInvokeResponse(id: request.id, ok: true, payloadJSON: #"{"owner":"cli"}"#)
}
func setRoute(_: GatewayNodeSessionRoute?, authorityGeneration _: UInt64) async -> Bool { true }
func publishInventory(ifCurrentRoute _: GatewayNodeSessionRoute) async {}
func stop() async {}
func invokedCommands() -> [String] { self.requests.map(\.command) }
}
@Suite(.serialized)
struct MacNodeHostWorkerTests {
@Test func `Mac runtime forwards CLI node commands to the shared worker`() async {
let worker = StubMacNodeHostWorker()
let runtime = MacNodeRuntime(nodeHostWorker: worker)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "worker-run",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: #"{"command":["/usr/bin/true"]}"#))
#expect(response.ok)
#expect(response.payloadJSON == #"{"owner":"cli"}"#)
#expect(await worker.invokedCommands() == [OpenClawSystemCommand.run.rawValue])
}
@Test func `capability union preserves native order and adds worker commands once`() {
#expect(MacNodeModeCoordinator.mergingUnique(
["canvas", "screen", "system"],
["system", "mcp"]) == ["canvas", "screen", "system", "mcp"])
}
@Test func `stale route updates cannot replace newer worker authority`() {
#expect(MacNodeHostWorker.routeUpdateIsCurrent(candidateGeneration: 4, currentGeneration: 4))
#expect(MacNodeHostWorker.routeUpdateIsCurrent(candidateGeneration: 5, currentGeneration: 4))
#expect(!MacNodeHostWorker.routeUpdateIsCurrent(candidateGeneration: 3, currentGeneration: 4))
}
@Test func `worker forces app exec host without fallback`() async throws {
let worker = MacNodeHostWorker(session: GatewayNodeSession())
let script = """
test "$OPENCLAW_NODE_EXEC_HOST" = app || exit 42
test "$OPENCLAW_NODE_EXEC_FALLBACK" = 0 || exit 43
printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}'
printf '%s\\n' '{"type":"gateway-request","id":"gateway-1","method":"skills.bins","params":{},"timeoutMs":1000}'
IFS= read -r unavailable
printf '%s' "$unavailable" | grep -q '"type":"gateway-response"' || exit 44
printf '%s' "$unavailable" | grep -q '"ok":false' || exit 45
while IFS= read -r line; do
case "$line" in
*'"type":"invoke"'*) printf '%s\\n' '{"type":"invoke-result","result":{"id":"worker-run","ok":true,"payload":{"owner":"cli"}}}' ;;
esac
done
"""
let manifest = try await worker.start(command: ["/bin/sh", "-c", script])
#expect(manifest.commands == ["system.run"])
let response = await worker.invoke(BridgeInvokeRequest(
id: "worker-run",
command: "system.run",
paramsJSON: #"{"command":["/usr/bin/true"]}"#))
#expect(response.ok)
#expect(response.payload != nil)
await worker.stop()
}
@Test func `ready worker exit notifies its route owner`() async throws {
try await confirmation("unexpected worker exit") { confirmed in
let worker = MacNodeHostWorker(session: GatewayNodeSession()) {
confirmed()
}
let script = """
printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}'
sleep 0.05
exit 7
"""
_ = try await worker.start(command: ["/bin/sh", "-c", script])
try? await Task.sleep(for: .milliseconds(200))
}
}
@Test func `changed worker command replaces the running process`() async throws {
let worker = MacNodeHostWorker(session: GatewayNodeSession())
let firstScript = """
printf '%s\\n' '{"type":"ready","version":"first","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}'
while IFS= read -r line; do :; done
"""
let secondScript = """
printf '%s\\n' '{"type":"ready","version":"second","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}'
while IFS= read -r line; do :; done
"""
let first = try await worker.start(command: ["/bin/sh", "-c", firstScript])
let second = try await worker.start(command: ["/bin/sh", "-c", secondScript])
#expect(first.version == "first")
#expect(second.version == "second")
await worker.stop()
}
@Test func `worker drains stdout while a large stdin frame is backpressured`() async throws {
let worker = MacNodeHostWorker(session: GatewayNodeSession())
let script = """
printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}'
IFS= read -r first
printf '{"type":"invoke-result","result":{"id":"first","ok":true,"payload":{"blob":"'
head -c 2097152 /dev/zero | tr '\\000' x
printf '"}}}\\n'
IFS= read -r second
printf '%s\\n' '{"type":"invoke-result","result":{"id":"second","ok":true,"payload":{"done":true}}}'
"""
_ = try await worker.start(command: ["/bin/sh", "-c", script])
let first = Task {
await worker.invoke(BridgeInvokeRequest(
id: "first",
command: "system.run",
paramsJSON: #"{"command":["/usr/bin/true"]}"#))
}
try await Task.sleep(for: .milliseconds(20))
let largeParams = #"{"blob":""# + String(repeating: "x", count: 2 * 1024 * 1024) + #""}"#
let second = Task {
await worker.invoke(BridgeInvokeRequest(
id: "second",
command: "system.run",
paramsJSON: largeParams))
}
do {
let responses = try await AsyncTimeout.withTimeout(
seconds: 5,
onTimeout: { WorkerBackpressureTimeout() },
operation: { [first, second] in [await first.value, await second.value] })
await worker.stop()
#expect(responses.allSatisfy { $0.ok })
} catch {
await worker.stop()
throw error
}
}
}
@@ -99,6 +99,24 @@ private actor CoordinatorDrainSnapshotProbe {
}
}
private actor CoordinatorNodeHostWorkerProbe: MacNodeHostWorking {
private var stopCount = 0
func start(command _: [String]) async throws -> MacNodeHostManifest {
MacNodeHostManifest(version: "test", caps: [], commands: [], pathEnv: "/usr/bin:/bin")
}
func supports(_: String) async -> Bool { false }
func invoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse {
BridgeInvokeResponse(id: request.id, ok: false)
}
func setRoute(_: GatewayNodeSessionRoute?, authorityGeneration _: UInt64) async -> Bool { true }
func publishInventory(ifCurrentRoute _: GatewayNodeSessionRoute) async {}
func stop() async { self.stopCount += 1 }
func stops() -> Int { self.stopCount }
}
struct MacNodeModeCoordinatorTests {
private func waitUntil(
_ description: String,
@@ -125,6 +143,29 @@ struct MacNodeModeCoordinatorTests {
currentGeneration: 8))
}
@Test @MainActor func `config and CLI changes restart startup scoped node host worker`() async throws {
let worker = CoordinatorNodeHostWorkerProbe()
let session = GatewayNodeSession()
let coordinator = MacNodeModeCoordinator(
session: session,
runtime: MacNodeRuntime(nodeHostWorker: worker),
nodeHostWorker: worker,
observeNotifications: true)
_ = coordinator
NotificationCenter.default.post(name: .openclawConfigDidChange, object: nil)
try await self.waitUntil("node-host worker restart") {
await worker.stops() == 1
}
NotificationCenter.default.post(name: .openclawCLIInstalled, object: nil)
try await self.waitUntil("node-host worker restart") {
await worker.stops() == 2
}
}
@Test func `paused node state requires route disconnect`() {
#expect(MacNodeModeCoordinator.pausedStateRequiresDisconnect(true))
#expect(!MacNodeModeCoordinator.pausedStateRequiresDisconnect(false))
@@ -420,7 +461,7 @@ struct MacNodeModeCoordinatorTests {
isExistingInstallation: false) == .primary)
}
@Test func `remote mode does not advertise browser proxy`() {
@Test func `native manifest excludes CLI-owned node commands`() {
let caps = MacNodeModeCoordinator.resolvedCaps(
browserControlEnabled: true,
cameraEnabled: false,
@@ -433,10 +474,11 @@ struct MacNodeModeCoordinatorTests {
#expect(!commands.contains(OpenClawBrowserCommand.proxy.rawValue))
#expect(commands.contains(OpenClawCanvasCommand.present.rawValue))
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
#expect(commands.contains(OpenClawFileSystemCommand.listDir.rawValue))
#expect(!commands.contains(OpenClawFileSystemCommand.listDir.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.run.rawValue))
}
@Test func `local mode advertises browser proxy when enabled`() {
@Test func `local native manifest leaves browser proxy to the CLI worker`() {
let caps = MacNodeModeCoordinator.resolvedCaps(
browserControlEnabled: true,
cameraEnabled: false,
@@ -445,8 +487,8 @@ struct MacNodeModeCoordinatorTests {
connectionMode: .local)
let commands = MacNodeModeCoordinator.resolvedCommands(caps: caps)
#expect(caps.contains(OpenClawCapability.browser.rawValue))
#expect(commands.contains(OpenClawBrowserCommand.proxy.rawValue))
#expect(!caps.contains(OpenClawCapability.browser.rawValue))
#expect(!commands.contains(OpenClawBrowserCommand.proxy.rawValue))
}
@Test func `local mode omits native session catalogs`() {
@@ -61,37 +61,6 @@ struct MacNodeRuntimeTests {
}
}
actor ExecEventProbe {
private var captured: [(event: String, json: String)] = []
func append(event: String, json: String?) {
self.captured.append((event: event, json: json ?? ""))
}
func events() -> [(event: String, json: String)] {
self.captured
}
}
actor ShellRunProbe {
private var commands: [[String]] = []
func run(_ command: [String]) -> ShellExecutor.ShellResult {
self.commands.append(command)
return ShellExecutor.ShellResult(
stdout: "",
stderr: "",
exitCode: 0,
timedOut: false,
success: true,
errorMessage: nil)
}
func capturedCommands() -> [[String]] {
self.commands
}
}
@MainActor
final class ScreenSnapshotProbeServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
var snapshotCallCount = 0
@@ -183,69 +152,6 @@ struct MacNodeRuntimeTests {
#expect(response.ok == false)
}
@Test func `file system list directory returns only sorted directories`() async throws {
struct Entry: Decodable, Equatable {
var name: String
var path: String
var hidden: Bool?
}
struct Payload: Decodable {
var path: String
var parent: String?
var home: String
var entries: [Entry]
}
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("MacNodeRuntimeTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let projects = root.appendingPathComponent("Projects", isDirectory: true)
let hidden = root.appendingPathComponent(".hidden", isDirectory: true)
try FileManager.default.createDirectory(at: projects, withIntermediateDirectories: false)
try FileManager.default.createDirectory(at: hidden, withIntermediateDirectories: false)
try Data("not a directory".utf8).write(to: root.appendingPathComponent("notes.txt"))
let paramsJSON = try String(
decoding: JSONEncoder().encode(["path": root.path]),
as: UTF8.self)
let response = await MacNodeRuntime().handleInvoke(BridgeInvokeRequest(
id: "req-fs-list-dir",
command: OpenClawFileSystemCommand.listDir.rawValue,
paramsJSON: paramsJSON))
let payloadJSON = try #require(response.payloadJSON)
let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8))
#expect(response.ok)
#expect(payload.path == root.path)
#expect(payload.parent == root.deletingLastPathComponent().path)
#expect(payload.home == FileManager.default.homeDirectoryForCurrentUser.path)
let listedRoot = URL(fileURLWithPath: payload.path, isDirectory: true)
#expect(payload.entries == [
Entry(
name: "Projects",
path: listedRoot.appendingPathComponent("Projects", isDirectory: true).path,
hidden: nil),
Entry(
name: ".hidden",
path: listedRoot.appendingPathComponent(".hidden", isDirectory: true).path,
hidden: true),
])
}
@Test func `file system list directory rejects relative paths`() async throws {
let paramsJSON = try String(
decoding: JSONEncoder().encode(["path": "Projects"]),
as: UTF8.self)
let response = await MacNodeRuntime().handleInvoke(BridgeInvokeRequest(
id: "req-fs-list-dir-relative",
command: OpenClawFileSystemCommand.listDir.rawValue,
paramsJSON: paramsJSON))
#expect(!response.ok)
#expect(response.error?.code == .invalidRequest)
}
@Test func `handle invoke returns injected Codex thread catalog`() async {
let payload = #"{"sessions":[]}"#
let runtime = MacNodeRuntime(
@@ -376,214 +282,6 @@ struct MacNodeRuntimeTests {
#expect(await probe.calls == 1)
}
@Test func `handle invoke rejects empty system run`() async throws {
let runtime = MacNodeRuntime()
let params = OpenClawSystemRunParams(command: [])
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-2", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
#expect(response.ok == false)
}
@Test func `system run rejects raw command prompt spoof before execution`() async throws {
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/usr/bin/printf", "unsafe"],
rawCommand: "echo safe")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "req-raw-command-spoof",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.code == .invalidRequest)
#expect(response.error?.message.contains("rawCommand does not match command") == true)
#expect(await probe.capturedCommands().isEmpty)
}
@Test func `system run rejects mismatched shell payload preview before execution`() async throws {
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(
command: ["/bin/sh", "-lc", "/usr/bin/printf unsafe"],
rawCommand: "echo safe")
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "req-shell-preview-spoof",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.code == .invalidRequest)
#expect(response.error?.message.contains("rawCommand does not match command") == true)
#expect(await probe.capturedCommands().isEmpty)
}
@Test func `system run shares padded executable rejection with socket host`() async throws {
let probe = ShellRunProbe()
let runtime = MacNodeRuntime(
shellRunner: { command, _, _, _ in await probe.run(command) })
let params = OpenClawSystemRunParams(command: [" /usr/bin/printf ", "unsafe"])
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(BridgeInvokeRequest(
id: "req-padded-executable",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(!response.ok)
#expect(response.error?.code == .invalidRequest)
#expect(response.error?.message.contains("executable has surrounding whitespace") == true)
#expect(await probe.capturedCommands().isEmpty)
}
@Test func `exec approvals snapshot reports resolved host defaults`() async throws {
let root = URL(
fileURLWithPath: "/tmp/oc-appr-\(UUID().uuidString.prefix(8))",
isDirectory: true)
let home = root.appendingPathComponent("home", isDirectory: true)
let stateDir = root.appendingPathComponent("state", isDirectory: true)
defer { try? FileManager().removeItem(at: root) }
try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
let initial = ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
try JSONEncoder().encode(initial)
.write(to: stateDir.appendingPathComponent("exec-approvals.json"))
try await TestIsolation.withEnvValues([
"OPENCLAW_HOME": home.path,
"OPENCLAW_STATE_DIR": stateDir.path,
]) {
let runtime = MacNodeRuntime()
let legacyResponse = await runtime.handleInvoke(
BridgeInvokeRequest(
id: "req-approvals-get-legacy",
command: OpenClawSystemCommand.execApprovalsGet.rawValue))
#expect(legacyResponse.ok)
let legacyPayloadJSON = try #require(legacyResponse.payloadJSON)
let legacyPayload = try #require(
JSONSerialization.jsonObject(with: Data(legacyPayloadJSON.utf8)) as? [String: Any])
#expect(legacyPayload["resolvedDefaults"] == nil)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(
id: "req-approvals-get-resolved",
command: OpenClawSystemCommand.execApprovalsGet.rawValue,
paramsJSON: #"{"includeResolvedDefaults":true}"#))
#expect(response.ok)
let payloadJSON = try #require(response.payloadJSON)
struct Snapshot: Decodable {
let resolvedDefaults: ExecApprovalsResolvedDefaults
}
let snapshot = try JSONDecoder().decode(Snapshot.self, from: Data(payloadJSON.utf8))
#expect(snapshot.resolvedDefaults.security == .full)
#expect(snapshot.resolvedDefaults.ask == .off)
#expect(snapshot.resolvedDefaults.askFallback == .deny)
#expect(snapshot.resolvedDefaults.autoAllowSkills == false)
try ExecApprovalsStore.updateDefaults { defaults in
defaults.security = .deny
defaults.ask = .onMiss
defaults.askFallback = .deny
defaults.autoAllowSkills = false
}.get()
let persistedResponse = await runtime.handleInvoke(
BridgeInvokeRequest(
id: "req-approvals-get-persisted",
command: OpenClawSystemCommand.execApprovalsGet.rawValue,
paramsJSON: #"{"includeResolvedDefaults":true}"#))
#expect(persistedResponse.ok)
let persistedPayloadJSON = try #require(persistedResponse.payloadJSON)
let persistedSnapshot = try JSONDecoder().decode(
Snapshot.self,
from: Data(persistedPayloadJSON.utf8))
#expect(persistedSnapshot.resolvedDefaults.security == .deny)
#expect(persistedSnapshot.resolvedDefaults.ask == .onMiss)
#expect(persistedSnapshot.resolvedDefaults.askFallback == .deny)
#expect(persistedSnapshot.resolvedDefaults.autoAllowSkills == false)
}
}
@Test func `system run denied event preserves gateway run id`() async throws {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: stateDir) }
try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
let probe = ExecEventProbe()
let runtime = MacNodeRuntime()
await runtime.setEventSender { event, json in
await probe.append(event: event, json: json)
}
let params = OpenClawSystemRunParams(
command: ["/bin/sh", "-lc", "printf ok"],
rawCommand: "printf ok",
sessionKey: "agent:main:main",
runId: "gateway-run-1",
approvalDecision: ExecApprovalDecision.deny.rawValue)
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(
id: "req-run-id",
command: OpenClawSystemCommand.run.rawValue,
paramsJSON: json))
#expect(response.ok == false)
let denied = try #require(await (probe.events()).first { $0.event == "exec.denied" })
struct Payload: Decodable {
var sessionKey: String
var runId: String
var command: String
}
let payload = try JSONDecoder().decode(Payload.self, from: Data(denied.json.utf8))
#expect(payload.sessionKey == "agent:main:main")
#expect(payload.runId == "gateway-run-1")
#expect(payload.command == ExecCommandFormatter.displayString(for: params.command))
#expect(payload.command != params.rawCommand)
}
}
@Test func `handle invoke rejects blocked system run env override before execution`() async throws {
let runtime = MacNodeRuntime()
let params = OpenClawSystemRunParams(
command: ["/bin/sh", "-lc", "echo ok"],
env: ["CLASSPATH": "/tmp/evil-classpath"])
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-2c", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
#expect(response.ok == false)
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
#expect(response.error?.message.contains("CLASSPATH") == true)
}
@Test func `handle invoke rejects invalid system run env override key before execution`() async throws {
let runtime = MacNodeRuntime()
let params = OpenClawSystemRunParams(
command: ["/bin/sh", "-lc", "echo ok"],
env: ["BAD-KEY": "x"])
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-2d", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
#expect(response.ok == false)
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
#expect(response.error?.message.contains("BAD-KEY") == true)
}
@Test func `handle invoke rejects empty system which`() async throws {
let runtime = MacNodeRuntime()
let params = OpenClawSystemWhichParams(bins: [])
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-2b", command: OpenClawSystemCommand.which.rawValue, paramsJSON: json))
#expect(response.ok == false)
}
@Test func `handle invoke rejects empty notification`() async throws {
let runtime = MacNodeRuntime()
let params = OpenClawSystemNotifyParams(title: "", body: "")
@@ -1282,38 +980,4 @@ struct MacNodeRuntimeTests {
#expect(controlHeavyProjection > 25 * 1024 * 1024)
}
@Test func `handle invoke browser proxy uses injected request`() async {
let runtime = MacNodeRuntime(
browserProxyRequest: { paramsJSON in
#expect(paramsJSON?.contains("/tabs") == true)
return #"{"result":{"ok":true,"tabs":[{"id":"tab-1"}]}}"#
},
browserControlEnabled: { true })
let paramsJSON = #"{"method":"GET","path":"/tabs","timeoutMs":2500}"#
let response = await runtime.handleInvoke(
BridgeInvokeRequest(
id: "req-browser",
command: OpenClawBrowserCommand.proxy.rawValue,
paramsJSON: paramsJSON))
#expect(response.ok == true)
#expect(response.payloadJSON == #"{"result":{"ok":true,"tabs":[{"id":"tab-1"}]}}"#)
}
@Test func `handle invoke browser proxy rejects disabled browser control`() async {
let runtime = MacNodeRuntime(
browserProxyRequest: { _ in
Issue.record("browserProxyRequest should not run when browser control is disabled")
return "{}"
},
browserControlEnabled: { false })
let response = await runtime.handleInvoke(
BridgeInvokeRequest(
id: "req-browser-disabled",
command: OpenClawBrowserCommand.proxy.rawValue,
paramsJSON: #"{"method":"GET","path":"/tabs"}"#))
#expect(response.ok == false)
#expect(response.error?.message.contains("BROWSER_DISABLED") == true)
}
}
@@ -76,6 +76,16 @@ struct OnboardingViewSmokeTests {
#expect(!order.contains(2))
}
@Test func `fresh remote setup installs CLI for the Mac node worker`() {
let order = OnboardingView.pageOrder(
for: .remote,
requiresCLIInstall: true)
#expect(order.contains(2))
#expect(!OnboardingView.shouldActivateLocalGateway(afterCLIInstallFor: .remote))
#expect(OnboardingView.shouldActivateLocalGateway(afterCLIInstallFor: .local))
}
@Test func `fresh onboarding defaults to this Mac`() {
let state = AppState(preview: true)
state.onboardingSeen = false
@@ -99,6 +99,22 @@ struct UpdateOrchestrationTests {
launchAgentWriteDisabled: false))
}
@Test func `CLI management follows configured node modes`() {
#expect(CLIInstallPrompter.shouldManageCLI(connectionMode: .local))
#expect(CLIInstallPrompter.shouldManageCLI(connectionMode: .remote))
#expect(!CLIInstallPrompter.shouldManageCLI(connectionMode: .unconfigured))
#expect(CLIInstallPrompter.shouldRestartManagedGateway(
requested: true,
connectionMode: .local))
#expect(!CLIInstallPrompter.shouldRestartManagedGateway(
requested: true,
connectionMode: .remote))
#expect(!CLIInstallPrompter.shouldRestartManagedGateway(
requested: false,
connectionMode: .local))
}
@Test func `managed repair only upgrades`() {
#expect(CLIInstallPrompter.isManagedUpgrade(found: "2026.7.1", required: "2026.7.2"))
#expect(!CLIInstallPrompter.isManagedUpgrade(found: "2026.7.2", required: "2026.7.1"))
@@ -34,6 +34,7 @@ public struct BridgeInvokeResponse: Codable, Sendable {
public let type: String
public let id: String
public let ok: Bool
public let payload: AnyCodable?
public let payloadJSON: String?
public let error: OpenClawNodeError?
@@ -41,12 +42,14 @@ public struct BridgeInvokeResponse: Codable, Sendable {
type: String = "invoke-res",
id: String,
ok: Bool,
payload: AnyCodable? = nil,
payloadJSON: String? = nil,
error: OpenClawNodeError? = nil)
{
self.type = type
self.id = id
self.ok = ok
self.payload = payload
self.payloadJSON = payloadJSON
self.error = error
}
@@ -459,19 +459,10 @@ public actor GatewayChannelActor {
selectedAuth: selectedAuth)
let reqId = UUID().uuidString
var client: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(clientId),
"displayName": ProtoAnyCodable(clientDisplayName),
"version": ProtoAnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
"platform": ProtoAnyCodable(platform),
"mode": ProtoAnyCodable(clientMode),
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
]
client["deviceFamily"] = ProtoAnyCodable(InstanceIdentity.deviceFamily)
if let model = InstanceIdentity.modelIdentifier {
client["modelIdentifier"] = ProtoAnyCodable(model)
}
let client = GatewayConnectPayload.makeClient(
options: options,
displayName: clientDisplayName,
platform: platform)
var params: [String: ProtoAnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_MIN_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
@@ -485,6 +476,9 @@ public actor GatewayChannelActor {
if !options.commands.isEmpty {
params["commands"] = ProtoAnyCodable(options.commands)
}
if let pathEnv = options.pathEnv?.trimmingCharacters(in: .whitespacesAndNewlines), !pathEnv.isEmpty {
params["pathEnv"] = ProtoAnyCodable(pathEnv)
}
if !options.permissions.isEmpty {
params["permissions"] = ProtoAnyCodable(options.permissions)
}
@@ -4,6 +4,7 @@ public struct GatewayConnectOptions: Sendable {
public var scopesAreExplicit: Bool
public var caps: [String]
public var commands: [String]
public var pathEnv: String?
public var permissions: [String: Bool]
public var clientId: String
public var clientMode: String
@@ -26,6 +27,7 @@ public struct GatewayConnectOptions: Sendable {
scopesAreExplicit: Bool = false,
caps: [String],
commands: [String],
pathEnv: String? = nil,
permissions: [String: Bool],
clientId: String,
clientMode: String,
@@ -40,6 +42,7 @@ public struct GatewayConnectOptions: Sendable {
self.scopesAreExplicit = scopesAreExplicit
self.caps = caps
self.commands = commands
self.pathEnv = pathEnv
self.permissions = permissions
self.clientId = clientId
self.clientMode = clientMode
@@ -0,0 +1,25 @@
import Foundation
import OpenClawProtocol
enum GatewayConnectPayload {
static func makeClient(
options: GatewayConnectOptions,
displayName: String,
platform: String) -> [String: OpenClawProtocol.AnyCodable]
{
var client: [String: OpenClawProtocol.AnyCodable] = [
"id": OpenClawProtocol.AnyCodable(options.clientId),
"displayName": OpenClawProtocol.AnyCodable(displayName),
"version": OpenClawProtocol.AnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
"platform": OpenClawProtocol.AnyCodable(platform),
"mode": OpenClawProtocol.AnyCodable(options.clientMode),
"instanceId": OpenClawProtocol.AnyCodable(InstanceIdentity.instanceId),
"deviceFamily": OpenClawProtocol.AnyCodable(InstanceIdentity.deviceFamily),
]
if let model = InstanceIdentity.modelIdentifier {
client["modelIdentifier"] = OpenClawProtocol.AnyCodable(model)
}
return client
}
}
@@ -289,6 +289,7 @@ public actor GatewayNodeSession {
let scopes = sorted(options.scopes)
let caps = sorted(options.caps)
let commands = sorted(options.commands)
let pathEnv = options.pathEnv?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines)
let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines)
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
@@ -308,6 +309,7 @@ public actor GatewayNodeSession {
scopes,
caps,
commands,
pathEnv,
clientId,
clientMode,
clientDisplayName,
@@ -1309,6 +1311,7 @@ extension GatewayNodeSession {
type: response.type,
id: requestId,
ok: response.ok,
payload: response.payload,
payloadJSON: response.payloadJSON,
error: response.error)
}
@@ -1392,6 +1395,9 @@ extension GatewayNodeSession {
if let payloadJSON = response.payloadJSON {
params["payloadJSON"] = AnyCodable(payloadJSON)
}
if let payload = response.payload {
params["payload"] = payload
}
if let error = response.error {
params["error"] = AnyCodable([
"code": error.code.rawValue,
@@ -1919,6 +1919,49 @@ struct GatewayNodeSessionTests {
await gateway.disconnect()
}
@Test
func `node invoke result preserves structured worker payload`() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: ["mcp"],
commands: ["mcp.tools.call.v1"],
permissions: [:],
clientId: "openclaw-macos",
clientMode: "node",
clientDisplayName: "macOS Test",
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
credentials: .init(),
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { request in
BridgeInvokeResponse(
id: request.id,
ok: true,
payload: AnyCodable(["content": [["type": "text", "text": "worker-ok"]]]))
})
let task = try #require(session.latestTask())
task.emitInvokeRequest(id: "mcp-structured", command: "mcp.tools.call.v1")
try await waitUntil("structured invoke result") {
task.sentRequestCount(method: "node.invoke.result") == 1
}
let result = try #require(task.sentRequests(method: "node.invoke.result").first)
let params = try #require(result["params"] as? [String: Any])
let payload = try #require(params["payload"] as? [String: Any])
let content = try #require(payload["content"] as? [[String: Any]])
#expect(content.first?["text"] as? String == "worker-ok")
await gateway.disconnect()
}
@Test
func `computer invoke receipts deduplicate in flight and after reconnect`() async throws {
let session = FakeGatewayWebSocketSession()
+5
View File
@@ -11,6 +11,11 @@ title: "Node"
Run a **headless node host** that connects to the Gateway WebSocket and exposes
`system.run` / `system.which` on this machine.
On macOS, the menu bar app already embeds this node-host runtime into its own
node connection and adds native Mac capabilities. Use `openclaw node run` on a
Mac only when you intentionally want a headless node without the app. Running
both creates two node identities for the same machine.
## Why use a node host?
Use a node host when you want agents to **run commands on other machines** in your
+6 -1
View File
@@ -11,7 +11,12 @@ A **node** is a companion device (macOS/iOS/watchOS/Android/headless) that conne
Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; historical only for current nodes).
macOS can also run in **node mode**: the menubar app connects to the Gateway's WS server and exposes its local canvas/camera commands as a node (so `openclaw nodes …` works against this Mac). In remote gateway mode, browser automation is handled by the CLI node host (`openclaw node run` or the installed node service), not by the native app node.
macOS can also run in **node mode**: the menu bar app connects to the Gateway's
WS server as one node (so `openclaw nodes …` works against this Mac). The app
adds native Canvas, camera, screen, notification, and computer-control commands
to the same node-host command surface used by `openclaw node run`. Do not start a
second CLI node on that Mac; the app runs the matching CLI node-host runtime as
an internal worker and remains the sole Gateway connection and node identity.
Nodes are **peripherals**, not gateways: they don't run the gateway service, and channel messages (Telegram, WhatsApp, etc.) land on the gateway, not on nodes.
+11 -4
View File
@@ -29,7 +29,8 @@ has no macOS app asset, use the newest one that does, or build from source with
1. Install and launch **OpenClaw.app**.
2. Pick **This Mac** for a local Gateway, or connect to a remote Gateway.
3. Local mode: wait while the app installs its user-space runtime and Gateway.
3. Wait while the app installs the matching CLI runtime. In local mode it also
installs and starts the Gateway.
4. Establish inference with a live model check. After it passes, Crestodian
handles the remaining setup.
5. Complete the macOS permission checklist and send the onboarding test message.
@@ -75,16 +76,22 @@ When the app runs against a local Gateway and a Chrome-family profile with cooki
| Local | This Mac should run the Gateway and keep it alive with launchd. | [Gateway on macOS](/platforms/mac/bundled-gateway) |
| Remote | Another host runs the Gateway; this Mac controls it over SSH, LAN, or Tailnet. | [Remote control](/platforms/mac/remote) |
Local mode needs an installed `openclaw` CLI. On a fresh Mac, the app installs
the matching CLI and runtime automatically before starting the Gateway wizard.
Both modes need an installed `openclaw` CLI because the app reuses its node-host
runtime. On a fresh Mac, the app installs the matching CLI automatically; local
mode then starts the Gateway wizard, while remote mode connects to the selected
Gateway without starting a second local Gateway.
See [Gateway on macOS](/platforms/mac/bundled-gateway) for manual recovery.
## What the app owns
- Menu bar status, notifications, health, and WebChat.
- macOS permission prompts for screen, microphone, speech, automation, and accessibility.
- Local node tools: Canvas, camera/screen capture, notifications, and `system.run`.
- One Mac node that combines native Canvas, camera/screen capture, notifications,
location, and computer control with the CLI node host's system, browser,
plugin, skill, and MCP commands.
- Exec approval prompts for Mac-hosted commands.
- App-context execution for approved shell commands, preserving the app's macOS
permission attribution while the CLI runtime owns shared node policy.
- Remote-mode SSH tunnels or direct Gateway connections.
The app does **not** replace the Gateway or general CLI docs. Gateway
+10
View File
@@ -301,6 +301,16 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
commandPath: ["node"],
policy: { networkProxy: "bypass" },
},
{
commandPath: ["node", "worker"],
exact: true,
policy: {
hideBanner: true,
loadPlugins: "never",
ownsProtocolStdout: true,
networkProxy: "bypass",
},
},
{
commandPath: ["node", "run"],
exact: true,
+8
View File
@@ -240,6 +240,14 @@ describe("command-startup-policy", () => {
expect(resolvePolicy({ commandPath: ["mcp", "serve"] }).suppressDoctorStdout).toBe(true);
});
it("reserves stdout for the node worker protocol", () => {
const policy = resolvePolicy({ commandPath: ["node", "worker"] });
expect(policy.hideBanner).toBe(true);
expect(policy.loadPlugins).toBe(false);
expect(policy.suppressDoctorStdout).toBe(true);
});
it("suppresses startup stdout for the bare acp protocol", () => {
expect(resolvePolicy({ commandPath: ["acp"] }).suppressDoctorStdout).toBe(true);
});
+8
View File
@@ -5,6 +5,7 @@ import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { loadNodeHostConfig } from "../../node-host/config.js";
import { runNodeHost } from "../../node-host/runner.js";
import { runNodeHostWorker } from "../../node-host/worker.js";
import { defaultRuntime } from "../../runtime.js";
import { parsePort } from "../daemon-cli/shared.js";
import { formatInvalidPortOption } from "../error-format.js";
@@ -46,6 +47,13 @@ export function registerNodeCli(program: Command) {
])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/node", "docs.openclaw.ai/cli/node")}\n`,
);
node
.command("worker", { hidden: true })
.description("Run the private macOS app node-host worker")
.action(async () => {
await runNodeHostWorker();
});
node
.command("run")
.description("Run the headless node host (foreground)")
+10
View File
@@ -0,0 +1,10 @@
/** Minimal Gateway request surface consumed by the reusable node-host runtime. */
import type { GatewayClientRequestOptions } from "../gateway/client.js";
export type NodeHostClient = {
request<T = Record<string, unknown>>(
method: string,
params?: unknown,
opts?: GatewayClientRequestOptions,
): Promise<T>;
};
+3 -3
View File
@@ -2,7 +2,6 @@
import crypto from "node:crypto";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { GatewayClient } from "../gateway/client.js";
import {
describeInterpreterInlineEval,
type InterpreterInlineEvalHit,
@@ -49,6 +48,7 @@ import { normalizeSystemRunApprovalPlan } from "../infra/system-run-approval-bin
import { formatExecCommand, resolveSystemRunCommandRequest } from "../infra/system-run-command.js";
import { logWarn } from "../logger.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { NodeHostClient } from "./client.js";
import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js";
import {
applyOutputTruncation,
@@ -263,7 +263,7 @@ async function resolveSystemRunAutoReviewer(params: {
}
export type HandleSystemRunInvokeOptions = {
client: GatewayClient;
client: NodeHostClient;
params: SystemRunParams;
skillBins: SkillBinsProvider;
execHostEnforced: boolean;
@@ -282,7 +282,7 @@ export type HandleSystemRunInvokeOptions = {
approvals: ExecApprovalsResolved;
request: ExecHostRequest;
}) => Promise<ExecHostResponse | null>;
sendNodeEvent: (client: GatewayClient, event: string, payload: unknown) => Promise<void>;
sendNodeEvent: (client: NodeHostClient, event: string, payload: unknown) => Promise<void>;
buildExecEventPayload: (payload: ExecEventPayload) => ExecEventPayload;
sendInvokeResult: (result: SystemRunInvokeResult) => Promise<void>;
sendExecFinishedEvent: (params: ExecFinishedEventParams) => Promise<void>;
+14 -14
View File
@@ -8,7 +8,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { mcpContentBlockToAgentContent } from "../agents/mcp-content.js";
import { GatewayClient } from "../gateway/client.js";
import {
analyzeArgvCommand,
createExecApprovalPolicySnapshot,
@@ -46,6 +45,7 @@ import {
} from "../infra/windows-encoding.js";
import { logWarn } from "../logger.js";
import { truncateUtf8Prefix } from "../utils/utf8-truncate.js";
import type { NodeHostClient } from "./client.js";
import {
buildSystemRunApprovalPlan,
handleSystemRunInvoke,
@@ -226,7 +226,7 @@ type ExecApprovalsSnapshot = {
file: ExecApprovalsFile;
};
type NodeInvokeRequestPayload = {
export type NodeInvokeRequestPayload = {
id: string;
nodeId: string;
command: string;
@@ -540,7 +540,7 @@ function buildExecEventPayload(payload: ExecEventPayload): ExecEventPayload {
async function sendExecFinishedEvent(
params: ExecFinishedEventParams & {
client: GatewayClient;
client: NodeHostClient;
},
) {
const combined = [params.result.stdout, params.result.stderr, params.result.error]
@@ -576,7 +576,7 @@ async function runViaMacAppExecHost(params: {
}
async function sendJsonPayloadResult(
client: GatewayClient,
client: NodeHostClient,
frame: NodeInvokeRequestPayload,
payload: unknown,
) {
@@ -587,7 +587,7 @@ async function sendJsonPayloadResult(
}
async function sendMcpPayloadResult(
client: GatewayClient,
client: NodeHostClient,
frame: NodeInvokeRequestPayload,
payload: unknown,
) {
@@ -595,7 +595,7 @@ async function sendMcpPayloadResult(
}
async function sendRawPayloadResult(
client: GatewayClient,
client: NodeHostClient,
frame: NodeInvokeRequestPayload,
payloadJSON: string,
) {
@@ -606,7 +606,7 @@ async function sendRawPayloadResult(
}
async function sendErrorResult(
client: GatewayClient,
client: NodeHostClient,
frame: NodeInvokeRequestPayload,
code: string,
message: string,
@@ -618,7 +618,7 @@ async function sendErrorResult(
}
async function sendInvalidRequestResult(
client: GatewayClient,
client: NodeHostClient,
frame: NodeInvokeRequestPayload,
err: unknown,
) {
@@ -632,7 +632,7 @@ function classifyExecApprovalsStorageError(err: unknown): "TIMEOUT" | "UNAVAILAB
}
async function sendExecApprovalsStorageErrorResult(
client: GatewayClient,
client: NodeHostClient,
frame: NodeInvokeRequestPayload,
err: unknown,
) {
@@ -642,7 +642,7 @@ async function sendExecApprovalsStorageErrorResult(
/** Handles one node-host command invocation payload and returns serialized results. */
export async function handleInvoke(
frame: NodeInvokeRequestPayload,
client: GatewayClient,
client: NodeHostClient,
skillBins: SkillBinsProvider,
mcpManager?: NodeHostMcpManager,
) {
@@ -668,7 +668,7 @@ export async function handleInvoke(
async function dispatchInvoke(
frame: NodeInvokeRequestPayload,
client: GatewayClient,
client: NodeHostClient,
skillBins: SkillBinsProvider,
mcpManager?: NodeHostMcpManager,
) {
@@ -1046,7 +1046,7 @@ function mcpToolErrorMessage(result: { content: readonly unknown[] }): string {
async function handleMcpToolsCall(
frame: NodeInvokeRequestPayload,
client: GatewayClient,
client: NodeHostClient,
mcpManager: NodeHostMcpManager | undefined,
): Promise<void> {
if (!mcpManager) {
@@ -1126,7 +1126,7 @@ export function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayl
}
async function sendInvokeResult(
client: GatewayClient,
client: NodeHostClient,
frame: NodeInvokeRequestPayload,
result: {
ok: boolean;
@@ -1193,7 +1193,7 @@ export function buildNodeEventParams(
};
}
async function sendNodeEvent(client: GatewayClient, event: string, payload: unknown) {
async function sendNodeEvent(client: NodeHostClient, event: string, payload: unknown) {
try {
await client.request("node.event", buildNodeEventParams(event, payload));
} catch {
+24 -170
View File
@@ -1,5 +1,4 @@
/** CLI runner for node-host stdin/stdout command dispatch. */
import fs from "node:fs";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
@@ -14,30 +13,11 @@ import {
} from "../gateway/client.js";
import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
import { resolveExecutableFromPathEnv } from "../infra/executable-path.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import {
NODE_EXEC_APPROVALS_COMMANDS,
NODE_FS_LIST_DIR_COMMAND,
NODE_MCP_TOOLS_CALL_COMMAND,
NODE_SYSTEM_RUN_COMMANDS,
} from "../infra/node-commands.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { VERSION } from "../version.js";
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
import {
coerceNodeInvokePayload,
type SkillBinsProvider,
buildNodeInvokeResultParams,
handleInvoke,
} from "./invoke.js";
import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js";
import {
ensureNodeHostPluginRegistry,
listRegisteredNodeHostCapsAndCommands,
} from "./plugin-node-host.js";
import { scanNodeHostedSkills } from "./skills.js";
import { coerceNodeInvokePayload, buildNodeInvokeResultParams } from "./invoke.js";
import { prepareNodeHostRuntime, type NodeHostInventory } from "./runtime.js";
export { buildNodeInvokeResultParams };
export { buildNodeEventParams } from "./invoke.js";
@@ -53,8 +33,6 @@ type NodeHostRunOptions = {
displayName?: string;
};
const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
export function resolveNodeHostGatewayPlatform(platform: NodeJS.Platform): string {
switch (platform) {
case "darwin":
@@ -168,92 +146,6 @@ async function publishNodeSkills(client: GatewayClient, skills: unknown[]): Prom
}
}
function resolveExecutablePathFromEnv(bin: string, pathEnv: string): string | null {
if (bin.includes("/") || bin.includes("\\")) {
return null;
}
return resolveExecutableFromPathEnv(bin, pathEnv) ?? null;
}
function resolveExecutableTrustPathFromEnv(bin: string, pathEnv: string): string | null {
const resolvedPath = resolveExecutablePathFromEnv(bin, pathEnv);
if (!resolvedPath) {
return null;
}
try {
return fs.realpathSync(resolvedPath);
} catch {
return resolvedPath;
}
}
function resolveSkillBinTrustEntries(bins: string[], pathEnv: string): SkillBinTrustEntry[] {
const trustEntries: SkillBinTrustEntry[] = [];
const seen = new Set<string>();
for (const bin of bins) {
const name = bin.trim();
if (!name) {
continue;
}
const resolvedPath = resolveExecutableTrustPathFromEnv(name, pathEnv);
if (!resolvedPath) {
continue;
}
const key = `${name}\u0000${resolvedPath}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
trustEntries.push({ name, resolvedPath });
}
return trustEntries.toSorted(
(left, right) =>
left.name.localeCompare(right.name) || left.resolvedPath.localeCompare(right.resolvedPath),
);
}
class SkillBinsCache implements SkillBinsProvider {
private bins: SkillBinTrustEntry[] = [];
private lastRefresh = 0;
private readonly ttlMs = 90_000;
private readonly fetch: () => Promise<string[]>;
private readonly pathEnv: string;
constructor(fetch: () => Promise<string[]>, pathEnv: string) {
this.fetch = fetch;
this.pathEnv = pathEnv;
}
async current(force = false): Promise<SkillBinTrustEntry[]> {
if (force || Date.now() - this.lastRefresh > this.ttlMs) {
await this.refresh();
}
return this.bins;
}
private async refresh() {
try {
const bins = await this.fetch();
this.bins = resolveSkillBinTrustEntries(bins, this.pathEnv);
this.lastRefresh = Date.now();
} catch {
if (!this.lastRefresh) {
this.bins = [];
}
}
}
}
function ensureNodePathEnv(): string {
ensureOpenClawCliOnPath({ pathEnv: process.env.PATH ?? "" });
const current = process.env.PATH ?? "";
if (current.trim()) {
return current;
}
process.env.PATH = DEFAULT_NODE_PATH;
return DEFAULT_NODE_PATH;
}
export async function resolveNodeHostGatewayCredentials(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
@@ -306,8 +198,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
await saveNodeHostConfig(config);
const cfg = getRuntimeConfig();
await ensureNodeHostPluginRegistry({ config: cfg, env: process.env });
const pluginNodeHost = listRegisteredNodeHostCapsAndCommands({ config: cfg, env: process.env });
const preparedRuntime = await prepareNodeHostRuntime({ config: cfg, env: process.env });
const { token, password } = await resolveNodeHostGatewayCredentials({
config: cfg,
env: process.env,
@@ -322,33 +213,17 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
: `/${gateway.contextPath}`
: "";
const url = `${scheme}://${host}:${port}${contextPath}`;
const pathEnv = ensureNodePathEnv();
const mcpServers = cfg.nodeHost?.mcp?.servers;
const nodeSkills = cfg.nodeHost?.skills?.enabled === false ? null : scanNodeHostedSkills();
const mcpStartupAbort = new AbortController();
const mcpRuntime: {
manager?: NodeHostMcpManager;
startup?: Promise<NodeHostMcpManager>;
} = {};
let inventory: NodeHostInventory = preparedRuntime.initialInventory;
let gatewayHelloReceived = false;
const publishNodeToolsWhenReady = () => {
if (!gatewayHelloReceived || !mcpRuntime.manager) {
const publishInventory = () => {
if (!gatewayHelloReceived) {
return;
}
const nodePluginTools = [
...pluginNodeHost.nodePluginTools,
...mcpRuntime.manager.descriptors,
].toSorted(
(left, right) =>
left.pluginId.localeCompare(right.pluginId) || left.name.localeCompare(right.name),
);
void publishNodePluginTools(client, nodePluginTools);
};
const closeMcpRuntime = async () => {
mcpStartupAbort.abort();
const manager = mcpRuntime.manager ?? (await mcpRuntime.startup?.catch(() => undefined));
await manager?.close();
if (inventory.skills) {
void publishNodeSkills(client, inventory.skills);
}
void publishNodePluginTools(client, inventory.pluginTools);
};
const client = new GatewayClient({
@@ -367,15 +242,9 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
scopes: [],
// Pair the built-in MCP command family up front. Server inventory is
// restart-scoped availability, not a capability upgrade requiring re-pairing.
caps: ["system", "mcp", ...pluginNodeHost.caps],
commands: [
...NODE_SYSTEM_RUN_COMMANDS,
...NODE_EXEC_APPROVALS_COMMANDS,
NODE_FS_LIST_DIR_COMMAND,
NODE_MCP_TOOLS_CALL_COMMAND,
...pluginNodeHost.commands,
],
pathEnv,
caps: preparedRuntime.manifest.caps,
commands: preparedRuntime.manifest.commands,
pathEnv: preparedRuntime.manifest.pathEnv,
permissions: undefined,
deviceIdentity: loadOrCreateDeviceIdentity(),
tlsFingerprint: gateway.tlsFingerprint,
@@ -387,20 +256,12 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
if (!payload) {
return;
}
void handleInvoke(payload, client, skillBins, mcpRuntime.manager);
void activeRuntime.invoke(payload);
},
onHelloOk: () => {
writeStderrLine(`node host gateway connected: ${url}`);
gatewayHelloReceived = true;
if (nodeSkills) {
void publishNodeSkills(client, nodeSkills);
}
if (mcpRuntime.manager) {
publishNodeToolsWhenReady();
} else {
// Do not make existing plugin tools wait for optional MCP discovery.
void publishNodePluginTools(client, pluginNodeHost.nodePluginTools);
}
publishInventory();
},
onConnectError: (err) => {
// keep retrying (handled by GatewayClient)
@@ -412,7 +273,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
client.stop();
// Terminal auth/version pauses restart under a supervisor; close MCP
// subprocesses first so restart loops cannot orphan server processes.
void closeMcpRuntime().finally(() => process.exit(code));
void activeRuntime.close().finally(() => process.exit(code));
},
});
},
@@ -420,12 +281,13 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
writeStderrLine(`node host gateway closed (${code}): ${reason}`);
},
});
const skillBins = new SkillBinsCache(async () => {
const res = await client.request<{ bins: Array<unknown> }>("skills.bins", {});
const bins = Array.isArray(res?.bins) ? res.bins.map((bin) => String(bin)) : [];
return bins;
}, pathEnv);
const activeRuntime = preparedRuntime.start({
client,
onInventoryChanged: (nextInventory) => {
inventory = nextInventory;
publishInventory();
},
});
let stopping = false;
let resolveStopped: (() => void) | undefined;
@@ -442,7 +304,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
const stopClientAndMcp = async () => {
client.stop();
try {
await closeMcpRuntime();
await activeRuntime.close();
} finally {
clearInterval(lifetimeInterval);
}
@@ -468,14 +330,6 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
const readinessPromise = startGatewayClientWhenEventLoopReady(client, {
clientOptions: { preauthHandshakeTimeoutMs: cfg.gateway?.handshakeTimeoutMs },
});
// Gateway startup begins first; optional MCP discovery must not delay core node availability.
mcpRuntime.startup = startNodeHostMcpManager(mcpServers, { signal: mcpStartupAbort.signal }).then(
(manager) => {
mcpRuntime.manager = manager;
publishNodeToolsWhenReady();
return manager;
},
);
let readiness;
try {
readiness = await readinessPromise;
+213
View File
@@ -0,0 +1,213 @@
/** Transport-independent CLI node-host runtime shared by Gateway and app workers. */
import fs from "node:fs";
import type { OpenClawConfig } from "../config/config.js";
import { getRuntimeConfig } from "../config/config.js";
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
import { resolveExecutableFromPathEnv } from "../infra/executable-path.js";
import {
NODE_EXEC_APPROVALS_COMMANDS,
NODE_FS_LIST_DIR_COMMAND,
NODE_MCP_TOOLS_CALL_COMMAND,
NODE_SYSTEM_RUN_COMMANDS,
} from "../infra/node-commands.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import type { NodeHostClient } from "./client.js";
import { handleInvoke, type NodeInvokeRequestPayload, type SkillBinsProvider } from "./invoke.js";
import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js";
import {
ensureNodeHostPluginRegistry,
listRegisteredNodeHostCapsAndCommands,
} from "./plugin-node-host.js";
import { scanNodeHostedSkills } from "./skills.js";
const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
type NodeHostManifest = {
caps: string[];
commands: string[];
pathEnv: string;
};
export type NodeHostInventory = {
skills: unknown[] | null;
pluginTools: unknown[];
};
type PreparedNodeHostRuntime = {
manifest: NodeHostManifest;
initialInventory: NodeHostInventory;
start(params: {
client: NodeHostClient;
onInventoryChanged?: (inventory: NodeHostInventory) => void;
}): ActiveNodeHostRuntime;
};
type ActiveNodeHostRuntime = {
invoke(frame: NodeInvokeRequestPayload): Promise<void>;
close(): Promise<void>;
};
function resolveExecutablePathFromEnv(bin: string, pathEnv: string): string | null {
if (bin.includes("/") || bin.includes("\\")) {
return null;
}
return resolveExecutableFromPathEnv(bin, pathEnv) ?? null;
}
function resolveExecutableTrustPathFromEnv(bin: string, pathEnv: string): string | null {
const resolvedPath = resolveExecutablePathFromEnv(bin, pathEnv);
if (!resolvedPath) {
return null;
}
try {
return fs.realpathSync(resolvedPath);
} catch {
return resolvedPath;
}
}
function resolveSkillBinTrustEntries(bins: string[], pathEnv: string): SkillBinTrustEntry[] {
const trustEntries: SkillBinTrustEntry[] = [];
const seen = new Set<string>();
for (const raw of bins) {
const name = raw.trim();
if (!name) {
continue;
}
const resolvedPath = resolveExecutableTrustPathFromEnv(name, pathEnv);
if (!resolvedPath) {
continue;
}
const key = `${name}\u0000${resolvedPath}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
trustEntries.push({ name, resolvedPath });
}
return trustEntries.toSorted(
(left, right) =>
left.name.localeCompare(right.name) || left.resolvedPath.localeCompare(right.resolvedPath),
);
}
class SkillBinsCache implements SkillBinsProvider {
private bins: SkillBinTrustEntry[] = [];
private lastRefresh = 0;
private readonly ttlMs = 90_000;
constructor(
private readonly client: NodeHostClient,
private readonly pathEnv: string,
) {}
async current(force = false): Promise<SkillBinTrustEntry[]> {
if (force || Date.now() - this.lastRefresh > this.ttlMs) {
await this.refresh();
}
return this.bins;
}
private async refresh() {
try {
const res = await this.client.request<{ bins: Array<unknown> }>("skills.bins", {});
const bins = Array.isArray(res?.bins) ? res.bins.map((bin) => String(bin)) : [];
this.bins = resolveSkillBinTrustEntries(bins, this.pathEnv);
this.lastRefresh = Date.now();
} catch {
if (!this.lastRefresh) {
this.bins = [];
}
}
}
}
function ensureNodePathEnv(): string {
ensureOpenClawCliOnPath({ pathEnv: process.env.PATH ?? "" });
const current = process.env.PATH ?? "";
if (current.trim()) {
return current;
}
process.env.PATH = DEFAULT_NODE_PATH;
return DEFAULT_NODE_PATH;
}
function createInventory(params: {
skills: unknown[] | null;
pluginTools: unknown[];
mcpManager?: NodeHostMcpManager;
}): NodeHostInventory {
const pluginTools = [...params.pluginTools, ...(params.mcpManager?.descriptors ?? [])].toSorted(
(left, right) => {
const a = left as { pluginId?: string; name?: string };
const b = right as { pluginId?: string; name?: string };
return (
(a.pluginId ?? "").localeCompare(b.pluginId ?? "") ||
(a.name ?? "").localeCompare(b.name ?? "")
);
},
);
return { skills: params.skills, pluginTools };
}
export async function prepareNodeHostRuntime(params?: {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): Promise<PreparedNodeHostRuntime> {
const config = params?.config ?? getRuntimeConfig();
const env = params?.env ?? process.env;
await ensureNodeHostPluginRegistry({ config, env });
const pluginNodeHost = listRegisteredNodeHostCapsAndCommands({ config, env });
const pathEnv = ensureNodePathEnv();
const skills = config.nodeHost?.skills?.enabled === false ? null : scanNodeHostedSkills();
const manifest: NodeHostManifest = {
caps: [...new Set(["system", "mcp", ...pluginNodeHost.caps])].toSorted(),
commands: [
...new Set([
...NODE_SYSTEM_RUN_COMMANDS,
...NODE_EXEC_APPROVALS_COMMANDS,
NODE_FS_LIST_DIR_COMMAND,
NODE_MCP_TOOLS_CALL_COMMAND,
...pluginNodeHost.commands,
]),
].toSorted(),
pathEnv,
};
const initialInventory = createInventory({
skills,
pluginTools: pluginNodeHost.nodePluginTools,
});
return {
manifest,
initialInventory,
start({ client, onInventoryChanged }) {
const mcpAbort = new AbortController();
const skillBins = new SkillBinsCache(client, pathEnv);
let manager: NodeHostMcpManager | undefined;
const startup = startNodeHostMcpManager(config.nodeHost?.mcp?.servers, {
signal: mcpAbort.signal,
}).then((resolved) => {
manager = resolved;
onInventoryChanged?.(
createInventory({
skills,
pluginTools: pluginNodeHost.nodePluginTools,
mcpManager: manager,
}),
);
return resolved;
});
return {
async invoke(frame) {
await handleInvoke(frame, client, skillBins, manager);
},
async close() {
mcpAbort.abort();
const resolved = manager ?? (await startup.catch(() => undefined));
await resolved?.close();
},
};
},
};
}
+79
View File
@@ -0,0 +1,79 @@
import type { GatewayClientRequestOptions } from "../gateway/client.js";
import type { NodeHostClient } from "./client.js";
export type NodeHostWorkerGatewayResponse =
| { type: "gateway-response"; id: string; ok: true; result: unknown }
| { type: "gateway-response"; id: string; ok: false; error: string };
type PendingGatewayRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
};
export class NodeHostWorkerBridgeClient implements NodeHostClient {
private nextRequestId = 1;
private readonly pending = new Map<string, PendingGatewayRequest>();
constructor(private readonly writeMessage: (message: unknown) => void) {}
async request<T = Record<string, unknown>>(
method: string,
params?: unknown,
opts?: GatewayClientRequestOptions,
): Promise<T> {
if (method === "node.invoke.result") {
this.writeMessage({ type: "invoke-result", result: params ?? {} });
return {} as T;
}
if (method === "node.event") {
this.writeMessage({ type: "node-event", event: params ?? {} });
return {} as T;
}
const id = `gateway-${this.nextRequestId++}`;
const timeoutMs = Math.max(1, opts?.timeoutMs ?? 15_000);
const response = new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Gateway request timed out: ${method}`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
});
this.writeMessage({ type: "gateway-request", id, method, params: params ?? {}, timeoutMs });
return (await response) as T;
}
handleResponse(message: NodeHostWorkerGatewayResponse): boolean {
const pending = this.pending.get(message.id);
if (!pending) {
return false;
}
this.pending.delete(message.id);
clearTimeout(pending.timer);
if (message.ok) {
pending.resolve(message.result);
} else {
pending.reject(new Error(message.error));
}
return true;
}
close(): void {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(new Error("node-host worker stopped"));
}
this.pending.clear();
}
}
export async function stopNodeHostWorkerFromSignal(
input: { close(): void },
stop: (exitCode: number) => Promise<void>,
exitCode: number,
): Promise<void> {
const stopped = stop(exitCode);
input.close();
await stopped;
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { NodeHostWorkerBridgeClient, stopNodeHostWorkerFromSignal } from "./worker-support.js";
describe("NodeHostWorkerBridgeClient", () => {
it("forwards invoke results and events without creating gateway request waits", async () => {
const messages: unknown[] = [];
const client = new NodeHostWorkerBridgeClient((message) => messages.push(message));
await client.request("node.invoke.result", { id: "invoke-1", ok: true });
await client.request("node.event", { event: "exec.started", payloadJSON: "{}" });
expect(messages).toEqual([
{ type: "invoke-result", result: { id: "invoke-1", ok: true } },
{ type: "node-event", event: { event: "exec.started", payloadJSON: "{}" } },
]);
});
it("tunnels runtime gateway requests and resolves their matching response", async () => {
const messages: Array<Record<string, unknown>> = [];
const client = new NodeHostWorkerBridgeClient((message) => {
messages.push(message as Record<string, unknown>);
});
const response = client.request<{ bins: string[] }>("skills.bins", {}, { timeoutMs: 1_000 });
expect(messages).toEqual([
{
type: "gateway-request",
id: "gateway-1",
method: "skills.bins",
params: {},
timeoutMs: 1_000,
},
]);
expect(
client.handleResponse({
type: "gateway-response",
id: "gateway-1",
ok: true,
result: { bins: ["rg"] },
}),
).toBe(true);
await expect(response).resolves.toEqual({ bins: ["rg"] });
});
it("fails pending gateway requests when the app worker stops", async () => {
const client = new NodeHostWorkerBridgeClient(() => {});
const response = client.request("skills.bins", {}, { timeoutMs: 1_000 });
client.close();
await expect(response).rejects.toThrow("node-host worker stopped");
});
});
describe("stopNodeHostWorkerFromSignal", () => {
it("preserves the signal exit code when closing stdin emits EOF", async () => {
const calls: string[] = [];
let stopping = false;
const stop = async (exitCode: number) => {
if (stopping) {
return;
}
stopping = true;
calls.push(`stop:${exitCode}`);
};
await stopNodeHostWorkerFromSignal(
{
close: () => {
calls.push("close");
void stop(0);
},
},
stop,
143,
);
expect(calls).toEqual(["stop:143", "close"]);
});
});
+120
View File
@@ -0,0 +1,120 @@
/** Private JSONL worker exposing the CLI node-host runtime to the macOS app. */
import { createInterface } from "node:readline";
import { VERSION } from "../version.js";
import type { NodeInvokeRequestPayload } from "./invoke.js";
import { prepareNodeHostRuntime, type NodeHostInventory } from "./runtime.js";
import {
NodeHostWorkerBridgeClient,
type NodeHostWorkerGatewayResponse,
stopNodeHostWorkerFromSignal,
} from "./worker-support.js";
type WorkerInput =
| { type: "invoke"; request: NodeInvokeRequestPayload }
| NodeHostWorkerGatewayResponse
| { type: "stop" };
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function writeMessage(message: unknown): void {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function parseInput(line: string): WorkerInput | null {
try {
const parsed = asRecord(JSON.parse(line));
const type = typeof parsed?.type === "string" ? parsed.type : "";
if (type === "invoke") {
const request = asRecord(parsed?.request);
if (
request &&
typeof request.id === "string" &&
typeof request.nodeId === "string" &&
typeof request.command === "string"
) {
return { type, request: request as NodeInvokeRequestPayload };
}
return null;
}
if (type === "gateway-response") {
const id = typeof parsed?.id === "string" ? parsed.id : "";
if (!id) {
return null;
}
return parsed?.ok === true
? { type, id, ok: true, result: parsed.result }
: {
type,
id,
ok: false,
error: typeof parsed?.error === "string" ? parsed.error : "Gateway request failed",
};
}
return type === "stop" ? { type } : null;
} catch {
return null;
}
}
function emitInventory(inventory: NodeHostInventory): void {
writeMessage({ type: "inventory", inventory });
}
export async function runNodeHostWorker(): Promise<void> {
const prepared = await prepareNodeHostRuntime();
const client = new NodeHostWorkerBridgeClient(writeMessage);
let stopping = false;
let resolveStopped: (() => void) | undefined;
const stopped = new Promise<void>((resolve) => {
resolveStopped = resolve;
});
const stop = async (exitCode: number) => {
if (stopping) {
return;
}
stopping = true;
try {
client.close();
await runtime.close();
process.exitCode = exitCode;
} finally {
resolveStopped?.();
}
};
const runtime = prepared.start({ client, onInventoryChanged: emitInventory });
writeMessage({
type: "ready",
version: VERSION,
manifest: prepared.manifest,
inventory: prepared.initialInventory,
});
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
input.on("line", (line) => {
const message = parseInput(line);
if (!message) {
writeMessage({ type: "protocol-error", error: "invalid worker request" });
return;
}
if (message.type === "gateway-response") {
client.handleResponse(message);
return;
}
if (message.type === "stop") {
input.close();
void stop(0);
return;
}
void runtime.invoke(message.request);
});
input.on("close", () => void stop(0));
process.once("SIGINT", () => void stopNodeHostWorkerFromSignal(input, stop, 130));
process.once("SIGTERM", () => void stopNodeHostWorkerFromSignal(input, stop, 143));
await stopped;
}