diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 5db46767aceb..9dabf1373d0b 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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, you’ll 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 didn’t 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": "You’re 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 you’re 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": "Couldn’t 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", diff --git a/apps/.i18n/native/ar.json b/apps/.i18n/native/ar.json index 9f1759875a19..c985f19c67f3 100644 --- a/apps/.i18n/native/ar.json +++ b/apps/.i18n/native/ar.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/de.json b/apps/.i18n/native/de.json index 1428ec75765e..b3b2b61c5c80 100644 --- a/apps/.i18n/native/de.json +++ b/apps/.i18n/native/de.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/es.json b/apps/.i18n/native/es.json index 2205f7f3c193..4224b591b63f 100644 --- a/apps/.i18n/native/es.json +++ b/apps/.i18n/native/es.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/fa.json b/apps/.i18n/native/fa.json index 473afdbc4c19..626ffb548dd1 100644 --- a/apps/.i18n/native/fa.json +++ b/apps/.i18n/native/fa.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/fr.json b/apps/.i18n/native/fr.json index 04b646a417c5..6db085c83b9b 100644 --- a/apps/.i18n/native/fr.json +++ b/apps/.i18n/native/fr.json @@ -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 d’arrière-plan sur ce Mac. Cela prend généralement moins d’une 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 d’arrière-plan" }, + { + "id": "native.apple.9230c2a15ba6da94", + "source": "Runs inside the app and uses its macOS permissions.", + "translated": "S’exécute dans l’app 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, you’ll connect your AI.", diff --git a/apps/.i18n/native/hi.json b/apps/.i18n/native/hi.json index 6b6df3499c55..d26b479a5a8a 100644 --- a/apps/.i18n/native/hi.json +++ b/apps/.i18n/native/hi.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/id.json b/apps/.i18n/native/id.json index d0b71776c27c..e27dfeebb40c 100644 --- a/apps/.i18n/native/id.json +++ b/apps/.i18n/native/id.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/it.json b/apps/.i18n/native/it.json index d2e8bd15f3d2..fbd189ba6cac 100644 --- a/apps/.i18n/native/it.json +++ b/apps/.i18n/native/it.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/ja-JP.json b/apps/.i18n/native/ja-JP.json index ee03f442095b..56f8682d53c8 100644 --- a/apps/.i18n/native/ja-JP.json +++ b/apps/.i18n/native/ja-JP.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/ko.json b/apps/.i18n/native/ko.json index 752fcf15e6c1..2f09b3f8e1c9 100644 --- a/apps/.i18n/native/ko.json +++ b/apps/.i18n/native/ko.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/nl.json b/apps/.i18n/native/nl.json index ce0bd2b3850b..a11cb107a1b0 100644 --- a/apps/.i18n/native/nl.json +++ b/apps/.i18n/native/nl.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/pl.json b/apps/.i18n/native/pl.json index fc634a6680ce..d6088045a290 100644 --- a/apps/.i18n/native/pl.json +++ b/apps/.i18n/native/pl.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/pt-BR.json b/apps/.i18n/native/pt-BR.json index e3d100063f9c..c427324fe644 100644 --- a/apps/.i18n/native/pt-BR.json +++ b/apps/.i18n/native/pt-BR.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/ru.json b/apps/.i18n/native/ru.json index fa46b6fa8c40..65e3905507fb 100644 --- a/apps/.i18n/native/ru.json +++ b/apps/.i18n/native/ru.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/sv.json b/apps/.i18n/native/sv.json index ccf2f573c9d5..f9a1cef94911 100644 --- a/apps/.i18n/native/sv.json +++ b/apps/.i18n/native/sv.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/th.json b/apps/.i18n/native/th.json index c7f8c6c6da00..cf9e56c22d66 100644 --- a/apps/.i18n/native/th.json +++ b/apps/.i18n/native/th.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/tr.json b/apps/.i18n/native/tr.json index 119e384d43a2..f27a4de7ffbe 100644 --- a/apps/.i18n/native/tr.json +++ b/apps/.i18n/native/tr.json @@ -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 Mac’te 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, you’ll connect your AI.", diff --git a/apps/.i18n/native/uk.json b/apps/.i18n/native/uk.json index 048f0f8fcf63..60308fa3966f 100644 --- a/apps/.i18n/native/uk.json +++ b/apps/.i18n/native/uk.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/vi.json b/apps/.i18n/native/vi.json index 842f6673fdc3..12e4b0803fe7 100644 --- a/apps/.i18n/native/vi.json +++ b/apps/.i18n/native/vi.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/zh-CN.json b/apps/.i18n/native/zh-CN.json index 8c447e5ede22..6a5e148cf2db 100644 --- a/apps/.i18n/native/zh-CN.json +++ b/apps/.i18n/native/zh-CN.json @@ -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, you’ll connect your AI.", diff --git a/apps/.i18n/native/zh-TW.json b/apps/.i18n/native/zh-TW.json index 2f8649188600..560813974cc0 100644 --- a/apps/.i18n/native/zh-TW.json +++ b/apps/.i18n/native/zh-TW.json @@ -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, you’ll connect your AI.", diff --git a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift index bbb4a787969f..056335b0b54e 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift @@ -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() diff --git a/apps/macos/Sources/OpenClaw/CLIInstaller.swift b/apps/macos/Sources/OpenClaw/CLIInstaller.swift index b7d761c34142..ffdf03b6cc04 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstaller.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstaller.swift @@ -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 } diff --git a/apps/macos/Sources/OpenClaw/CommandResolver.swift b/apps/macos/Sources/OpenClaw/CommandResolver.swift index dab7933df81d..1a2216394d6f 100644 --- a/apps/macos/Sources/OpenClaw/CommandResolver.swift +++ b/apps/macos/Sources/OpenClaw/CommandResolver.swift @@ -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 diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift deleted file mode 100644 index b3f8edc62c16..000000000000 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift +++ /dev/null @@ -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() - 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) - } -} diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeFileSystemCommands.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeFileSystemCommands.swift deleted file mode 100644 index 1bd7a408a884..000000000000 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeFileSystemCommands.swift +++ /dev/null @@ -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)) - } -} diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift new file mode 100644 index 000000000000..48d55390dafe --- /dev/null +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift @@ -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? + private var invokeContinuations: [String: CheckedContinuation] = [:] + private var startTimer: DispatchSourceTimer? + private var eventDeliveryTask: Task? + private var inventoryPublicationTask: Task? + 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? = 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 + { + 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) { + 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.. Void)? private let refreshEvents: AsyncStream @@ -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 { + private func enqueueRouteInvalidation( + yieldRefresh: Bool, + restartNodeHostWorker: Bool = false) -> Task + { 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() + return (primary + additional).filter { seen.insert($0).inserted } + } +} diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift index 6fe6cb72047c..fbe60eae4358 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift @@ -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) 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(_ 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: [ diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift index 6a5a02fc37e8..cc8638625e75 100644 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ b/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -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 { diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift index 1162595012b3..0ff456798c8f 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift @@ -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. diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 22ed33c8a83f..19e5aa2d6b28 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -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, you’ll connect your AI.", + detail: remoteMode + ? "Once ready, this Mac connects to your selected Gateway." + : "Once the service answers, you’ll connect your AI.", state: self.cliInstalled ? .done : .pending) if self.installFailed { diff --git a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift index 1d4511baf374..8d38b8026ed6 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift @@ -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() diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift index 469e6ed9e1d8..1714d84bca8d 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift @@ -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 { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift index 5fbb1a2cf05d..ddaacb0f6d5d 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift @@ -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( diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift deleted file mode 100644 index 0b3a6c0d865a..000000000000 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift +++ /dev/null @@ -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")) - } - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift new file mode 100644 index 000000000000..7bd64ccbf5bc --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift @@ -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 + } + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift index f78e8e8a9a46..07ac91d9ffe0 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift @@ -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`() { diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift index 5fe9be4ca2c9..936df6af3806 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift @@ -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) - } } diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift index 60edae4031de..3dedea567b81 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift @@ -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 diff --git a/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift b/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift index f6cab6612355..03d325d52850 100644 --- a/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift @@ -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")) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift index 79d1a7369e62..bd5139615d8c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift @@ -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 } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 308800a8be2c..6e0c7c171013 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -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) } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectOptions.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectOptions.swift index 33329b628a29..102cfa4f74cd 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectOptions.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectOptions.swift @@ -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 diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectPayload.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectPayload.swift new file mode 100644 index 000000000000..f06a628e0ce2 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectPayload.swift @@ -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 + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index cffb86295462..f6ef76809830 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -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, diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index ad876a417339..9fe132ece1d4 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -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() diff --git a/docs/cli/node.md b/docs/cli/node.md index a8a72860189b..e6d675630f95 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -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 diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 9e3d9368c6aa..4a45fdfc94d8 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -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. diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index 48c024fe32c5..5f9c565f4ff4 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -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 diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index 75ad190bd30b..e3966d3b1a16 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -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, diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index 7ec4eefef92d..67c8edfbeefe 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -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); }); diff --git a/src/cli/node-cli/register.ts b/src/cli/node-cli/register.ts index c997f53248e3..67d60c035555 100644 --- a/src/cli/node-cli/register.ts +++ b/src/cli/node-cli/register.ts @@ -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)") diff --git a/src/node-host/client.ts b/src/node-host/client.ts new file mode 100644 index 000000000000..c3c9afe1c714 --- /dev/null +++ b/src/node-host/client.ts @@ -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>( + method: string, + params?: unknown, + opts?: GatewayClientRequestOptions, + ): Promise; +}; diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index 5e8cdf6493bf..08ba1b10a24d 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -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; - sendNodeEvent: (client: GatewayClient, event: string, payload: unknown) => Promise; + sendNodeEvent: (client: NodeHostClient, event: string, payload: unknown) => Promise; buildExecEventPayload: (payload: ExecEventPayload) => ExecEventPayload; sendInvokeResult: (result: SystemRunInvokeResult) => Promise; sendExecFinishedEvent: (params: ExecFinishedEventParams) => Promise; diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index 725ff5cf44ba..62a5c9174418 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -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 { 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 { diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 4479d8b5d342..e7c95ec93649 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -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(); - 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; - private readonly pathEnv: string; - - constructor(fetch: () => Promise, pathEnv: string) { - this.fetch = fetch; - this.pathEnv = pathEnv; - } - - async current(force = false): Promise { - 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 { 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 { : `/${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; - } = {}; + 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 { 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 { 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 { 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 { writeStderrLine(`node host gateway closed (${code}): ${reason}`); }, }); - - const skillBins = new SkillBinsCache(async () => { - const res = await client.request<{ bins: Array }>("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 { 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 { 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; diff --git a/src/node-host/runtime.ts b/src/node-host/runtime.ts new file mode 100644 index 000000000000..b8dafca3a7d2 --- /dev/null +++ b/src/node-host/runtime.ts @@ -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; + close(): Promise; +}; + +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(); + 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 { + 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 }>("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 { + 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(); + }, + }; + }, + }; +} diff --git a/src/node-host/worker-support.ts b/src/node-host/worker-support.ts new file mode 100644 index 000000000000..731eca7a005f --- /dev/null +++ b/src/node-host/worker-support.ts @@ -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(); + + constructor(private readonly writeMessage: (message: unknown) => void) {} + + async request>( + method: string, + params?: unknown, + opts?: GatewayClientRequestOptions, + ): Promise { + 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((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, + exitCode: number, +): Promise { + const stopped = stop(exitCode); + input.close(); + await stopped; +} diff --git a/src/node-host/worker.test.ts b/src/node-host/worker.test.ts new file mode 100644 index 000000000000..177f03317957 --- /dev/null +++ b/src/node-host/worker.test.ts @@ -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> = []; + const client = new NodeHostWorkerBridgeClient((message) => { + messages.push(message as Record); + }); + + 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"]); + }); +}); diff --git a/src/node-host/worker.ts b/src/node-host/worker.ts new file mode 100644 index 000000000000..c4ce6adda8a0 --- /dev/null +++ b/src/node-host/worker.ts @@ -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 | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : 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 { + const prepared = await prepareNodeHostRuntime(); + const client = new NodeHostWorkerBridgeClient(writeMessage); + let stopping = false; + let resolveStopped: (() => void) | undefined; + const stopped = new Promise((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; +}