mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(mac): make app node a CLI capability superset (#105642)
* refactor(mac): reuse CLI node-host runtime * fix(mac): prefer checkout CLI in debug builds * chore: leave release notes to release automation * chore(mac): sync native string inventory * chore(mac): refresh native locale artifacts * fix(node): satisfy native and deadcode gates
This commit is contained in:
committed by
GitHub
parent
f15a5e566b
commit
d287c9b414
@@ -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",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
+15
-15
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -19,12 +19,12 @@ final class CLIInstallPrompter {
|
||||
|
||||
private func checkAndPromptIfNeededAsync(reason: String) async {
|
||||
guard AppStateStore.shared.onboardingSeen else { return }
|
||||
guard AppStateStore.shared.connectionMode == .local else { return }
|
||||
let connectionMode = AppStateStore.shared.connectionMode
|
||||
guard Self.shouldManageCLI(connectionMode: connectionMode) else { return }
|
||||
guard let version = Self.appVersion() else { return }
|
||||
let status = await CLIInstaller.status()
|
||||
let managedStatus = await CLIInstaller.managedStatus()
|
||||
guard AppStateStore.shared.onboardingSeen else { return }
|
||||
guard AppStateStore.shared.connectionMode == .local else { return }
|
||||
let shouldRepairManaged = Self.shouldAutomaticallyRepair(
|
||||
status: managedStatus,
|
||||
launchAgentUsesManagedCLI: Self.launchAgentUsesManagedCLI(
|
||||
@@ -45,7 +45,9 @@ final class CLIInstallPrompter {
|
||||
if await self.installCLI(
|
||||
target: .exact(version),
|
||||
showCompletionAlert: false,
|
||||
restartManagedGateway: !AppStateStore.shared.isPaused)
|
||||
restartManagedGateway: Self.shouldRestartManagedGateway(
|
||||
requested: !AppStateStore.shared.isPaused,
|
||||
connectionMode: connectionMode))
|
||||
{
|
||||
return
|
||||
}
|
||||
@@ -74,7 +76,7 @@ final class CLIInstallPrompter {
|
||||
guard confirmStable else { return target }
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Install OpenClaw CLI?"
|
||||
alert.informativeText = "Local mode needs the CLI so launchd can run the Gateway."
|
||||
alert.informativeText = "The Mac node needs the matching CLI runtime."
|
||||
alert.addButton(withTitle: "Install CLI")
|
||||
alert.addButton(withTitle: "Not Now")
|
||||
alert.addButton(withTitle: "Open Settings")
|
||||
@@ -119,7 +121,11 @@ final class CLIInstallPrompter {
|
||||
restartManagedGateway: Bool = false) async -> Bool
|
||||
{
|
||||
let status = StatusBox()
|
||||
let previousPID = restartManagedGateway
|
||||
let usesLocalGateway = AppStateStore.shared.connectionMode == .local
|
||||
let shouldRestartManagedGateway = Self.shouldRestartManagedGateway(
|
||||
requested: restartManagedGateway,
|
||||
connectionMode: AppStateStore.shared.connectionMode)
|
||||
let previousPID = shouldRestartManagedGateway
|
||||
? await GatewayLaunchAgentManager.runningGatewayPID()
|
||||
: nil
|
||||
let installed = await CLIInstaller.install(target: target) { message in
|
||||
@@ -130,7 +136,7 @@ final class CLIInstallPrompter {
|
||||
}
|
||||
var activated = false
|
||||
if installed {
|
||||
if restartManagedGateway {
|
||||
if shouldRestartManagedGateway {
|
||||
let restarted = await self.ensureManagedGatewayRestarted(
|
||||
previousPID: previousPID,
|
||||
status: status)
|
||||
@@ -143,13 +149,18 @@ final class CLIInstallPrompter {
|
||||
return false
|
||||
}
|
||||
}
|
||||
await status.set("Starting OpenClaw Gateway…")
|
||||
if !showCompletionAlert {
|
||||
self.logger.info("managed CLI repair: Starting OpenClaw Gateway…")
|
||||
let activation: CLIInstaller.LocalGatewayActivation?
|
||||
if usesLocalGateway {
|
||||
await status.set("Starting OpenClaw Gateway…")
|
||||
if !showCompletionAlert {
|
||||
self.logger.info("managed CLI repair: Starting OpenClaw Gateway…")
|
||||
}
|
||||
activation = await CLIInstaller.activateLocalGateway()
|
||||
} else {
|
||||
activation = nil
|
||||
}
|
||||
let activation = await CLIInstaller.activateLocalGateway()
|
||||
activated = activation != .failed
|
||||
if restartManagedGateway {
|
||||
if shouldRestartManagedGateway {
|
||||
// Only proven gateway health closes the recovery loop; the
|
||||
// on-disk CLI already reads ready, so a lost marker here means
|
||||
// no later trigger would ever restart a failed gateway.
|
||||
@@ -166,6 +177,8 @@ final class CLIInstallPrompter {
|
||||
"OpenClaw is installed. The Gateway will start when This Mac is active and resumed."
|
||||
case .failed:
|
||||
"OpenClaw was installed, but the Gateway did not start. Open Settings to retry."
|
||||
case nil:
|
||||
"OpenClaw CLI is ready for the Mac node."
|
||||
}
|
||||
await status.set(message)
|
||||
if !showCompletionAlert {
|
||||
@@ -193,6 +206,7 @@ final class CLIInstallPrompter {
|
||||
}
|
||||
guard Self.launchAgentUsesManagedCLI(
|
||||
programArguments: GatewayLaunchAgentManager.launchdConfigSnapshot()?.programArguments ?? []),
|
||||
AppStateStore.shared.connectionMode == .local,
|
||||
!GatewayLaunchAgentManager.isLaunchAgentWriteDisabled(),
|
||||
!AppStateStore.shared.isPaused
|
||||
else { return false }
|
||||
@@ -219,6 +233,17 @@ final class CLIInstallPrompter {
|
||||
UserDefaults.standard.removeObject(forKey: cliManagedRestartPendingKey)
|
||||
}
|
||||
|
||||
static func shouldManageCLI(connectionMode: AppState.ConnectionMode) -> Bool {
|
||||
connectionMode == .local || connectionMode == .remote
|
||||
}
|
||||
|
||||
static func shouldRestartManagedGateway(
|
||||
requested: Bool,
|
||||
connectionMode: AppState.ConnectionMode) -> Bool
|
||||
{
|
||||
requested && connectionMode == .local
|
||||
}
|
||||
|
||||
private func ensureManagedGatewayRestarted(previousPID: Int32?, status: StatusBox) async -> Bool {
|
||||
guard previousPID != nil else {
|
||||
await GatewayConnection.shared.shutdown()
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
static let openclawCLIInstalled = Notification.Name("openclaw.cli.installed")
|
||||
}
|
||||
|
||||
enum CLIInstallBuild {
|
||||
static var isDebug: Bool {
|
||||
#if DEBUG
|
||||
@@ -307,6 +311,7 @@ enum CLIInstaller {
|
||||
let summary = installedVersion.map { "Installed openclaw \($0)." } ?? "Installed openclaw."
|
||||
self.rememberInstallPolicy(target)
|
||||
await statusHandler(summary)
|
||||
NotificationCenter.default.post(name: .openclawCLIInstalled, object: nil)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -234,7 +234,14 @@ enum CommandResolver {
|
||||
#if DEBUG
|
||||
let root = projectRoot ?? self.projectRoot()
|
||||
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
|
||||
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
|
||||
if FileManager().isExecutableFile(atPath: candidate) {
|
||||
return candidate
|
||||
}
|
||||
// pnpm does not create a self-referential node_modules/.bin link for
|
||||
// this package. Source builds still need the checkout CLI, not a stale
|
||||
// globally installed binary with a different private command surface.
|
||||
let sourceEntrypoint = root.appendingPathComponent("openclaw.mjs").path
|
||||
return FileManager().isExecutableFile(atPath: sourceEntrypoint) ? sourceEntrypoint : nil
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
actor MacNodeBrowserProxy {
|
||||
static let shared = MacNodeBrowserProxy()
|
||||
|
||||
struct Endpoint {
|
||||
let baseURL: URL
|
||||
let token: String?
|
||||
let password: String?
|
||||
}
|
||||
|
||||
private struct RequestParams: Decodable {
|
||||
let method: String?
|
||||
let path: String?
|
||||
let query: [String: OpenClawProtocol.AnyCodable]?
|
||||
let body: OpenClawProtocol.AnyCodable?
|
||||
let timeoutMs: Int?
|
||||
let profile: String?
|
||||
}
|
||||
|
||||
private struct ProxyFilePayload {
|
||||
let path: String
|
||||
let base64: String
|
||||
let mimeType: String?
|
||||
|
||||
func asJSON() -> [String: Any] {
|
||||
var json: [String: Any] = [
|
||||
"path": self.path,
|
||||
"base64": self.base64,
|
||||
]
|
||||
if let mimeType = self.mimeType {
|
||||
json["mimeType"] = mimeType
|
||||
}
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
||||
private static let maxProxyFileBytes = 10 * 1024 * 1024
|
||||
private let endpointProvider: @Sendable () -> Endpoint
|
||||
private let performRequest: @Sendable (URLRequest) async throws -> (Data, URLResponse)
|
||||
|
||||
init(
|
||||
session: URLSession = .shared,
|
||||
endpointProvider: (@Sendable () -> Endpoint)? = nil,
|
||||
performRequest: (@Sendable (URLRequest) async throws -> (Data, URLResponse))? = nil)
|
||||
{
|
||||
self.endpointProvider = endpointProvider ?? MacNodeBrowserProxy.defaultEndpoint
|
||||
self.performRequest = performRequest ?? { request in
|
||||
try await session.data(for: request)
|
||||
}
|
||||
}
|
||||
|
||||
func request(paramsJSON: String?) async throws -> String {
|
||||
let params = try Self.decodeRequestParams(from: paramsJSON)
|
||||
let endpoint = self.endpointProvider()
|
||||
let request = try Self.makeRequest(params: params, endpoint: endpoint)
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await self.performRequest(request)
|
||||
} catch {
|
||||
throw Self.unavailableError(endpoint: endpoint, cause: error)
|
||||
}
|
||||
let http = try Self.requireHTTPResponse(response)
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: http.statusCode, userInfo: [
|
||||
NSLocalizedDescriptionKey: Self.httpErrorMessage(statusCode: http.statusCode, data: data),
|
||||
])
|
||||
}
|
||||
|
||||
let result = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
|
||||
let files = try Self.loadProxyFiles(from: result)
|
||||
var payload: [String: Any] = ["result": result]
|
||||
if !files.isEmpty {
|
||||
payload["files"] = files.map { $0.asJSON() }
|
||||
}
|
||||
let payloadData = try JSONSerialization.data(withJSONObject: payload)
|
||||
guard let payloadJSON = String(data: payloadData, encoding: .utf8) else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "browser proxy returned invalid UTF-8",
|
||||
])
|
||||
}
|
||||
return payloadJSON
|
||||
}
|
||||
|
||||
private static func defaultEndpoint() -> Endpoint {
|
||||
let config = GatewayEndpointStore.localConfig()
|
||||
let controlPort = GatewayEnvironment.gatewayPort() + 2
|
||||
let baseURL = URL(string: "http://127.0.0.1:\(controlPort)")!
|
||||
return Endpoint(baseURL: baseURL, token: config.token, password: config.password)
|
||||
}
|
||||
|
||||
private static func decodeRequestParams(from raw: String?) throws -> RequestParams {
|
||||
guard let raw else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
|
||||
])
|
||||
}
|
||||
return try JSONDecoder().decode(RequestParams.self, from: Data(raw.utf8))
|
||||
}
|
||||
|
||||
private static func makeRequest(params: RequestParams, endpoint: Endpoint) throws -> URLRequest {
|
||||
let method = (params.method ?? "GET").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
let path = (params.path ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !path.isEmpty else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: path required",
|
||||
])
|
||||
}
|
||||
|
||||
let normalizedPath = path.hasPrefix("/") ? path : "/\(path)"
|
||||
guard var components = URLComponents(
|
||||
url: endpoint.baseURL.appendingPathComponent(String(normalizedPath.dropFirst())),
|
||||
resolvingAgainstBaseURL: false)
|
||||
else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: invalid browser proxy URL",
|
||||
])
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem] = []
|
||||
if let query = params.query {
|
||||
for key in query.keys.sorted() {
|
||||
let value = query[key]?.value
|
||||
guard value != nil, !(value is NSNull) else { continue }
|
||||
queryItems.append(URLQueryItem(name: key, value: Self.stringValue(for: value)))
|
||||
}
|
||||
}
|
||||
let profile = params.profile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !profile.isEmpty, !queryItems.contains(where: { $0.name == "profile" }) {
|
||||
queryItems.append(URLQueryItem(name: "profile", value: profile))
|
||||
}
|
||||
if !queryItems.isEmpty {
|
||||
components.queryItems = queryItems
|
||||
}
|
||||
guard let url = components.url else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 5, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: invalid browser proxy URL",
|
||||
])
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.timeoutInterval = params.timeoutMs.map { TimeInterval(max($0, 1)) / 1000 } ?? 5
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let token = endpoint.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
} else if let password = endpoint.password?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!password.isEmpty
|
||||
{
|
||||
request.setValue(password, forHTTPHeaderField: "x-openclaw-password")
|
||||
}
|
||||
|
||||
if method != "GET", let body = params.body {
|
||||
request.httpBody = try JSONSerialization.data(
|
||||
withJSONObject: body.foundationValue,
|
||||
options: [.fragmentsAllowed])
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
private static func requireHTTPResponse(_ response: URLResponse) throws -> HTTPURLResponse {
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 6, userInfo: [
|
||||
NSLocalizedDescriptionKey: "browser proxy returned a non-HTTP response",
|
||||
])
|
||||
}
|
||||
return http
|
||||
}
|
||||
|
||||
private static func unavailableError(endpoint: Endpoint, cause: Error) -> NSError {
|
||||
let url = endpoint.baseURL.absoluteString
|
||||
let message = """
|
||||
UNAVAILABLE: macOS app node could not reach the local browser control service at \(url). \
|
||||
In remote mode, browser control is owned by the CLI node-host; start `openclaw node start` \
|
||||
on this Mac and target that browser node. Underlying error: \(cause.localizedDescription)
|
||||
"""
|
||||
return NSError(domain: "MacNodeBrowserProxy", code: 9, userInfo: [
|
||||
NSLocalizedDescriptionKey: message,
|
||||
NSUnderlyingErrorKey: cause,
|
||||
])
|
||||
}
|
||||
|
||||
private static func httpErrorMessage(statusCode: Int, data: Data) -> String {
|
||||
if let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? [String: Any],
|
||||
let error = object["error"] as? String,
|
||||
!error.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
return error
|
||||
}
|
||||
if let text = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty
|
||||
{
|
||||
return text
|
||||
}
|
||||
return "HTTP \(statusCode)"
|
||||
}
|
||||
|
||||
private static func stringValue(for value: Any?) -> String? {
|
||||
guard let value else { return nil }
|
||||
if let string = value as? String { return string }
|
||||
if let bool = value as? Bool { return bool ? "true" : "false" }
|
||||
if let number = value as? NSNumber { return number.stringValue }
|
||||
return String(describing: value)
|
||||
}
|
||||
|
||||
private static func loadProxyFiles(from result: Any) throws -> [ProxyFilePayload] {
|
||||
let paths = self.collectProxyPaths(from: result)
|
||||
return try paths.map(self.loadProxyFile)
|
||||
}
|
||||
|
||||
private static func collectProxyPaths(from payload: Any) -> [String] {
|
||||
guard let object = payload as? [String: Any] else { return [] }
|
||||
|
||||
var paths = Set<String>()
|
||||
if let path = object["path"] as? String, !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
paths.insert(path.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
if let imagePath = object["imagePath"] as? String,
|
||||
!imagePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
paths.insert(imagePath.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
if let download = object["download"] as? [String: Any],
|
||||
let path = download["path"] as? String,
|
||||
!path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
paths.insert(path.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
return paths.sorted()
|
||||
}
|
||||
|
||||
private static func loadProxyFile(path: String) throws -> ProxyFilePayload {
|
||||
let url = URL(fileURLWithPath: path)
|
||||
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
|
||||
guard values.isRegularFile == true else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 7, userInfo: [
|
||||
NSLocalizedDescriptionKey: "browser proxy file not found: \(path)",
|
||||
])
|
||||
}
|
||||
if let fileSize = values.fileSize, fileSize > Self.maxProxyFileBytes {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 8, userInfo: [
|
||||
NSLocalizedDescriptionKey: "browser proxy file exceeds 10MB: \(path)",
|
||||
])
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType
|
||||
return ProxyFilePayload(path: path, base64: data.base64EncodedString(), mimeType: mimeType)
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum MacNodeFileSystemCommands {
|
||||
private struct ListDirectoryParams: Decodable {
|
||||
var path: String?
|
||||
}
|
||||
|
||||
private struct DirectoryEntry: Encodable {
|
||||
var name: String
|
||||
var path: String
|
||||
var hidden: Bool?
|
||||
}
|
||||
|
||||
private struct ListDirectoryPayload: Encodable {
|
||||
var path: String
|
||||
var parent: String?
|
||||
var home: String
|
||||
var entries: [DirectoryEntry]
|
||||
}
|
||||
|
||||
static func listDirectory(_ request: BridgeInvokeRequest) throws -> BridgeInvokeResponse {
|
||||
let params: ListDirectoryParams = if let paramsJSON = request.paramsJSON {
|
||||
try self.decodeParams(paramsJSON)
|
||||
} else {
|
||||
ListDirectoryParams(path: nil)
|
||||
}
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
let requested = params.path?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let rawPath = requested.flatMap { $0.isEmpty ? nil : $0 } ?? home
|
||||
guard NSString(string: rawPath).isAbsolutePath else {
|
||||
return self.errorResponse(
|
||||
request,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: fs.listDir path must be absolute")
|
||||
}
|
||||
|
||||
let directory = URL(fileURLWithPath: rawPath, isDirectory: true).standardizedFileURL
|
||||
let children = try FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [])
|
||||
var entries: [DirectoryEntry] = []
|
||||
for child in children {
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: child.path, isDirectory: &isDirectory),
|
||||
isDirectory.boolValue
|
||||
else { continue }
|
||||
let name = child.lastPathComponent
|
||||
entries.append(DirectoryEntry(
|
||||
name: name,
|
||||
path: directory.appendingPathComponent(name, isDirectory: true).path,
|
||||
hidden: name.hasPrefix(".") ? true : nil))
|
||||
}
|
||||
entries.sort { lhs, rhs in
|
||||
if (lhs.hidden != nil) != (rhs.hidden != nil) {
|
||||
return lhs.hidden == nil
|
||||
}
|
||||
return lhs.name.utf8.lexicographicallyPrecedes(rhs.name.utf8)
|
||||
}
|
||||
|
||||
let parentPath = directory.deletingLastPathComponent().path
|
||||
let payload = ListDirectoryPayload(
|
||||
path: directory.path,
|
||||
parent: parentPath == directory.path ? nil : parentPath,
|
||||
home: home,
|
||||
entries: entries)
|
||||
return try BridgeInvokeResponse(
|
||||
id: request.id,
|
||||
ok: true,
|
||||
payloadJSON: self.encodePayload(payload))
|
||||
}
|
||||
|
||||
private static func decodeParams(_ paramsJSON: String) throws -> ListDirectoryParams {
|
||||
guard let data = paramsJSON.data(using: .utf8) else {
|
||||
throw NSError(domain: "Gateway", code: 20, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
|
||||
])
|
||||
}
|
||||
return try JSONDecoder().decode(ListDirectoryParams.self, from: data)
|
||||
}
|
||||
|
||||
private static func encodePayload(_ payload: ListDirectoryPayload) throws -> String {
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "Node", code: 21, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8",
|
||||
])
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
private static func errorResponse(
|
||||
_ request: BridgeInvokeRequest,
|
||||
code: OpenClawNodeErrorCode,
|
||||
message: String) -> BridgeInvokeResponse
|
||||
{
|
||||
BridgeInvokeResponse(
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: code, message: message))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
|
||||
extension Notification.Name {
|
||||
static let openclawNodeHostWorkerFailed = Notification.Name("openclaw.node-host-worker.failed")
|
||||
}
|
||||
|
||||
struct MacNodeHostManifest: Equatable, Sendable {
|
||||
let version: String
|
||||
let caps: [String]
|
||||
let commands: [String]
|
||||
let pathEnv: String
|
||||
}
|
||||
|
||||
protocol MacNodeHostWorking: Sendable {
|
||||
func start(command: [String]) async throws -> MacNodeHostManifest
|
||||
func supports(_ command: String) async -> Bool
|
||||
func invoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse
|
||||
func setRoute(_ route: GatewayNodeSessionRoute?, authorityGeneration: UInt64) async -> Bool
|
||||
func publishInventory(ifCurrentRoute route: GatewayNodeSessionRoute) async
|
||||
func stop() async
|
||||
}
|
||||
|
||||
/// Runs the canonical TypeScript node-host runtime as an app-owned JSONL worker.
|
||||
/// The worker never connects to Gateway; this app remains the sole node identity
|
||||
/// and keeps TCC-sensitive execution behind the native exec-host socket.
|
||||
final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable {
|
||||
enum WorkerError: LocalizedError {
|
||||
case unavailable(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .unavailable(message): message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "node-host-worker")
|
||||
private let queue = DispatchQueue(label: "ai.openclaw.node-host-worker")
|
||||
private let writerQueue = DispatchQueue(label: "ai.openclaw.node-host-worker.writer")
|
||||
private let session: GatewayNodeSession
|
||||
private let onUnexpectedExit: @Sendable () -> Void
|
||||
private var process: Process?
|
||||
private var stdinPipe: Pipe?
|
||||
private var stdoutPipe: Pipe?
|
||||
private var stderrPipe: Pipe?
|
||||
private var stdoutSource: DispatchSourceRead?
|
||||
private var stderrSource: DispatchSourceRead?
|
||||
private var processGeneration: UUID?
|
||||
private var launchedCommand: [String]?
|
||||
private var stdoutBuffer = Data()
|
||||
private var manifest: MacNodeHostManifest?
|
||||
private var inventoryData: Data?
|
||||
private var route: GatewayNodeSessionRoute?
|
||||
private var routeAuthorityGeneration: UInt64 = 0
|
||||
private var startContinuation: CheckedContinuation<MacNodeHostManifest, Error>?
|
||||
private var invokeContinuations: [String: CheckedContinuation<BridgeInvokeResponse, Never>] = [:]
|
||||
private var startTimer: DispatchSourceTimer?
|
||||
private var eventDeliveryTask: Task<Void, Never>?
|
||||
private var inventoryPublicationTask: Task<Void, Never>?
|
||||
private var inventoryPublicationGeneration: UInt64 = 0
|
||||
private var stopping = false
|
||||
|
||||
init(
|
||||
session: GatewayNodeSession,
|
||||
onUnexpectedExit: @escaping @Sendable () -> Void = {})
|
||||
{
|
||||
self.session = session
|
||||
self.onUnexpectedExit = onUnexpectedExit
|
||||
}
|
||||
|
||||
func start(command: [String]) async throws -> MacNodeHostManifest {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.queue.async {
|
||||
if let manifest = self.manifest,
|
||||
self.process?.isRunning == true,
|
||||
self.launchedCommand == command
|
||||
{
|
||||
continuation.resume(returning: manifest)
|
||||
return
|
||||
}
|
||||
guard self.startContinuation == nil else {
|
||||
continuation.resume(throwing: WorkerError.unavailable("node-host worker is already starting"))
|
||||
return
|
||||
}
|
||||
self.startContinuation = continuation
|
||||
self.startLocked(command: command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func supports(_ command: String) async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.queue.async {
|
||||
continuation.resume(returning: self.manifest?.commands.contains(command) == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func invoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.queue.async {
|
||||
guard self.process?.isRunning == true, self.manifest != nil else {
|
||||
continuation.resume(returning: Self.unavailableResponse(
|
||||
request.id,
|
||||
"UNAVAILABLE: node-host worker is not running"))
|
||||
return
|
||||
}
|
||||
guard self.invokeContinuations[request.id] == nil else {
|
||||
continuation.resume(returning: Self.unavailableResponse(
|
||||
request.id,
|
||||
"UNAVAILABLE: duplicate node-host worker request"))
|
||||
return
|
||||
}
|
||||
self.invokeContinuations[request.id] = continuation
|
||||
do {
|
||||
let workerRequest: [String: Any] = [
|
||||
"id": request.id,
|
||||
"nodeId": request.nodeId ?? "",
|
||||
"command": request.command,
|
||||
"paramsJSON": request.paramsJSON ?? NSNull(),
|
||||
]
|
||||
try self.enqueueWriteLocked([
|
||||
"type": "invoke",
|
||||
"request": workerRequest,
|
||||
])
|
||||
} catch {
|
||||
self.invokeContinuations.removeValue(forKey: request.id)?.resume(returning:
|
||||
Self.unavailableResponse(request.id, "UNAVAILABLE: node-host worker write failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setRoute(_ route: GatewayNodeSessionRoute?, authorityGeneration: UInt64) async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.queue.async {
|
||||
guard Self.routeUpdateIsCurrent(
|
||||
candidateGeneration: authorityGeneration,
|
||||
currentGeneration: self.routeAuthorityGeneration)
|
||||
else {
|
||||
continuation.resume(returning: false)
|
||||
return
|
||||
}
|
||||
self.routeAuthorityGeneration = authorityGeneration
|
||||
self.route = route
|
||||
self.inventoryPublicationGeneration &+= 1
|
||||
self.inventoryPublicationTask?.cancel()
|
||||
self.inventoryPublicationTask = nil
|
||||
self.eventDeliveryTask?.cancel()
|
||||
self.eventDeliveryTask = nil
|
||||
continuation.resume(returning: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func routeUpdateIsCurrent(
|
||||
candidateGeneration: UInt64,
|
||||
currentGeneration: UInt64) -> Bool
|
||||
{
|
||||
candidateGeneration >= currentGeneration
|
||||
}
|
||||
|
||||
func publishInventory(ifCurrentRoute route: GatewayNodeSessionRoute) async {
|
||||
let publication: Task<Void, Never>? = await withCheckedContinuation { continuation in
|
||||
self.queue.async {
|
||||
guard let inventoryData = self.inventoryData else {
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: self.scheduleInventoryPublicationLocked(
|
||||
inventoryData,
|
||||
route: route))
|
||||
}
|
||||
}
|
||||
await publication?.value
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.queue.async {
|
||||
self.stopLocked(reason: "worker stopped")
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startLocked(command: [String]) {
|
||||
guard let executable = command.first, !executable.isEmpty else {
|
||||
self.finishStartLocked(.failure(WorkerError.unavailable("node-host worker command missing")))
|
||||
return
|
||||
}
|
||||
self.stopLocked(reason: "worker restarted", preserveStart: true)
|
||||
self.stopping = false
|
||||
|
||||
let process = Process()
|
||||
let stdinPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = Array(command.dropFirst())
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
environment["OPENCLAW_NODE_EXEC_HOST"] = "app"
|
||||
environment["OPENCLAW_NODE_EXEC_FALLBACK"] = "0"
|
||||
process.environment = environment
|
||||
process.standardInput = stdinPipe
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
self.process = process
|
||||
self.launchedCommand = command
|
||||
self.stdinPipe = stdinPipe
|
||||
self.stdoutPipe = stdoutPipe
|
||||
self.stderrPipe = stderrPipe
|
||||
let processGeneration = UUID()
|
||||
self.processGeneration = processGeneration
|
||||
|
||||
process.terminationHandler = { [weak self] process in
|
||||
guard let self else { return }
|
||||
self.queue.async {
|
||||
guard self.process === process else { return }
|
||||
self.stopLocked(
|
||||
reason: "worker exited with status \(process.terminationStatus)",
|
||||
notifyUnexpectedExit: true)
|
||||
}
|
||||
}
|
||||
|
||||
let timer = DispatchSource.makeTimerSource(queue: self.queue)
|
||||
timer.schedule(deadline: .now() + 20)
|
||||
timer.setEventHandler { [weak self] in
|
||||
guard let self else { return }
|
||||
let state = self.process?.isRunning == true ? "running" : "exited"
|
||||
self.finishStartLocked(.failure(WorkerError.unavailable(
|
||||
"node-host worker startup timed out (process \(state), buffered \(self.stdoutBuffer.count) bytes)")))
|
||||
self.stopLocked(reason: "worker startup timed out")
|
||||
}
|
||||
self.startTimer = timer
|
||||
timer.resume()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
let stdoutSource = DispatchSource.makeReadSource(
|
||||
fileDescriptor: stdoutPipe.fileHandleForReading.fileDescriptor,
|
||||
queue: self.queue)
|
||||
stdoutSource.setEventHandler { [weak self] in
|
||||
guard let self, self.processGeneration == processGeneration else { return }
|
||||
let data = Self.readAvailable(
|
||||
fileDescriptor: stdoutPipe.fileHandleForReading.fileDescriptor,
|
||||
byteCount: stdoutSource.data)
|
||||
if data.isEmpty {
|
||||
self.stdoutSource?.cancel()
|
||||
} else {
|
||||
self.consumeStdoutLocked(data)
|
||||
}
|
||||
}
|
||||
self.stdoutSource = stdoutSource
|
||||
stdoutSource.resume()
|
||||
|
||||
let stderrSource = DispatchSource.makeReadSource(
|
||||
fileDescriptor: stderrPipe.fileHandleForReading.fileDescriptor,
|
||||
queue: self.queue)
|
||||
stderrSource.setEventHandler { [weak self] in
|
||||
guard let self, self.processGeneration == processGeneration else { return }
|
||||
let data = Self.readAvailable(
|
||||
fileDescriptor: stderrPipe.fileHandleForReading.fileDescriptor,
|
||||
byteCount: stderrSource.data)
|
||||
guard !data.isEmpty else {
|
||||
self.stderrSource?.cancel()
|
||||
return
|
||||
}
|
||||
if let message = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!message.isEmpty
|
||||
{
|
||||
self.logger.error("node-host worker stderr: \(message, privacy: .private)")
|
||||
}
|
||||
}
|
||||
self.stderrSource = stderrSource
|
||||
stderrSource.resume()
|
||||
try? stdinPipe.fileHandleForReading.close()
|
||||
try? stdoutPipe.fileHandleForWriting.close()
|
||||
try? stderrPipe.fileHandleForWriting.close()
|
||||
} catch {
|
||||
self.finishStartLocked(.failure(WorkerError.unavailable("node-host worker launch failed")))
|
||||
self.stopLocked(reason: "worker launch failed")
|
||||
}
|
||||
}
|
||||
|
||||
private func consumeStdoutLocked(_ data: Data) {
|
||||
self.stdoutBuffer.append(data)
|
||||
guard self.stdoutBuffer.count <= 25 * 1024 * 1024 else {
|
||||
self.stopLocked(reason: "worker response exceeded limit", notifyUnexpectedExit: true)
|
||||
return
|
||||
}
|
||||
while let newline = self.stdoutBuffer.firstIndex(of: 0x0A) {
|
||||
let line = self.stdoutBuffer.prefix(upTo: newline)
|
||||
self.stdoutBuffer.removeSubrange(...newline)
|
||||
guard !line.isEmpty,
|
||||
let message = try? JSONSerialization.jsonObject(with: Data(line)) as? [String: Any]
|
||||
else { continue }
|
||||
self.handleMessageLocked(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMessageLocked(_ message: [String: Any]) {
|
||||
switch message["type"] as? String {
|
||||
case "ready":
|
||||
guard let version = message["version"] as? String,
|
||||
let rawManifest = message["manifest"] as? [String: Any],
|
||||
let caps = rawManifest["caps"] as? [String],
|
||||
let commands = rawManifest["commands"] as? [String],
|
||||
let pathEnv = rawManifest["pathEnv"] as? String
|
||||
else {
|
||||
self.stopLocked(reason: "worker returned invalid manifest")
|
||||
return
|
||||
}
|
||||
let manifest = MacNodeHostManifest(version: version, caps: caps, commands: commands, pathEnv: pathEnv)
|
||||
self.manifest = manifest
|
||||
self.inventoryData = (message["inventory"] as? [String: Any]).flatMap(Self.jsonData)
|
||||
self.finishStartLocked(.success(manifest))
|
||||
case "inventory":
|
||||
guard let inventory = message["inventory"] as? [String: Any],
|
||||
let inventoryData = Self.jsonData(inventory)
|
||||
else { return }
|
||||
self.inventoryData = inventoryData
|
||||
if let route = self.route {
|
||||
self.scheduleInventoryPublicationLocked(inventoryData, route: route)
|
||||
}
|
||||
case "invoke-result":
|
||||
guard let result = message["result"] as? [String: Any],
|
||||
let id = result["id"] as? String,
|
||||
let continuation = self.invokeContinuations.removeValue(forKey: id)
|
||||
else { return }
|
||||
continuation.resume(returning: Self.decodeInvokeResponse(result, id: id))
|
||||
case "node-event":
|
||||
guard let event = message["event"] as? [String: Any],
|
||||
let name = event["event"] as? String,
|
||||
let route = self.route
|
||||
else { return }
|
||||
let payload = event["payloadJSON"] as? String
|
||||
let previous = self.eventDeliveryTask
|
||||
let session = self.session
|
||||
let delivery = Task {
|
||||
await previous?.value
|
||||
guard !Task.isCancelled else { return }
|
||||
_ = await session.sendEvent(
|
||||
event: name,
|
||||
payloadJSON: payload,
|
||||
ifCurrentRoute: route)
|
||||
}
|
||||
self.eventDeliveryTask = delivery
|
||||
case "gateway-request":
|
||||
guard let id = message["id"] as? String,
|
||||
let method = message["method"] as? String
|
||||
else { return }
|
||||
guard let route = self.route else {
|
||||
self.writeGatewayUnavailableLocked(id: id)
|
||||
return
|
||||
}
|
||||
guard let paramsData = Self.jsonData(message["params"] ?? [:]),
|
||||
let processGeneration = self.processGeneration
|
||||
else {
|
||||
self.writeGatewayUnavailableLocked(id: id)
|
||||
return
|
||||
}
|
||||
let timeoutMs = (message["timeoutMs"] as? NSNumber)?.intValue ?? 15000
|
||||
Task {
|
||||
await self.handleGatewayRequest(
|
||||
id: id,
|
||||
method: method,
|
||||
paramsData: paramsData,
|
||||
timeoutMs: timeoutMs,
|
||||
route: route,
|
||||
processGeneration: processGeneration)
|
||||
}
|
||||
case "protocol-error":
|
||||
self.logger.error("node-host worker rejected a protocol frame")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleGatewayRequest(
|
||||
id: String,
|
||||
method: String,
|
||||
paramsData: Data,
|
||||
timeoutMs: Int,
|
||||
route: GatewayNodeSessionRoute,
|
||||
processGeneration: UUID) async
|
||||
{
|
||||
do {
|
||||
guard let paramsJSON = String(bytes: paramsData, encoding: .utf8) else {
|
||||
throw WorkerError.unavailable("node-host worker gateway request was not UTF-8")
|
||||
}
|
||||
let data = try await self.session.request(
|
||||
method: method,
|
||||
paramsJSON: paramsJSON,
|
||||
timeoutSeconds: max(1, Int(ceil(Double(timeoutMs) / 1000.0))),
|
||||
ifCurrentRoute: route,
|
||||
distinguishPreDispatchRouteChange: true)
|
||||
self.queue.async {
|
||||
// A replacement worker restarts request ids. Never deliver an old
|
||||
// route response into the replacement process.
|
||||
guard self.processGeneration == processGeneration else { return }
|
||||
guard let result = try? JSONSerialization.jsonObject(with: data) else { return }
|
||||
try? self.enqueueWriteLocked([
|
||||
"type": "gateway-response",
|
||||
"id": id,
|
||||
"ok": true,
|
||||
"result": result,
|
||||
])
|
||||
}
|
||||
} catch {
|
||||
self.queue.async {
|
||||
guard self.processGeneration == processGeneration else { return }
|
||||
self.writeGatewayUnavailableLocked(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func writeGatewayUnavailableLocked(id: String) {
|
||||
try? self.enqueueWriteLocked([
|
||||
"type": "gateway-response",
|
||||
"id": id,
|
||||
"ok": false,
|
||||
"error": "Gateway request unavailable",
|
||||
])
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func scheduleInventoryPublicationLocked(
|
||||
_ inventoryData: Data,
|
||||
route: GatewayNodeSessionRoute) -> Task<Void, Never>
|
||||
{
|
||||
self.inventoryPublicationGeneration &+= 1
|
||||
let generation = self.inventoryPublicationGeneration
|
||||
let previous = self.inventoryPublicationTask
|
||||
let publication = Task { [weak self] in
|
||||
await previous?.value
|
||||
guard let self,
|
||||
!Task.isCancelled,
|
||||
await self.inventoryPublicationIsCurrent(generation, route: route)
|
||||
else { return }
|
||||
await self.sendInventory(inventoryData, route: route)
|
||||
}
|
||||
self.inventoryPublicationTask = publication
|
||||
return publication
|
||||
}
|
||||
|
||||
private func inventoryPublicationIsCurrent(
|
||||
_ generation: UInt64,
|
||||
route: GatewayNodeSessionRoute) async -> Bool
|
||||
{
|
||||
await withCheckedContinuation { continuation in
|
||||
self.queue.async {
|
||||
continuation.resume(returning:
|
||||
self.inventoryPublicationGeneration == generation && self.route == route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendInventory(_ inventoryData: Data, route: GatewayNodeSessionRoute) async {
|
||||
guard let inventory = try? JSONSerialization.jsonObject(with: inventoryData) as? [String: Any] else { return }
|
||||
if let skills = inventory["skills"], !(skills is NSNull),
|
||||
let paramsJSON = Self.paramsJSON(["skills": skills])
|
||||
{
|
||||
_ = try? await self.session.request(
|
||||
method: "node.skills.update",
|
||||
paramsJSON: paramsJSON,
|
||||
ifCurrentRoute: route)
|
||||
}
|
||||
if let tools = inventory["pluginTools"] as? [Any],
|
||||
let paramsJSON = Self.paramsJSON(["tools": tools])
|
||||
{
|
||||
_ = try? await self.session.request(
|
||||
method: "node.pluginTools.update",
|
||||
paramsJSON: paramsJSON,
|
||||
ifCurrentRoute: route)
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueueWriteLocked(_ object: [String: Any]) throws {
|
||||
guard let handle = self.stdinPipe?.fileHandleForWriting,
|
||||
self.process?.isRunning == true,
|
||||
let processGeneration = self.processGeneration
|
||||
else {
|
||||
throw WorkerError.unavailable("node-host worker is not running")
|
||||
}
|
||||
var data = try JSONSerialization.data(withJSONObject: object)
|
||||
data.append(0x0A)
|
||||
let frame = data
|
||||
self.writerQueue.async { [weak self] in
|
||||
do {
|
||||
try handle.write(contentsOf: frame)
|
||||
} catch {
|
||||
self?.queue.async { [weak self] in
|
||||
guard let self, self.processGeneration == processGeneration else { return }
|
||||
self.stopLocked(reason: "worker input write failed", notifyUnexpectedExit: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishStartLocked(_ result: Result<MacNodeHostManifest, Error>) {
|
||||
self.startTimer?.cancel()
|
||||
self.startTimer = nil
|
||||
self.eventDeliveryTask?.cancel()
|
||||
self.eventDeliveryTask = nil
|
||||
self.inventoryPublicationGeneration &+= 1
|
||||
self.inventoryPublicationTask?.cancel()
|
||||
self.inventoryPublicationTask = nil
|
||||
guard let continuation = self.startContinuation else { return }
|
||||
self.startContinuation = nil
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
|
||||
private func stopLocked(
|
||||
reason: String,
|
||||
preserveStart: Bool = false,
|
||||
notifyUnexpectedExit: Bool = false)
|
||||
{
|
||||
guard !self.stopping else { return }
|
||||
let wasReady = self.manifest != nil
|
||||
self.stopping = true
|
||||
self.startTimer?.cancel()
|
||||
self.startTimer = nil
|
||||
self.stdoutSource?.cancel()
|
||||
self.stdoutSource = nil
|
||||
self.stderrSource?.cancel()
|
||||
self.stderrSource = nil
|
||||
try? self.stdinPipe?.fileHandleForWriting.close()
|
||||
try? self.stdinPipe?.fileHandleForReading.close()
|
||||
try? self.stdoutPipe?.fileHandleForReading.close()
|
||||
try? self.stdoutPipe?.fileHandleForWriting.close()
|
||||
try? self.stderrPipe?.fileHandleForReading.close()
|
||||
try? self.stderrPipe?.fileHandleForWriting.close()
|
||||
if self.process?.isRunning == true {
|
||||
self.process?.terminate()
|
||||
}
|
||||
self.process = nil
|
||||
self.launchedCommand = nil
|
||||
self.stdinPipe = nil
|
||||
self.stdoutPipe = nil
|
||||
self.stderrPipe = nil
|
||||
self.processGeneration = nil
|
||||
self.stdoutBuffer.removeAll(keepingCapacity: false)
|
||||
self.manifest = nil
|
||||
self.inventoryData = nil
|
||||
self.route = nil
|
||||
if !preserveStart {
|
||||
self.finishStartLocked(.failure(WorkerError.unavailable(reason)))
|
||||
}
|
||||
let pending = self.invokeContinuations
|
||||
self.invokeContinuations.removeAll()
|
||||
for (id, continuation) in pending {
|
||||
continuation.resume(returning: Self.unavailableResponse(id, "UNAVAILABLE: node-host worker stopped"))
|
||||
}
|
||||
if notifyUnexpectedExit, wasReady {
|
||||
self.onUnexpectedExit()
|
||||
}
|
||||
}
|
||||
|
||||
private static func decodeInvokeResponse(_ result: [String: Any], id: String) -> BridgeInvokeResponse {
|
||||
let ok = result["ok"] as? Bool ?? false
|
||||
let payload = result["payload"].map(AnyCodable.init)
|
||||
let payloadJSON = result["payloadJSON"] as? String
|
||||
let rawError = result["error"] as? [String: Any]
|
||||
let code = OpenClawNodeErrorCode(rawValue: rawError?["code"] as? String ?? "UNAVAILABLE") ?? .unavailable
|
||||
let error = ok ? nil : OpenClawNodeError(
|
||||
code: code,
|
||||
message: rawError?["message"] as? String ?? "UNAVAILABLE: node-host worker failed")
|
||||
return BridgeInvokeResponse(id: id, ok: ok, payload: payload, payloadJSON: payloadJSON, error: error)
|
||||
}
|
||||
|
||||
private static func unavailableResponse(_ id: String, _ message: String) -> BridgeInvokeResponse {
|
||||
BridgeInvokeResponse(
|
||||
id: id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .unavailable, message: message))
|
||||
}
|
||||
|
||||
private static func paramsJSON(_ object: [String: Any]) -> String? {
|
||||
guard let data = self.jsonData(object) else { return nil }
|
||||
return String(bytes: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private static func jsonData(_ object: Any) -> Data? {
|
||||
guard JSONSerialization.isValidJSONObject(object) else { return nil }
|
||||
return try? JSONSerialization.data(withJSONObject: object)
|
||||
}
|
||||
|
||||
private static func readAvailable(fileDescriptor: Int32, byteCount: UInt) -> Data {
|
||||
let count = max(1, min(Int(byteCount), 64 * 1024))
|
||||
var data = Data(count: count)
|
||||
let bytesRead = data.withUnsafeMutableBytes { buffer in
|
||||
Darwin.read(fileDescriptor, buffer.baseAddress, count)
|
||||
}
|
||||
guard bytesRead > 0 else { return Data() }
|
||||
data.removeSubrange(bytesRead..<data.count)
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,7 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
private var lastObservedComputerControlEnabled: Bool
|
||||
private let runtime: MacNodeRuntime
|
||||
private let session: GatewayNodeSession
|
||||
private let nodeHostWorker: (any MacNodeHostWorking)?
|
||||
private let presenceReporter: MacNodePresenceReporter
|
||||
private let routeInvalidationHook: (@Sendable () async -> Void)?
|
||||
private let refreshEvents: AsyncStream<Void>
|
||||
@@ -112,11 +113,16 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
|
||||
override private convenience init() {
|
||||
let session = GatewayNodeSession()
|
||||
let nodeHostWorker = MacNodeHostWorker(session: session) {
|
||||
NotificationCenter.default.post(name: .openclawNodeHostWorkerFailed, object: nil)
|
||||
}
|
||||
self.init(
|
||||
session: session,
|
||||
runtime: MacNodeRuntime(
|
||||
nodeHostWorker: nodeHostWorker,
|
||||
canvasSurfaceUrl: { await session.currentCanvasHostUrl() },
|
||||
refreshCanvasSurfaceUrl: { await session.refreshCanvasHostUrl() }),
|
||||
nodeHostWorker: nodeHostWorker,
|
||||
presenceReporter: MacNodePresenceReporter(),
|
||||
observeNotifications: true,
|
||||
initialPaused: nil,
|
||||
@@ -127,6 +133,7 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
init(
|
||||
session: GatewayNodeSession,
|
||||
runtime: MacNodeRuntime,
|
||||
nodeHostWorker: (any MacNodeHostWorking)? = nil,
|
||||
presenceReporter: MacNodePresenceReporter = MacNodePresenceReporter(),
|
||||
observeNotifications: Bool = false,
|
||||
initialPaused: Bool? = nil,
|
||||
@@ -136,6 +143,7 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
let refreshEvents = AsyncStream.makeStream(of: Void.self, bufferingPolicy: .bufferingNewest(1))
|
||||
self.session = session
|
||||
self.runtime = runtime
|
||||
self.nodeHostWorker = nodeHostWorker
|
||||
self.presenceReporter = presenceReporter
|
||||
self.routeInvalidationHook = routeInvalidationHook
|
||||
self.refreshEvents = refreshEvents.stream
|
||||
@@ -161,6 +169,21 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
selector: #selector(self.refreshNodeConfiguration),
|
||||
name: .openclawPermissionsChanged,
|
||||
object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(self.nodeHostWorkerFailed),
|
||||
name: .openclawNodeHostWorkerFailed,
|
||||
object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(self.nodeHostConfigurationChanged),
|
||||
name: .openclawConfigDidChange,
|
||||
object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(self.nodeHostConfigurationChanged),
|
||||
name: .openclawCLIInstalled,
|
||||
object: nil)
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -196,11 +219,13 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
func stop() {
|
||||
self.cancelCoordinatorTasks()
|
||||
_ = self.enqueueRouteInvalidation(yieldRefresh: false)
|
||||
Task { await self.nodeHostWorker?.stop() }
|
||||
}
|
||||
|
||||
func stopAndWait() async {
|
||||
self.cancelCoordinatorTasks()
|
||||
await self.enqueueRouteInvalidation(yieldRefresh: false).value
|
||||
await self.nodeHostWorker?.stop()
|
||||
}
|
||||
|
||||
private func cancelCoordinatorTasks() {
|
||||
@@ -266,7 +291,10 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
/// Generation advances synchronously; disconnect then cancels active computer
|
||||
/// invokes and runs the held-input release hook before the latest refresh wakes.
|
||||
@discardableResult
|
||||
private func enqueueRouteInvalidation(yieldRefresh: Bool) -> Task<Void, Never> {
|
||||
private func enqueueRouteInvalidation(
|
||||
yieldRefresh: Bool,
|
||||
restartNodeHostWorker: Bool = false) -> Task<Void, Never>
|
||||
{
|
||||
self.revokeRouteAuthority()
|
||||
let invalidationGeneration = self.endpointAttemptGeneration
|
||||
let invalidatedRouteAuthorityGeneration = self.routeAuthorityGeneration
|
||||
@@ -275,7 +303,10 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
await previous?.value
|
||||
guard let self else { return }
|
||||
await self.session.disconnect()
|
||||
await self.invalidateRuntimeRoute()
|
||||
await self.invalidateRuntimeRoute(authorityGeneration: invalidatedRouteAuthorityGeneration)
|
||||
if restartNodeHostWorker {
|
||||
await self.nodeHostWorker?.stop()
|
||||
}
|
||||
self.completedRouteAuthorityGeneration = invalidatedRouteAuthorityGeneration
|
||||
guard yieldRefresh,
|
||||
invalidationGeneration == self.endpointAttemptGeneration,
|
||||
@@ -392,9 +423,9 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
routeRevision: routeRevision)
|
||||
}
|
||||
|
||||
private func invalidateRuntimeRoute() async {
|
||||
private func invalidateRuntimeRoute(authorityGeneration: UInt64) async {
|
||||
self.presenceReporter.stop()
|
||||
await self.runtime.setEventSender(nil)
|
||||
_ = await self.nodeHostWorker?.setRoute(nil, authorityGeneration: authorityGeneration)
|
||||
await self.runtime.releaseHeldComputerInput()
|
||||
await self.routeInvalidationHook?()
|
||||
}
|
||||
@@ -486,7 +517,8 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
codexThreadCatalogEnabled: Bool,
|
||||
claudeSessionCatalogEnabled: Bool) async throws -> ConnectionAttempt?
|
||||
{
|
||||
let caps = self.currentCaps(
|
||||
let workerManifest = try await self.startNodeHostWorkerIfConfigured()
|
||||
let nativeCaps = self.currentCaps(
|
||||
browserControlEnabled: browserControlEnabled,
|
||||
cameraEnabled: cameraEnabled,
|
||||
codexThreadCatalogEnabled: codexThreadCatalogEnabled,
|
||||
@@ -495,10 +527,13 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
// computer.act service is still holding rather than waiting for
|
||||
// the idle watchdog. This refresh loop re-runs on the settings
|
||||
// change that drops the cap.
|
||||
if !caps.contains(OpenClawCapability.computer.rawValue) {
|
||||
if !nativeCaps.contains(OpenClawCapability.computer.rawValue) {
|
||||
await self.runtime.releaseHeldComputerInput()
|
||||
}
|
||||
let commands = self.currentCommands(caps: caps)
|
||||
let caps = Self.mergingUnique(nativeCaps, workerManifest?.caps ?? [])
|
||||
let commands = Self.mergingUnique(
|
||||
self.currentCommands(caps: nativeCaps),
|
||||
workerManifest?.commands ?? [])
|
||||
let permissions = await self.currentPermissions()
|
||||
// TCC queries suspend. An endpoint loss/replacement during that
|
||||
// hop must not let this stale continuation install old credentials.
|
||||
@@ -516,6 +551,7 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
scopes: [],
|
||||
caps: caps,
|
||||
commands: commands,
|
||||
pathEnv: workerManifest?.pathEnv,
|
||||
permissions: permissions,
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "node",
|
||||
@@ -566,6 +602,12 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
// Capture this callback's admission before setup suspends. The
|
||||
// sender lease then drops already-captured events after replacement.
|
||||
guard let installedRoute = await self.session.currentRoute() else { return }
|
||||
guard await self.routeAuthorityAllowsInvoke(attempt.routeAuthorityGeneration) else { return }
|
||||
let workerRouteInstalled = await self.nodeHostWorker?.setRoute(
|
||||
installedRoute,
|
||||
authorityGeneration: attempt.routeAuthorityGeneration) ?? true
|
||||
guard workerRouteInstalled else { return }
|
||||
await self.nodeHostWorker?.publishInventory(ifCurrentRoute: installedRoute)
|
||||
await self.cancelReconnectProbe()
|
||||
self.logger.info("mac node connected to gateway")
|
||||
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
|
||||
@@ -573,13 +615,6 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
let routeStillAuthoritative = await self.routeAuthorityAllowsInvoke(attempt.routeAuthorityGeneration)
|
||||
let currentRoute = await self.session.currentRoute()
|
||||
guard routeStillAuthoritative, currentRoute == installedRoute else { return }
|
||||
await self.runtime.setEventSender { [weak self] event, payload in
|
||||
guard let self else { return }
|
||||
await self.session.sendEvent(
|
||||
event: event,
|
||||
payloadJSON: payload,
|
||||
ifCurrentRoute: installedRoute)
|
||||
}
|
||||
await self.presenceReporter.start { [weak self] event, payload in
|
||||
guard let self else { return false }
|
||||
return await self.session.sendEvent(
|
||||
@@ -590,7 +625,7 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
},
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await self.invalidateRuntimeRoute()
|
||||
await self.invalidateRuntimeRoute(authorityGeneration: attempt.routeAuthorityGeneration)
|
||||
await self.scheduleReconnectProbe()
|
||||
self.logger.error("mac node disconnected: \(reason, privacy: .public)")
|
||||
},
|
||||
@@ -637,7 +672,7 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
return await self.runtime.handleInvoke(req)
|
||||
},
|
||||
onRouteInvalidated: { [weak self] in
|
||||
await self?.invalidateRuntimeRoute()
|
||||
await self?.invalidateRuntimeRoute(authorityGeneration: attempt.routeAuthorityGeneration)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -730,38 +765,18 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func resolvedCaps(
|
||||
browserControlEnabled: Bool,
|
||||
cameraEnabled: Bool,
|
||||
computerControlEnabled: Bool,
|
||||
locationMode: OpenClawLocationMode,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
codexThreadCatalogEnabled: Bool = false,
|
||||
claudeSessionCatalogEnabled: Bool = false) -> [String]
|
||||
{
|
||||
var caps: [String] = [
|
||||
OpenClawCapability.canvas.rawValue,
|
||||
OpenClawCapability.screen.rawValue,
|
||||
]
|
||||
if browserControlEnabled, connectionMode == .local {
|
||||
caps.append(OpenClawCapability.browser.rawValue)
|
||||
@objc private nonisolated func nodeHostWorkerFailed(_: Notification) {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.enqueueRouteInvalidation(yieldRefresh: true)
|
||||
}
|
||||
if cameraEnabled { caps.append(OpenClawCapability.camera.rawValue) }
|
||||
// Advertised only when the operator has enabled Computer Control; the
|
||||
// command is dangerous and stays disarmed until allowlisted on the gateway.
|
||||
if computerControlEnabled {
|
||||
caps.append(OpenClawCapability.computer.rawValue)
|
||||
}
|
||||
|
||||
@objc private nonisolated func nodeHostConfigurationChanged(_: Notification) {
|
||||
Task { @MainActor [weak self] in
|
||||
// Worker code, plugin availability, and its manifest are startup-scoped.
|
||||
// Replace the process before reconnecting so updates cannot leave a stale route.
|
||||
self?.enqueueRouteInvalidation(yieldRefresh: true, restartNodeHostWorker: true)
|
||||
}
|
||||
if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) }
|
||||
// A local Gateway already catalogs this user's Codex home. Advertise the
|
||||
// node-owned catalog only when this Mac supplies it to a remote Gateway.
|
||||
if codexThreadCatalogEnabled, connectionMode == .remote {
|
||||
caps.append(MacNodeCodexThreadCatalogContract.capability)
|
||||
}
|
||||
if claudeSessionCatalogEnabled, connectionMode == .remote {
|
||||
caps.append(MacNodeClaudeSessionCatalogContract.capability)
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
private func currentCaps(
|
||||
@@ -788,55 +803,25 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) })
|
||||
}
|
||||
|
||||
nonisolated static func resolvedCommands(caps: [String]) -> [String] {
|
||||
var commands: [String] = [
|
||||
OpenClawCanvasCommand.present.rawValue,
|
||||
OpenClawCanvasCommand.hide.rawValue,
|
||||
OpenClawCanvasCommand.navigate.rawValue,
|
||||
OpenClawCanvasCommand.evalJS.rawValue,
|
||||
OpenClawCanvasCommand.snapshot.rawValue,
|
||||
OpenClawCanvasA2UICommand.push.rawValue,
|
||||
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
|
||||
OpenClawCanvasA2UICommand.reset.rawValue,
|
||||
MacNodeScreenCommand.snapshot.rawValue,
|
||||
MacNodeScreenCommand.record.rawValue,
|
||||
OpenClawSystemCommand.notify.rawValue,
|
||||
OpenClawSystemCommand.which.rawValue,
|
||||
OpenClawSystemCommand.run.rawValue,
|
||||
OpenClawSystemCommand.execApprovalsGet.rawValue,
|
||||
OpenClawSystemCommand.execApprovalsSet.rawValue,
|
||||
OpenClawFileSystemCommand.listDir.rawValue,
|
||||
]
|
||||
|
||||
let capsSet = Set(caps)
|
||||
if capsSet.contains(OpenClawCapability.browser.rawValue) {
|
||||
commands.append(OpenClawBrowserCommand.proxy.rawValue)
|
||||
}
|
||||
if capsSet.contains(OpenClawCapability.camera.rawValue) {
|
||||
commands.append(OpenClawCameraCommand.list.rawValue)
|
||||
commands.append(OpenClawCameraCommand.snap.rawValue)
|
||||
commands.append(OpenClawCameraCommand.clip.rawValue)
|
||||
}
|
||||
if capsSet.contains(OpenClawCapability.location.rawValue) {
|
||||
commands.append(OpenClawLocationCommand.get.rawValue)
|
||||
}
|
||||
if capsSet.contains(MacNodeCodexThreadCatalogContract.capability) {
|
||||
commands.append(contentsOf: MacNodeCodexThreadCatalogContract.commands)
|
||||
}
|
||||
if capsSet.contains(MacNodeClaudeSessionCatalogContract.capability) {
|
||||
commands.append(contentsOf: MacNodeClaudeSessionCatalogContract.commands)
|
||||
}
|
||||
if capsSet.contains(OpenClawCapability.computer.rawValue) {
|
||||
commands.append(OpenClawComputerCommand.act.rawValue)
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
private func currentCommands(caps: [String]) -> [String] {
|
||||
Self.resolvedCommands(caps: caps)
|
||||
}
|
||||
|
||||
private func startNodeHostWorkerIfConfigured() async throws -> MacNodeHostManifest? {
|
||||
guard let nodeHostWorker else { return nil }
|
||||
let executable: String
|
||||
if let projectExecutable = CommandResolver.projectOpenClawExecutable() {
|
||||
executable = projectExecutable
|
||||
} else {
|
||||
switch await CLIInstaller.status() {
|
||||
case let .ready(location, _): executable = location
|
||||
case let status:
|
||||
throw MacNodeHostWorker.WorkerError.unavailable(status.message)
|
||||
}
|
||||
}
|
||||
return try await nodeHostWorker.start(command: [executable, "node", "worker"])
|
||||
}
|
||||
|
||||
nonisolated static func tlsPinStoreKey(for url: URL) -> String {
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "gateway"
|
||||
let port = url.port ?? 443
|
||||
@@ -915,3 +900,79 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
return self.tlsSessionCache.sessionBox(url: url, params: params)
|
||||
}
|
||||
}
|
||||
|
||||
extension MacNodeModeCoordinator {
|
||||
nonisolated static func resolvedCaps(
|
||||
browserControlEnabled: Bool,
|
||||
cameraEnabled: Bool,
|
||||
computerControlEnabled: Bool,
|
||||
locationMode: OpenClawLocationMode,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
codexThreadCatalogEnabled: Bool = false,
|
||||
claudeSessionCatalogEnabled: Bool = false) -> [String]
|
||||
{
|
||||
var caps: [String] = [
|
||||
OpenClawCapability.canvas.rawValue,
|
||||
OpenClawCapability.screen.rawValue,
|
||||
]
|
||||
_ = browserControlEnabled
|
||||
if cameraEnabled { caps.append(OpenClawCapability.camera.rawValue) }
|
||||
// Advertised only when the operator has enabled Computer Control; the
|
||||
// command is dangerous and stays disarmed until allowlisted on the gateway.
|
||||
if computerControlEnabled {
|
||||
caps.append(OpenClawCapability.computer.rawValue)
|
||||
}
|
||||
if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) }
|
||||
// A local Gateway already catalogs this user's Codex home. Advertise the
|
||||
// node-owned catalog only when this Mac supplies it to a remote Gateway.
|
||||
if codexThreadCatalogEnabled, connectionMode == .remote {
|
||||
caps.append(MacNodeCodexThreadCatalogContract.capability)
|
||||
}
|
||||
if claudeSessionCatalogEnabled, connectionMode == .remote {
|
||||
caps.append(MacNodeClaudeSessionCatalogContract.capability)
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
nonisolated static func resolvedCommands(caps: [String]) -> [String] {
|
||||
var commands: [String] = [
|
||||
OpenClawCanvasCommand.present.rawValue,
|
||||
OpenClawCanvasCommand.hide.rawValue,
|
||||
OpenClawCanvasCommand.navigate.rawValue,
|
||||
OpenClawCanvasCommand.evalJS.rawValue,
|
||||
OpenClawCanvasCommand.snapshot.rawValue,
|
||||
OpenClawCanvasA2UICommand.push.rawValue,
|
||||
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
|
||||
OpenClawCanvasA2UICommand.reset.rawValue,
|
||||
MacNodeScreenCommand.snapshot.rawValue,
|
||||
MacNodeScreenCommand.record.rawValue,
|
||||
OpenClawSystemCommand.notify.rawValue,
|
||||
]
|
||||
|
||||
let capsSet = Set(caps)
|
||||
if capsSet.contains(OpenClawCapability.camera.rawValue) {
|
||||
commands.append(OpenClawCameraCommand.list.rawValue)
|
||||
commands.append(OpenClawCameraCommand.snap.rawValue)
|
||||
commands.append(OpenClawCameraCommand.clip.rawValue)
|
||||
}
|
||||
if capsSet.contains(OpenClawCapability.location.rawValue) {
|
||||
commands.append(OpenClawLocationCommand.get.rawValue)
|
||||
}
|
||||
if capsSet.contains(MacNodeCodexThreadCatalogContract.capability) {
|
||||
commands.append(contentsOf: MacNodeCodexThreadCatalogContract.commands)
|
||||
}
|
||||
if capsSet.contains(MacNodeClaudeSessionCatalogContract.capability) {
|
||||
commands.append(contentsOf: MacNodeClaudeSessionCatalogContract.commands)
|
||||
}
|
||||
if capsSet.contains(OpenClawCapability.computer.rawValue) {
|
||||
commands.append(OpenClawComputerCommand.act.rawValue)
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
nonisolated static func mergingUnique(_ primary: [String], _ additional: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
return (primary + additional).filter { seen.insert($0).inserted }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,20 +6,9 @@ import OpenClawKit
|
||||
actor MacNodeRuntime {
|
||||
private static let maxGatewayPayloadBytes = 25 * 1024 * 1024
|
||||
private static let maxScreenSnapshotRawBytesBeforeBase64 = (maxGatewayPayloadBytes / 4) * 3
|
||||
private struct ExecApprovalsNodeSnapshot: Encodable {
|
||||
let path: String
|
||||
let exists: Bool
|
||||
let hash: String
|
||||
let file: ExecApprovalsFile
|
||||
let resolvedDefaults: ExecApprovalsResolvedDefaults
|
||||
}
|
||||
|
||||
private let cameraCapture = CameraCaptureService()
|
||||
private let nodeHostWorker: (any MacNodeHostWorking)?
|
||||
private let makeMainActorServices: @Sendable () async -> any MacNodeRuntimeMainActorServices
|
||||
private let browserProxyRequest: @Sendable (String?) async throws -> String
|
||||
/// Injectable so tests can pin the gate instead of racing on process-global
|
||||
/// OPENCLAW_CONFIG_PATH; config parsing is covered by OpenClawConfigFileTests.
|
||||
private let browserControlEnabled: @Sendable () -> Bool
|
||||
// Injectable so tests pin the gate instead of racing on process-global UserDefaults.
|
||||
private let computerControlEnabled: @Sendable () -> Bool
|
||||
private let canvasHostedSurfaceResolver: MacNodeCanvasHostedSurfaceResolver
|
||||
@@ -29,12 +18,6 @@ actor MacNodeRuntime {
|
||||
private let claudeSessionCatalogEnabled: @Sendable () -> Bool
|
||||
private let claudeSessionListRequest: @Sendable (String?) async throws -> String
|
||||
private let claudeSessionReadRequest: @Sendable (String?) async throws -> String
|
||||
private let execApprovalStoreMutations: ExecApprovalStoreMutations
|
||||
private let shellRunner: @Sendable (
|
||||
_ command: [String],
|
||||
_ cwd: String?,
|
||||
_ env: [String: String]?,
|
||||
_ timeout: Double?) async -> ShellExecutor.ShellResult
|
||||
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
||||
/// Single-flight lazy initialization. Separate service instances would split
|
||||
/// ownership of held computer input and make lifecycle release incomplete.
|
||||
@@ -43,18 +26,12 @@ actor MacNodeRuntime {
|
||||
/// the first action while the shared main-actor services are still initializing.
|
||||
private var computerInputReleaseGeneration: UInt64 = 0
|
||||
private var mainSessionKey: String = "main"
|
||||
private var eventSender: (@Sendable (String, String?) async -> Void)?
|
||||
|
||||
init(
|
||||
nodeHostWorker: (any MacNodeHostWorking)? = nil,
|
||||
makeMainActorServices: @escaping @Sendable () async -> any MacNodeRuntimeMainActorServices = {
|
||||
await MainActor.run { LiveMacNodeRuntimeMainActorServices() }
|
||||
},
|
||||
browserProxyRequest: @escaping @Sendable (String?) async throws -> String = { paramsJSON in
|
||||
try await MacNodeBrowserProxy.shared.request(paramsJSON: paramsJSON)
|
||||
},
|
||||
browserControlEnabled: @escaping @Sendable () -> Bool = {
|
||||
OpenClawConfigFile.browserControlEnabled()
|
||||
},
|
||||
computerControlEnabled: @escaping @Sendable () -> Bool = {
|
||||
MacNodeRuntime.computerControlEnabledDefault()
|
||||
},
|
||||
@@ -79,19 +56,10 @@ actor MacNodeRuntime {
|
||||
},
|
||||
claudeSessionReadRequest: @escaping @Sendable (String?) async throws -> String = { paramsJSON in
|
||||
try MacNodeClaudeSessionCatalog.read(paramsJSON: paramsJSON)
|
||||
},
|
||||
execApprovalStoreMutations: ExecApprovalStoreMutations = .live,
|
||||
shellRunner: @escaping @Sendable (
|
||||
_ command: [String],
|
||||
_ cwd: String?,
|
||||
_ env: [String: String]?,
|
||||
_ timeout: Double?) async -> ShellExecutor.ShellResult = { command, cwd, env, timeout in
|
||||
await ShellExecutor.runDetailed(command: command, cwd: cwd, env: env, timeout: timeout)
|
||||
})
|
||||
{
|
||||
self.nodeHostWorker = nodeHostWorker
|
||||
self.makeMainActorServices = makeMainActorServices
|
||||
self.browserProxyRequest = browserProxyRequest
|
||||
self.browserControlEnabled = browserControlEnabled
|
||||
self.computerControlEnabled = computerControlEnabled
|
||||
self.canvasHostedSurfaceResolver = MacNodeCanvasHostedSurfaceResolver(
|
||||
currentSurfaceURL: canvasSurfaceUrl,
|
||||
@@ -102,8 +70,6 @@ actor MacNodeRuntime {
|
||||
self.claudeSessionCatalogEnabled = claudeSessionCatalogEnabled
|
||||
self.claudeSessionListRequest = claudeSessionListRequest
|
||||
self.claudeSessionReadRequest = claudeSessionReadRequest
|
||||
self.execApprovalStoreMutations = execApprovalStoreMutations
|
||||
self.shellRunner = shellRunner
|
||||
}
|
||||
|
||||
func updateMainSessionKey(_ sessionKey: String) {
|
||||
@@ -112,14 +78,12 @@ actor MacNodeRuntime {
|
||||
self.mainSessionKey = trimmed
|
||||
}
|
||||
|
||||
func setEventSender(_ sender: (@Sendable (String, String?) async -> Void)?) {
|
||||
self.eventSender = sender
|
||||
}
|
||||
|
||||
// One branch per advertised node command keeps command ownership explicit.
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
/// One branch per advertised native command keeps command ownership explicit.
|
||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
let command = req.command
|
||||
if let nodeHostWorker, await nodeHostWorker.supports(command) {
|
||||
return await nodeHostWorker.invoke(req)
|
||||
}
|
||||
if self.isCanvasCommand(command), !Self.canvasEnabled() {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
@@ -140,8 +104,6 @@ actor MacNodeRuntime {
|
||||
OpenClawCanvasA2UICommand.push.rawValue,
|
||||
OpenClawCanvasA2UICommand.pushJSONL.rawValue:
|
||||
return try await self.handleA2UIInvoke(req)
|
||||
case OpenClawBrowserCommand.proxy.rawValue:
|
||||
return try await self.handleBrowserProxyInvoke(req)
|
||||
case OpenClawCameraCommand.snap.rawValue,
|
||||
OpenClawCameraCommand.clip.rawValue,
|
||||
OpenClawCameraCommand.list.rawValue:
|
||||
@@ -154,18 +116,8 @@ actor MacNodeRuntime {
|
||||
return try await self.handleScreenRecordInvoke(req)
|
||||
case OpenClawComputerCommand.act.rawValue:
|
||||
return try await self.handleComputerActInvoke(req)
|
||||
case OpenClawSystemCommand.run.rawValue:
|
||||
return try await self.handleSystemRun(req)
|
||||
case OpenClawSystemCommand.which.rawValue:
|
||||
return try await self.handleSystemWhich(req)
|
||||
case OpenClawSystemCommand.notify.rawValue:
|
||||
return try await self.handleSystemNotify(req)
|
||||
case OpenClawSystemCommand.execApprovalsGet.rawValue:
|
||||
return try await self.handleSystemExecApprovalsGet(req)
|
||||
case OpenClawSystemCommand.execApprovalsSet.rawValue:
|
||||
return try await self.handleSystemExecApprovalsSet(req)
|
||||
case OpenClawFileSystemCommand.listDir.rawValue:
|
||||
return try MacNodeFileSystemCommands.listDirectory(req)
|
||||
case MacNodeCodexThreadCatalogContract.listCommand,
|
||||
MacNodeCodexThreadCatalogContract.turnsCommand:
|
||||
return try await self.handleCodexThreadInvoke(req)
|
||||
@@ -320,19 +272,6 @@ extension MacNodeRuntime {
|
||||
Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleBrowserProxyInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
guard self.browserControlEnabled() else {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(
|
||||
code: .unavailable,
|
||||
message: "BROWSER_DISABLED: enable Browser in Settings"))
|
||||
}
|
||||
let payloadJSON = try await browserProxyRequest(req.paramsJSON)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payloadJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Device command handling
|
||||
@@ -786,534 +725,9 @@ extension MacNodeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - System commands
|
||||
// MARK: - Native system notifications
|
||||
|
||||
extension MacNodeRuntime {
|
||||
private struct SystemRunPreparation {
|
||||
let params: OpenClawSystemRunParams
|
||||
let approvalSource: ExecApprovalRequestSource?
|
||||
let validatedCommand: ExecHostValidatedRequest
|
||||
let evaluation: ExecApprovalEvaluation
|
||||
let security: ExecSecurity
|
||||
let delayedPolicySnapshot: ExecApprovalPolicySnapshot?
|
||||
let sessionKey: String
|
||||
let runId: String
|
||||
}
|
||||
|
||||
private enum SystemRunPreparationResult {
|
||||
case prepared(SystemRunPreparation)
|
||||
case response(BridgeInvokeResponse)
|
||||
}
|
||||
|
||||
private func handleSystemRun(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
let prepared: SystemRunPreparation
|
||||
switch try await self.prepareSystemRun(req) {
|
||||
case let .prepared(result):
|
||||
prepared = result
|
||||
case let .response(response):
|
||||
return response
|
||||
}
|
||||
let params = prepared.params
|
||||
let approvalSource = prepared.approvalSource
|
||||
let command = prepared.validatedCommand.command
|
||||
let evaluation = prepared.evaluation
|
||||
let security = prepared.security
|
||||
let sessionKey = prepared.sessionKey
|
||||
let runId = prepared.runId
|
||||
|
||||
let approvedByAsk: Bool
|
||||
let persistAllowlist: Bool
|
||||
if approvalSource == .askFallback {
|
||||
approvedByAsk = false
|
||||
persistAllowlist = false
|
||||
} else if approvalSource == .autoReview {
|
||||
approvedByAsk = true
|
||||
persistAllowlist = false
|
||||
} else {
|
||||
let approval = await self.resolveSystemRunApproval(
|
||||
req: req,
|
||||
params: params,
|
||||
context: ExecRunContext(
|
||||
displayCommand: evaluation.displayCommand,
|
||||
security: evaluation.security,
|
||||
ask: evaluation.ask,
|
||||
agentId: evaluation.agentId,
|
||||
resolution: evaluation.resolution,
|
||||
allowlistMatch: evaluation.allowlistMatch,
|
||||
skillAllow: evaluation.skillAllow,
|
||||
allowAlwaysEligible: evaluation.canPersistAllowAlways,
|
||||
sessionKey: sessionKey,
|
||||
runId: runId))
|
||||
if let response = approval.response {
|
||||
return response
|
||||
}
|
||||
approvedByAsk = approval.approvedByAsk
|
||||
persistAllowlist = approval.persistAllowlist
|
||||
}
|
||||
if security == .allowlist,
|
||||
evaluation.authorizationBasis == nil,
|
||||
!approvedByAsk
|
||||
{
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: evaluation.displayCommand,
|
||||
reason: "allowlist-miss"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: allowlist miss")
|
||||
}
|
||||
|
||||
let reusableAuthorization = security == .allowlist &&
|
||||
!approvedByAsk &&
|
||||
evaluation.authorizationBasis != nil
|
||||
let executionCommand: [String]
|
||||
if reusableAuthorization {
|
||||
guard let boundCommand = evaluation.boundCommand else {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: evaluation.displayCommand,
|
||||
reason: "allowlist-unbound"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: reusable approval could not bind executable")
|
||||
}
|
||||
executionCommand = boundCommand
|
||||
} else {
|
||||
executionCommand = command
|
||||
}
|
||||
|
||||
if let permissionResponse = await self.validateScreenRecordingIfNeeded(
|
||||
req: req,
|
||||
needsScreenRecording: params.needsScreenRecording,
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
displayCommand: evaluation.displayCommand)
|
||||
{
|
||||
return permissionResponse
|
||||
}
|
||||
|
||||
let executionCommit = ExecApprovalExecutionCommit.build(
|
||||
context: evaluation,
|
||||
effectiveSecurity: security,
|
||||
approvalSource: approvalSource,
|
||||
explicitlyApproved: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
delayedPolicySnapshot: prepared.delayedPolicySnapshot)
|
||||
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||
let cwd = params.cwd
|
||||
let executionEnv = evaluation.env
|
||||
let shellRunner = self.shellRunner
|
||||
if case .failure = self.execApprovalStoreMutations.commitExecution(executionCommit) {
|
||||
return await self.execApprovalMutationFailure(
|
||||
req: req,
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
displayCommand: evaluation.displayCommand)
|
||||
}
|
||||
|
||||
// The locked store commit is the authorization linearization point.
|
||||
// Enqueue execution synchronously next; later revocations govern later commits.
|
||||
let execution = Task.detached {
|
||||
await shellRunner(executionCommand, cwd, executionEnv, timeoutSec)
|
||||
}
|
||||
return try await self.completeSystemRun(
|
||||
req: req,
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
displayCommand: evaluation.displayCommand,
|
||||
execution: execution)
|
||||
}
|
||||
|
||||
private func prepareSystemRun(_ req: BridgeInvokeRequest) async throws -> SystemRunPreparationResult {
|
||||
let params = try Self.decodeParams(OpenClawSystemRunParams.self, from: req.paramsJSON)
|
||||
let approvalSource: ExecApprovalRequestSource?
|
||||
switch params.approvalSource {
|
||||
case nil:
|
||||
approvalSource = nil
|
||||
case "ask-fallback":
|
||||
approvalSource = .askFallback
|
||||
case "auto-review":
|
||||
approvalSource = .autoReview
|
||||
default:
|
||||
return .response(
|
||||
Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: approvalSource invalid"))
|
||||
}
|
||||
if approvalSource != nil, params.approved != nil || params.approvalDecision != nil {
|
||||
return .response(
|
||||
Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: approvalSource cannot be combined with explicit approval"))
|
||||
}
|
||||
let explicitDecision = ExecApprovalHelpers.parseDecision(params.approvalDecision)
|
||||
let explicitApproval = params.approved == true ||
|
||||
explicitDecision == .allowOnce ||
|
||||
explicitDecision == .allowAlways
|
||||
let validatedCommand: ExecHostValidatedRequest
|
||||
switch ExecHostRequestEvaluator.validateCommand(
|
||||
command: params.command,
|
||||
rawCommand: params.rawCommand)
|
||||
{
|
||||
case let .success(resolved):
|
||||
validatedCommand = resolved
|
||||
case let .failure(error):
|
||||
let message = error.message.hasPrefix("INVALID_REQUEST:")
|
||||
? error.message
|
||||
: "INVALID_REQUEST: \(error.message)"
|
||||
return .response(Self.errorResponse(req, code: .invalidRequest, message: message))
|
||||
}
|
||||
if approvalSource != nil || explicitApproval {
|
||||
guard let plan = params.systemRunPlan,
|
||||
MacSystemRunApprovalPlanValidator.matches(
|
||||
plan,
|
||||
params: params,
|
||||
validatedCommand: validatedCommand)
|
||||
else {
|
||||
let message = approvalSource != nil
|
||||
? "approvalSource requires matching systemRunPlan"
|
||||
: "explicit approval requires matching systemRunPlan"
|
||||
return .response(Self.errorResponse(req, code: .invalidRequest, message: message))
|
||||
}
|
||||
}
|
||||
let carriesDelayedAuthority = approvalSource == .autoReview || explicitApproval
|
||||
let delayedPolicySnapshot: ExecApprovalPolicySnapshot?
|
||||
if carriesDelayedAuthority {
|
||||
if let operand = params.systemRunPlan?.mutableFileOperand,
|
||||
!MacSystemRunApprovalPlanValidator.revalidateMutableFileOperand(
|
||||
operand,
|
||||
command: validatedCommand.command,
|
||||
cwd: params.cwd)
|
||||
{
|
||||
return .response(
|
||||
Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: approval script operand changed before execution"))
|
||||
}
|
||||
guard let policySnapshot = params.systemRunPlan?.policySnapshot else {
|
||||
return .response(
|
||||
Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: delayed approval requires a prepared policy snapshot"))
|
||||
}
|
||||
delayedPolicySnapshot = ExecApprovalPolicySnapshot(portable: policySnapshot)
|
||||
} else {
|
||||
delayedPolicySnapshot = nil
|
||||
}
|
||||
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: self.mainSessionKey
|
||||
let providedRunId = params.runId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let runId = providedRunId.isEmpty ? UUID().uuidString : providedRunId
|
||||
let envOverrideDiagnostics = HostEnvSanitizer.inspectOverrides(
|
||||
overrides: params.env,
|
||||
blockPathOverrides: true)
|
||||
if !envOverrideDiagnostics.blockedKeys.isEmpty || !envOverrideDiagnostics.invalidKeys.isEmpty {
|
||||
var details: [String] = []
|
||||
if !envOverrideDiagnostics.blockedKeys.isEmpty {
|
||||
details.append("blocked override keys: \(envOverrideDiagnostics.blockedKeys.joined(separator: ", "))")
|
||||
}
|
||||
if !envOverrideDiagnostics.invalidKeys.isEmpty {
|
||||
details.append(
|
||||
"invalid non-portable override keys: \(envOverrideDiagnostics.invalidKeys.joined(separator: ", "))")
|
||||
}
|
||||
return .response(
|
||||
Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "SYSTEM_RUN_DENIED: environment override rejected (\(details.joined(separator: "; ")))"))
|
||||
}
|
||||
let evaluation = await ExecApprovalEvaluator.evaluate(
|
||||
command: validatedCommand.command,
|
||||
rawCommand: validatedCommand.evaluationRawCommand,
|
||||
displayCommand: validatedCommand.displayCommand,
|
||||
cwd: params.cwd,
|
||||
envOverrides: params.env,
|
||||
agentId: params.agentId)
|
||||
let security = approvalSource == .askFallback
|
||||
? ExecSecurity.narrower(evaluation.security, evaluation.askFallback)
|
||||
: evaluation.security
|
||||
|
||||
if security == .deny {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: evaluation.displayCommand,
|
||||
reason: "security=deny"))
|
||||
return .response(
|
||||
Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DISABLED: security=deny"))
|
||||
}
|
||||
|
||||
if approvalSource == .autoReview, evaluation.ask == .always {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: evaluation.displayCommand,
|
||||
reason: "ask=always"))
|
||||
return .response(
|
||||
Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: auto-review cannot bypass ask=always"))
|
||||
}
|
||||
|
||||
return .prepared(SystemRunPreparation(
|
||||
params: params,
|
||||
approvalSource: approvalSource,
|
||||
validatedCommand: validatedCommand,
|
||||
evaluation: evaluation,
|
||||
security: security,
|
||||
delayedPolicySnapshot: delayedPolicySnapshot,
|
||||
sessionKey: sessionKey,
|
||||
runId: runId))
|
||||
}
|
||||
|
||||
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
let params = try Self.decodeParams(OpenClawSystemWhichParams.self, from: req.paramsJSON)
|
||||
let bins = params.bins
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard !bins.isEmpty else {
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: bins required")
|
||||
}
|
||||
|
||||
let searchPaths = CommandResolver.preferredPaths()
|
||||
var matches: [String] = []
|
||||
var paths: [String: String] = [:]
|
||||
for bin in bins {
|
||||
if let path = CommandResolver.findExecutable(named: bin, searchPaths: searchPaths) {
|
||||
matches.append(bin)
|
||||
paths[bin] = path
|
||||
}
|
||||
}
|
||||
|
||||
struct WhichPayload: Encodable {
|
||||
let bins: [String]
|
||||
let paths: [String: String]
|
||||
}
|
||||
let payload = try Self.encodePayload(WhichPayload(bins: matches, paths: paths))
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private struct ExecApprovalOutcome {
|
||||
var approvedByAsk: Bool
|
||||
var persistAllowlist: Bool
|
||||
var response: BridgeInvokeResponse?
|
||||
}
|
||||
|
||||
private struct ExecRunContext {
|
||||
var displayCommand: String
|
||||
var security: ExecSecurity
|
||||
var ask: ExecAsk
|
||||
var agentId: String?
|
||||
var resolution: ExecCommandResolution?
|
||||
var allowlistMatch: ExecAllowlistEntry?
|
||||
var skillAllow: Bool
|
||||
var allowAlwaysEligible: Bool
|
||||
var sessionKey: String
|
||||
var runId: String
|
||||
}
|
||||
|
||||
private func resolveSystemRunApproval(
|
||||
req: BridgeInvokeRequest,
|
||||
params: OpenClawSystemRunParams,
|
||||
context: ExecRunContext) async -> ExecApprovalOutcome
|
||||
{
|
||||
let requiresAsk = ExecApprovalHelpers.requiresAsk(
|
||||
ask: context.ask,
|
||||
security: context.security,
|
||||
allowlistMatch: context.allowlistMatch,
|
||||
skillAllow: context.skillAllow)
|
||||
|
||||
let decisionFromParams = ExecApprovalHelpers.parseDecision(params.approvalDecision)
|
||||
var approvedByAsk = params.approved == true || decisionFromParams != nil
|
||||
var persistAllowlist = decisionFromParams == .allowAlways
|
||||
if decisionFromParams == .deny {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: context.sessionKey,
|
||||
runId: context.runId,
|
||||
host: "node",
|
||||
command: context.displayCommand,
|
||||
reason: "user-denied"))
|
||||
return ExecApprovalOutcome(
|
||||
approvedByAsk: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
response: Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied"))
|
||||
}
|
||||
|
||||
if requiresAsk, !approvedByAsk {
|
||||
let promptDecision = await ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: context.displayCommand,
|
||||
cwd: params.cwd,
|
||||
host: "node",
|
||||
security: context.security.rawValue,
|
||||
ask: context.ask.rawValue,
|
||||
agentId: context.agentId,
|
||||
resolvedPath: context.resolution?.resolvedPath,
|
||||
sessionKey: context.sessionKey,
|
||||
allowedDecisions: ExecApprovalPromptRequest.allowedDecisions(
|
||||
forAsk: context.ask.rawValue,
|
||||
allowAlwaysEligible: context.allowAlwaysEligible)))
|
||||
guard let decision = promptDecision else {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: context.sessionKey,
|
||||
runId: context.runId,
|
||||
host: "node",
|
||||
command: context.displayCommand,
|
||||
reason: "approval-cancelled"))
|
||||
return ExecApprovalOutcome(
|
||||
approvedByAsk: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
response: Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: approval prompt closed without decision"))
|
||||
}
|
||||
switch decision {
|
||||
case .deny:
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: context.sessionKey,
|
||||
runId: context.runId,
|
||||
host: "node",
|
||||
command: context.displayCommand,
|
||||
reason: "user-denied"))
|
||||
return ExecApprovalOutcome(
|
||||
approvedByAsk: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
response: Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied"))
|
||||
case .allowAlways:
|
||||
approvedByAsk = true
|
||||
persistAllowlist = true
|
||||
case .allowOnce:
|
||||
approvedByAsk = true
|
||||
}
|
||||
}
|
||||
|
||||
return ExecApprovalOutcome(
|
||||
approvedByAsk: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
response: nil)
|
||||
}
|
||||
|
||||
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
struct GetParams: Decodable {
|
||||
var includeResolvedDefaults: Bool?
|
||||
}
|
||||
|
||||
let params = try req.paramsJSON.map { json in
|
||||
try Self.decodeParams(GetParams.self, from: json)
|
||||
} ?? GetParams(includeResolvedDefaults: nil)
|
||||
let snapshot: ExecApprovalsSnapshot
|
||||
switch ExecApprovalsStore.ensureSnapshotResult() {
|
||||
case let .success(current):
|
||||
snapshot = current
|
||||
case .failure:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "UNAVAILABLE: exec approvals store unavailable; retry")
|
||||
}
|
||||
guard params.includeResolvedDefaults == true else {
|
||||
let redacted = ExecApprovalsSnapshot(
|
||||
path: snapshot.path,
|
||||
exists: snapshot.exists,
|
||||
hash: snapshot.hash,
|
||||
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
|
||||
let payload = try Self.encodePayload(redacted)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
let redacted = ExecApprovalsNodeSnapshot(
|
||||
path: snapshot.path,
|
||||
exists: snapshot.exists,
|
||||
hash: snapshot.hash,
|
||||
file: ExecApprovalsStore.redactForSnapshot(snapshot.file),
|
||||
resolvedDefaults: ExecApprovalsStore.resolveDefaults(from: snapshot.file))
|
||||
let payload = try Self.encodePayload(redacted)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
struct SetParams: Decodable {
|
||||
var file: ExecApprovalsFile
|
||||
var baseHash: String?
|
||||
}
|
||||
|
||||
let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON)
|
||||
switch ExecApprovalsStore.saveFile(params.file, ifBaseHash: params.baseHash) {
|
||||
case let .saved(snapshot):
|
||||
let redacted = ExecApprovalsSnapshot(
|
||||
path: snapshot.path,
|
||||
exists: snapshot.exists,
|
||||
hash: snapshot.hash,
|
||||
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
|
||||
let payload = try Self.encodePayload(redacted)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
case .baseHashUnavailable:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry")
|
||||
case .baseHashRequired:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: exec approvals base hash required; reload and retry")
|
||||
case .conflict:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: exec approvals changed; reload and retry")
|
||||
case .unavailable:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "UNAVAILABLE: exec approvals update lock unavailable; retry")
|
||||
}
|
||||
}
|
||||
|
||||
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
|
||||
guard let sender = eventSender else { return }
|
||||
guard let data = try? JSONEncoder().encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
return
|
||||
}
|
||||
await sender(event, json)
|
||||
}
|
||||
|
||||
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
let params = try Self.decodeParams(OpenClawSystemNotifyParams.self, from: req.paramsJSON)
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -1354,104 +768,9 @@ extension MacNodeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - System command support
|
||||
// MARK: - Shared command support
|
||||
|
||||
extension MacNodeRuntime {
|
||||
private func execApprovalMutationFailure(
|
||||
req: BridgeInvokeRequest,
|
||||
sessionKey: String,
|
||||
runId: String,
|
||||
displayCommand: String) async -> BridgeInvokeResponse
|
||||
{
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "approval-store-unavailable"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: exec approvals update unavailable")
|
||||
}
|
||||
|
||||
private func validateScreenRecordingIfNeeded(
|
||||
req: BridgeInvokeRequest,
|
||||
needsScreenRecording: Bool?,
|
||||
sessionKey: String,
|
||||
runId: String,
|
||||
displayCommand: String) async -> BridgeInvokeResponse?
|
||||
{
|
||||
guard needsScreenRecording == true else { return nil }
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
if authorized { return nil }
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "permission:screenRecording"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "PERMISSION_MISSING: screenRecording")
|
||||
}
|
||||
|
||||
private func completeSystemRun(
|
||||
req: BridgeInvokeRequest,
|
||||
sessionKey: String,
|
||||
runId: String,
|
||||
displayCommand: String,
|
||||
execution: Task<ShellExecutor.ShellResult, Never>) async throws -> BridgeInvokeResponse
|
||||
{
|
||||
await self.emitExecEvent(
|
||||
"exec.started",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand))
|
||||
let result = await execution.value
|
||||
let combined = [result.stdout, result.stderr, result.errorMessage]
|
||||
.compactMap(\.self)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
await self.emitExecEvent(
|
||||
"exec.finished",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
output: ExecEventPayload.truncateOutput(combined)))
|
||||
|
||||
struct RunPayload: Encodable {
|
||||
var exitCode: Int?
|
||||
var timedOut: Bool
|
||||
var success: Bool
|
||||
var stdout: String
|
||||
var stderr: String
|
||||
var error: String?
|
||||
}
|
||||
let runPayload = RunPayload(
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
error: result.errorMessage)
|
||||
let payload = try Self.encodePayload(runPayload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||
guard let json, let data = json.data(using: .utf8) else {
|
||||
throw NSError(domain: "Gateway", code: 20, userInfo: [
|
||||
|
||||
@@ -642,10 +642,10 @@ struct OnboardingView: View {
|
||||
{
|
||||
switch mode {
|
||||
case .remote:
|
||||
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
|
||||
// but the AI check runs against the remote gateway so a broken
|
||||
// remote model surfaces here, not in the first chat.
|
||||
return [0, 1, 3, 5, 9]
|
||||
// Remote mode skips local Gateway/workspace setup, but its Mac node
|
||||
// still runs the matching CLI node-host runtime inside the app.
|
||||
let setupPages = requiresCLIInstall ? [0, 1, 2, 3, 5] : [0, 1, 3, 5]
|
||||
return setupPages + [9]
|
||||
case .unconfigured:
|
||||
return [0, 1, 9]
|
||||
case .local:
|
||||
@@ -654,6 +654,10 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
static func shouldActivateLocalGateway(afterCLIInstallFor mode: AppState.ConnectionMode) -> Bool {
|
||||
mode == .local
|
||||
}
|
||||
|
||||
var selectedConnectionMode: AppState.ConnectionMode {
|
||||
if self.isConnectionSelectionBlocking {
|
||||
return .local
|
||||
@@ -668,7 +672,7 @@ struct OnboardingView: View {
|
||||
var pageOrder: [Int] {
|
||||
Self.pageOrder(
|
||||
for: self.state.connectionMode,
|
||||
requiresCLIInstall: self.state.connectionMode == .local && !self.cliInstalled)
|
||||
requiresCLIInstall: !self.cliInstalled)
|
||||
}
|
||||
|
||||
var pageCount: Int {
|
||||
|
||||
@@ -163,6 +163,11 @@ extension OnboardingView {
|
||||
guard installed else { return }
|
||||
cliExecutableReady = true
|
||||
cliInstallLocation = CLIInstaller.managedExecutableLocation()
|
||||
if !Self.shouldActivateLocalGateway(afterCLIInstallFor: self.state.connectionMode) {
|
||||
cliStatus = "OpenClaw CLI is ready for the Mac node."
|
||||
cliInstalled = true
|
||||
return
|
||||
}
|
||||
cliStatus = "Starting OpenClaw Gateway…"
|
||||
// The step checklist shows one spinner at a time: install first,
|
||||
// then the service start.
|
||||
|
||||
@@ -748,12 +748,18 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func cliPage() -> some View {
|
||||
onboardingPage {
|
||||
let remoteMode = self.state.connectionMode == .remote
|
||||
let detail = if remoteMode {
|
||||
"OpenClaw is installing the matching runtime for this Mac node. " +
|
||||
"It will connect to your selected Gateway without starting another one here."
|
||||
} else {
|
||||
"OpenClaw is setting up its background service on this Mac. " +
|
||||
"This usually takes under a minute — no Terminal, no administrator password."
|
||||
}
|
||||
return onboardingPage {
|
||||
Text("Getting things ready")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text(
|
||||
"OpenClaw is setting up its background service on this Mac. " +
|
||||
"This usually takes under a minute — no Terminal, no administrator password.")
|
||||
Text(detail)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -769,12 +775,16 @@ extension OnboardingView {
|
||||
state: self.installStepStateForInstall,
|
||||
monospacedDetail: self.cliInstalled && self.cliInstallLocation != nil)
|
||||
self.installStepRow(
|
||||
title: "Start the background service",
|
||||
detail: "Runs quietly and starts again after a restart.",
|
||||
title: remoteMode ? "Prepare the Mac node" : "Start the background service",
|
||||
detail: remoteMode
|
||||
? "Runs inside the app and uses its macOS permissions."
|
||||
: "Runs quietly and starts again after a restart.",
|
||||
state: self.installStepStateForService)
|
||||
self.installStepRow(
|
||||
title: "Ready for the next step",
|
||||
detail: "Once the service answers, 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 {
|
||||
|
||||
@@ -40,6 +40,25 @@ import Testing
|
||||
#expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"]))
|
||||
}
|
||||
|
||||
@Test func `source checkout entrypoint wins when package bin link is absent`() throws {
|
||||
let defaults = self.makeLocalDefaults()
|
||||
let tmp = try makeTempDirForTests()
|
||||
let sourceEntrypoint = tmp.appendingPathComponent("openclaw.mjs")
|
||||
let staleGlobalBin = tmp.appendingPathComponent("global/bin")
|
||||
try makeExecutableForTests(at: sourceEntrypoint)
|
||||
try makeExecutableForTests(at: staleGlobalBin.appendingPathComponent("openclaw"))
|
||||
|
||||
let cmd = CommandResolver.openclawCommand(
|
||||
subcommand: "node",
|
||||
extraArgs: ["worker"],
|
||||
defaults: defaults,
|
||||
configRoot: [:],
|
||||
searchPaths: [staleGlobalBin.path],
|
||||
projectRoot: tmp)
|
||||
|
||||
#expect(cmd == [sourceEntrypoint.path, "node", "worker"])
|
||||
}
|
||||
|
||||
@Test func `falls back to node and script`() throws {
|
||||
let defaults = self.makeLocalDefaults()
|
||||
|
||||
|
||||
@@ -6,45 +6,6 @@ import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ExecApprovalsStoreRefactorTests {
|
||||
private actor ShellRunProbe {
|
||||
private var commands: [[String]] = []
|
||||
|
||||
func run(_ command: [String]) -> ShellExecutor.ShellResult {
|
||||
self.commands.append(command)
|
||||
return ShellExecutor.ShellResult(
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
success: true,
|
||||
errorMessage: nil)
|
||||
}
|
||||
|
||||
func capturedCommands() -> [[String]] {
|
||||
self.commands
|
||||
}
|
||||
}
|
||||
|
||||
private static func forwardedApprovalPlan(
|
||||
command: [String],
|
||||
rawCommand: String? = nil,
|
||||
agentId: String? = "main",
|
||||
policySnapshot: ExecApprovalPolicySnapshot? = nil,
|
||||
mutableFileOperand: OpenClawSystemRunApprovalFileOperand? = nil) -> OpenClawSystemRunApprovalPlan
|
||||
{
|
||||
let snapshot = policySnapshot ?? ExecApprovalPolicySnapshot(
|
||||
resolved: ExecApprovalsStore.resolve(agentId: agentId))
|
||||
return OpenClawSystemRunApprovalPlan(
|
||||
argv: command,
|
||||
cwd: nil,
|
||||
commandText: ExecCommandFormatter.displayString(for: command),
|
||||
commandPreview: rawCommand,
|
||||
agentId: agentId,
|
||||
sessionKey: nil,
|
||||
policySnapshot: snapshot.portable,
|
||||
mutableFileOperand: mutableFileOperand)
|
||||
}
|
||||
|
||||
private var realTemporaryDirectory: URL {
|
||||
let path = FileManager().temporaryDirectory.path
|
||||
if path.hasPrefix("/var/") {
|
||||
@@ -552,890 +513,6 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `native approvals get reports held sidecar lock as unavailable`() async throws {
|
||||
try await self.withTempStateDir { stateDir in
|
||||
let lockURL = stateDir.appendingPathComponent("exec-approvals.json.lock")
|
||||
try Data("held".utf8).write(to: lockURL)
|
||||
|
||||
let response = await MacNodeRuntime().handleInvoke(BridgeInvokeRequest(
|
||||
id: "approvals-get-held-lock",
|
||||
command: OpenClawSystemCommand.execApprovalsGet.rawValue))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.code == .unavailable)
|
||||
#expect(response.error?.message == "UNAVAILABLE: exec approvals store unavailable; retry")
|
||||
#expect(FileManager().fileExists(atPath: lockURL.path))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `native approvals get reports malformed store as unavailable`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
let url = ExecApprovalsStore.fileURL()
|
||||
let malformed = Data("{".utf8)
|
||||
try malformed.write(to: url, options: [.atomic])
|
||||
|
||||
let response = await MacNodeRuntime().handleInvoke(BridgeInvokeRequest(
|
||||
id: "approvals-get-malformed",
|
||||
command: OpenClawSystemCommand.execApprovalsGet.rawValue))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.code == .unavailable)
|
||||
#expect(response.error?.message == "UNAVAILABLE: exec approvals store unavailable; retry")
|
||||
#expect(try Data(contentsOf: url) == malformed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `allow always atomic commit failure denies without persisting or executing`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .allowlist
|
||||
entry.ask = .off
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let mutations = ExecApprovalStoreMutations(
|
||||
commitExecution: { _ in .failure(.unavailable) })
|
||||
let runtime = MacNodeRuntime(
|
||||
execApprovalStoreMutations: mutations,
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/usr/bin/printf", "ok"],
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
|
||||
approvalDecision: "allow-always")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "persist-failure",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
#expect(ExecApprovalsStore.resolve(agentId: "main").allowlist.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `allowlist usage failure denies before shell execution`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .allowlist
|
||||
entry.ask = .off
|
||||
entry.allowlist = [ExecAllowlistEntry(pattern: "/usr/bin/printf")]
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let mutations = ExecApprovalStoreMutations(
|
||||
commitExecution: { _ in .failure(.unavailable) })
|
||||
let runtime = MacNodeRuntime(
|
||||
execApprovalStoreMutations: mutations,
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(command: ["/usr/bin/printf", "ok"], agentId: "main")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "usage-failure",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `native runtime revalidates unprompted full policy before shell execution`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .full
|
||||
entry.ask = .off
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
|
||||
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .deny
|
||||
}
|
||||
return ExecApprovalsStore.commitExecution(commit)
|
||||
})
|
||||
let runtime = MacNodeRuntime(
|
||||
execApprovalStoreMutations: mutations,
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/usr/bin/printf", "ok"],
|
||||
agentId: "main")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "full-policy-race",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `native runtime rejects ask tightening before shell execution`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .full
|
||||
entry.ask = .off
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
|
||||
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.ask = .onMiss
|
||||
}
|
||||
return ExecApprovalsStore.commitExecution(commit)
|
||||
})
|
||||
let runtime = MacNodeRuntime(
|
||||
execApprovalStoreMutations: mutations,
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/usr/bin/printf", "ok"],
|
||||
agentId: "main")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "ask-policy-race",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `native runtime rejects explicit once when security tightens to allowlist`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .full
|
||||
entry.ask = .always
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
|
||||
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .allowlist
|
||||
}
|
||||
return ExecApprovalsStore.commitExecution(commit)
|
||||
})
|
||||
let runtime = MacNodeRuntime(
|
||||
execApprovalStoreMutations: mutations,
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/usr/bin/printf", "ok"],
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
|
||||
approvalDecision: "allow-once")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "explicit-once-security-race",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `native runtime rejects explicit once after allowlist revocation`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .allowlist
|
||||
entry.ask = .onMiss
|
||||
entry.allowlist = [ExecAllowlistEntry(pattern: "/usr/bin/echo")]
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
|
||||
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.allowlist = []
|
||||
}
|
||||
return ExecApprovalsStore.commitExecution(commit)
|
||||
})
|
||||
let runtime = MacNodeRuntime(
|
||||
execApprovalStoreMutations: mutations,
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/usr/bin/printf", "ok"],
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
|
||||
approvalDecision: "allow-once")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "explicit-once-allowlist-race",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `native runtime rejects auto review when security tightens to allowlist`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .full
|
||||
entry.ask = .onMiss
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
|
||||
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .allowlist
|
||||
}
|
||||
return ExecApprovalsStore.commitExecution(commit)
|
||||
})
|
||||
let runtime = MacNodeRuntime(
|
||||
execApprovalStoreMutations: mutations,
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/usr/bin/printf", "ok"],
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
|
||||
approvalSource: "auto-review")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "auto-review-security-race",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `native runtime rejects auto review when ask tightens from off to on miss`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .full
|
||||
entry.ask = .off
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
|
||||
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.ask = .onMiss
|
||||
}
|
||||
return ExecApprovalsStore.commitExecution(commit)
|
||||
})
|
||||
let runtime = MacNodeRuntime(
|
||||
execApprovalStoreMutations: mutations,
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/usr/bin/printf", "ok"],
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
|
||||
approvalSource: "auto-review")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "auto-review-ask-tightening",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `native runtime treats a successful commit as the authorization linearization point`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .full
|
||||
entry.ask = .off
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
|
||||
let result = ExecApprovalsStore.commitExecution(commit)
|
||||
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .deny
|
||||
}
|
||||
return result
|
||||
})
|
||||
let runtime = MacNodeRuntime(
|
||||
execApprovalStoreMutations: mutations,
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let command = ["/usr/bin/printf", "ok"]
|
||||
let params = OpenClawSystemRunParams(command: command, agentId: "main")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "authorization-linearization",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(response.ok)
|
||||
#expect(await probe.capturedCommands() == [command])
|
||||
#expect(ExecApprovalsStore.resolve(agentId: "main").agent.security == .deny)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `native runtime requires the prepared snapshot for forwarded delayed authority`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let command = ["/usr/bin/printf", "ok"]
|
||||
let planWithoutSnapshot = OpenClawSystemRunApprovalPlan(
|
||||
argv: command,
|
||||
cwd: nil,
|
||||
commandText: ExecCommandFormatter.displayString(for: command),
|
||||
agentId: "main",
|
||||
sessionKey: nil)
|
||||
let cases = [
|
||||
OpenClawSystemRunParams(
|
||||
command: command,
|
||||
agentId: "main",
|
||||
systemRunPlan: planWithoutSnapshot,
|
||||
approvalDecision: "allow-once"),
|
||||
OpenClawSystemRunParams(
|
||||
command: command,
|
||||
agentId: "main",
|
||||
systemRunPlan: planWithoutSnapshot,
|
||||
approvalSource: "auto-review"),
|
||||
OpenClawSystemRunParams(
|
||||
command: command,
|
||||
agentId: "main",
|
||||
systemRunPlan: planWithoutSnapshot,
|
||||
approved: true),
|
||||
]
|
||||
|
||||
for (index, params) in cases.enumerated() {
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "missing-delayed-snapshot-\(index)",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.code == .invalidRequest)
|
||||
#expect(response.error?.message.contains("prepared policy snapshot") == true)
|
||||
}
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `native runtime rejects forwarded delayed authority for a mismatched plan`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let command = ["/usr/bin/printf", "ok"]
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: command,
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "different"]),
|
||||
approvalDecision: "allow-once")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "mismatched-forwarded-plan",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.code == .invalidRequest)
|
||||
#expect(response.error?.message.contains("matching systemRunPlan") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `forwarded node session approval cannot restore a rule revoked before Mac evaluation`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
let stale = ExecAllowlistEntry(
|
||||
pattern: "/usr/bin/echo",
|
||||
source: "allow-always")
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .allowlist
|
||||
entry.ask = .onMiss
|
||||
entry.allowlist = [stale]
|
||||
}.get()
|
||||
let forwardedSnapshot = ExecApprovalPolicySnapshot(
|
||||
resolved: ExecApprovalsStore.resolve(agentId: "main"))
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.allowlist = []
|
||||
}.get()
|
||||
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let command = ["/usr/bin/printf", "ok"]
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: command,
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(
|
||||
command: command,
|
||||
policySnapshot: forwardedSnapshot),
|
||||
approvalDecision: "allow-always")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "revoked-node-session-approval",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
#expect(ExecApprovalsStore.resolve(agentId: "main").allowlist.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `forwarded node session approval rejects a changed mutable script operand`() async throws {
|
||||
try await self.withTempStateDir { stateDir in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .full
|
||||
entry.ask = .off
|
||||
}.get()
|
||||
let scriptURL = stateDir.appendingPathComponent("script.py")
|
||||
let approvedData = Data("print('approved')\n".utf8)
|
||||
try approvedData.write(to: scriptURL)
|
||||
let approvedHash = SHA256.hash(data: approvedData)
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
let command = ["/usr/bin/python3", scriptURL.path]
|
||||
let operand = OpenClawSystemRunApprovalFileOperand(
|
||||
argvIndex: 1,
|
||||
path: scriptURL.resolvingSymlinksInPath().path,
|
||||
sha256: approvedHash)
|
||||
try Data("print('changed')\n".utf8).write(to: scriptURL)
|
||||
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: command,
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(
|
||||
command: command,
|
||||
mutableFileOperand: operand),
|
||||
approvalDecision: "allow-once")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "changed-script-operand",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("script operand changed") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
|
||||
try approvedData.write(to: scriptURL)
|
||||
let restoredResponse = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "restored-script-operand",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
#expect(restoredResponse.ok)
|
||||
#expect(await probe.capturedCommands() == [command])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `native runtime cannot persist allow always after concurrent policy revocation`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .allowlist
|
||||
entry.ask = .onMiss
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
|
||||
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .deny
|
||||
}
|
||||
return ExecApprovalsStore.commitExecution(commit)
|
||||
})
|
||||
let runtime = MacNodeRuntime(
|
||||
execApprovalStoreMutations: mutations,
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/usr/bin/printf", "ok"],
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
|
||||
approvalDecision: "allow-always")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "allow-always-policy-race",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
#expect(ExecApprovalsStore.resolve(agentId: "main").allowlist.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `revoked gateway timeout fallback denies before native shell execution`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .full
|
||||
entry.ask = .always
|
||||
entry.askFallback = .deny
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/usr/bin/printf", "ok"],
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
|
||||
approvalSource: "ask-fallback")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "revoked-timeout-fallback",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message == "SYSTEM_RUN_DISABLED: security=deny")
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `marker only full timeout fallback executes without a second prompt`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .full
|
||||
entry.ask = .always
|
||||
entry.askFallback = .full
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let command = ["/usr/bin/printf", "ok"]
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: command,
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: command),
|
||||
approvalSource: "ask-fallback")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "full-timeout-fallback",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(response.ok)
|
||||
#expect(await probe.capturedCommands() == [command])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `timeout fallback rejects explicit approval fields`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/usr/bin/printf", "ok"],
|
||||
agentId: "main",
|
||||
approvalDecision: "allow-once",
|
||||
approvalSource: "ask-fallback")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "mixed-timeout-fallback",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("cannot be combined") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto review marker executes one shot allowlist miss`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .allowlist
|
||||
entry.ask = .onMiss
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let command = ["/usr/bin/printf", "ok"]
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: command,
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: command),
|
||||
approvalSource: "auto-review")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "auto-review-once",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(response.ok)
|
||||
#expect(await probe.capturedCommands() == [command])
|
||||
#expect(ExecApprovalsStore.resolve(agentId: "main").allowlist.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto review marker cannot bypass ask always`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .full
|
||||
entry.ask = .always
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/usr/bin/printf", "ok"],
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
|
||||
approvalSource: "auto-review")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "auto-review-ask-always",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("ask=always") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto review revalidates concurrent ask always before shell execution`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .allowlist
|
||||
entry.ask = .onMiss
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let mutations = ExecApprovalStoreMutations(commitExecution: { commit in
|
||||
_ = ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.ask = .always
|
||||
}
|
||||
return ExecApprovalsStore.commitExecution(commit)
|
||||
})
|
||||
let runtime = MacNodeRuntime(
|
||||
execApprovalStoreMutations: mutations,
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/usr/bin/printf", "ok"],
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: ["/usr/bin/printf", "ok"]),
|
||||
approvalSource: "auto-review")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "auto-review-ask-race",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("exec approvals update unavailable") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto review marker preserves reviewed strict inline execution`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .allowlist
|
||||
entry.ask = .onMiss
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let payload = "/usr/bin/printf reviewed"
|
||||
let command = ["/bin/sh", "-c", payload]
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: command,
|
||||
rawCommand: payload,
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: command, rawCommand: payload),
|
||||
approvalSource: "auto-review")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "auto-review-inline",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(response.ok)
|
||||
#expect(await probe.capturedCommands() == [command])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto review marker rejects legacy approval fields`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/usr/bin/printf", "ok"],
|
||||
agentId: "main",
|
||||
approved: true,
|
||||
approvalSource: "auto-review")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "mixed-auto-review",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("cannot be combined") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `allowlist timeout fallback executes the canonical bound command`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .full
|
||||
entry.ask = .always
|
||||
entry.askFallback = .allowlist
|
||||
entry.allowlist = [ExecAllowlistEntry(pattern: "/usr/bin/printf")]
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["printf", "ok"],
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: ["printf", "ok"]),
|
||||
approvalSource: "ask-fallback")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "allowlist-timeout-fallback",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(response.ok)
|
||||
#expect(await probe.capturedCommands() == [["/usr/bin/printf", "ok"]])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `allowlist timeout fallback miss denies without shell execution`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .full
|
||||
entry.ask = .always
|
||||
entry.askFallback = .allowlist
|
||||
entry.allowlist = [ExecAllowlistEntry(pattern: "/usr/bin/printf")]
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/bin/echo", "miss"],
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: ["/bin/echo", "miss"]),
|
||||
approvalSource: "ask-fallback")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "allowlist-timeout-fallback-miss",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.message.contains("allowlist miss") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `explicit allow once executes original unbound shell command`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .allowlist
|
||||
entry.ask = .onMiss
|
||||
entry.allowlist = [ExecAllowlistEntry(pattern: "/usr/bin/printf")]
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let payload = "/usr/bin/printf one && /usr/bin/printf two"
|
||||
let original = ["/bin/sh", "-c", payload]
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: original,
|
||||
rawCommand: payload,
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: original, rawCommand: payload),
|
||||
approvalDecision: "allow-once")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "allow-once",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(response.ok)
|
||||
#expect(await probe.capturedCommands() == [original])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `allow always executes unbound shell command once without persisting inner grants`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = try ExecApprovalsStore.updateAgentSettings(agentId: "main") { entry in
|
||||
entry.security = .allowlist
|
||||
entry.ask = .onMiss
|
||||
}.get()
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let payload = "/usr/bin/printf one && /usr/bin/printf two"
|
||||
let original = ["/bin/sh", "-c", payload]
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: original,
|
||||
rawCommand: payload,
|
||||
agentId: "main",
|
||||
systemRunPlan: Self.forwardedApprovalPlan(command: original, rawCommand: payload),
|
||||
approvalDecision: "allow-always")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "allow-always-unbound",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(response.ok)
|
||||
#expect(await probe.capturedCommands() == [original])
|
||||
#expect(ExecApprovalsStore.resolve(agentId: "main").allowlist.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `native rewrites preserve non-sensitive metadata and arbitrary ids`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
@@ -2052,7 +1129,6 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ExecApprovalsStoreRefactorTests {
|
||||
@Test
|
||||
func `ensure file migrates default approvals into custom state dir`() async throws {
|
||||
|
||||
@@ -244,6 +244,39 @@ struct GatewayChannelConnectTests {
|
||||
#expect(params["maxProtocol"] as? Int == GATEWAY_PROTOCOL_VERSION)
|
||||
}
|
||||
|
||||
@Test func `node connect advertises worker path environment`() async throws {
|
||||
let recorder = ConnectParamsRecorder()
|
||||
let session = GatewayTestWebSocketSession(
|
||||
taskFactory: {
|
||||
GatewayTestWebSocketTask(
|
||||
sendHook: { _, message, sendIndex in
|
||||
guard sendIndex == 0 else { return }
|
||||
recorder.record(message)
|
||||
})
|
||||
})
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: ["system"],
|
||||
commands: ["system.run"],
|
||||
pathEnv: "/opt/homebrew/bin:/usr/bin:/bin",
|
||||
permissions: [:],
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "macOS Test",
|
||||
includeDeviceIdentity: false)
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session),
|
||||
connectOptions: options)
|
||||
|
||||
try await channel.connect()
|
||||
|
||||
let params = try #require(recorder.snapshot())
|
||||
#expect(params["pathEnv"] as? String == "/opt/homebrew/bin:/usr/bin:/bin")
|
||||
}
|
||||
|
||||
@Test func `concurrent connect shares failure`() async throws {
|
||||
let session = self.makeSession(response: .invalid(delayMs: 200))
|
||||
let channel = try GatewayChannelActor(
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct MacNodeBrowserProxyTests {
|
||||
@Test func `request uses browser control endpoint and wraps result`() async throws {
|
||||
let proxy = MacNodeBrowserProxy(
|
||||
endpointProvider: {
|
||||
MacNodeBrowserProxy.Endpoint(
|
||||
baseURL: URL(string: "http://127.0.0.1:18791")!,
|
||||
token: "test-token",
|
||||
password: nil)
|
||||
},
|
||||
performRequest: { request in
|
||||
#expect(request.url?.absoluteString == "http://127.0.0.1:18791/tabs?profile=work")
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
|
||||
|
||||
let body = Data(#"{"tabs":[{"id":"tab-1"}]}"#.utf8)
|
||||
let url = try #require(request.url)
|
||||
let response = try #require(
|
||||
HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: ["Content-Type": "application/json"]))
|
||||
return (body, response)
|
||||
})
|
||||
|
||||
let payloadJSON = try await proxy.request(
|
||||
paramsJSON: #"{"method":"GET","path":"/tabs","profile":"work"}"#)
|
||||
let payload = try #require(
|
||||
JSONSerialization.jsonObject(with: Data(payloadJSON.utf8)) as? [String: Any])
|
||||
let result = try #require(payload["result"] as? [String: Any])
|
||||
let tabs = try #require(result["tabs"] as? [[String: Any]])
|
||||
|
||||
#expect(payload["files"] == nil)
|
||||
#expect(tabs.count == 1)
|
||||
#expect(tabs[0]["id"] as? String == "tab-1")
|
||||
}
|
||||
|
||||
/// Regression test: nested POST bodies must serialize without __SwiftValue crashes.
|
||||
@Test func `post request serializes nested body without crash`() async throws {
|
||||
actor BodyCapture {
|
||||
private var body: Data?
|
||||
|
||||
func set(_ body: Data?) {
|
||||
self.body = body
|
||||
}
|
||||
|
||||
func get() -> Data? {
|
||||
self.body
|
||||
}
|
||||
}
|
||||
|
||||
let capturedBody = BodyCapture()
|
||||
let proxy = MacNodeBrowserProxy(
|
||||
endpointProvider: {
|
||||
MacNodeBrowserProxy.Endpoint(
|
||||
baseURL: URL(string: "http://127.0.0.1:18791")!,
|
||||
token: nil,
|
||||
password: nil)
|
||||
},
|
||||
performRequest: { request in
|
||||
await capturedBody.set(request.httpBody)
|
||||
let url = try #require(request.url)
|
||||
let response = try #require(
|
||||
HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: nil))
|
||||
return (Data(#"{"ok":true}"#.utf8), response)
|
||||
})
|
||||
|
||||
_ = try await proxy.request(
|
||||
paramsJSON: #"{"method":"POST","path":"/action","body":{"nested":{"key":"val"},"arr":[1,2]}}"#)
|
||||
|
||||
let bodyData = try #require(await capturedBody.get())
|
||||
let parsed = try #require(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
|
||||
let nested = try #require(parsed["nested"] as? [String: Any])
|
||||
#expect(nested["key"] as? String == "val")
|
||||
let arr = try #require(parsed["arr"] as? [Any])
|
||||
#expect(arr.count == 2)
|
||||
}
|
||||
|
||||
@Test func `request reports actionable unavailable when control service is missing`() async throws {
|
||||
let proxy = MacNodeBrowserProxy(
|
||||
endpointProvider: {
|
||||
MacNodeBrowserProxy.Endpoint(
|
||||
baseURL: URL(string: "http://127.0.0.1:18791")!,
|
||||
token: nil,
|
||||
password: nil)
|
||||
},
|
||||
performRequest: { _ in
|
||||
throw URLError(.cannotConnectToHost)
|
||||
})
|
||||
|
||||
do {
|
||||
_ = try await proxy.request(paramsJSON: #"{"method":"GET","path":"/"}"#)
|
||||
Issue.record("request should fail when browser control is unreachable")
|
||||
} catch {
|
||||
let message = error.localizedDescription
|
||||
#expect(message.contains("UNAVAILABLE: macOS app node could not reach the local browser control service"))
|
||||
#expect(message.contains("http://127.0.0.1:18791"))
|
||||
#expect(message.contains("browser control is owned by the CLI node-host"))
|
||||
#expect(message.contains("openclaw node start"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
private struct WorkerBackpressureTimeout: Error {}
|
||||
|
||||
private actor StubMacNodeHostWorker: MacNodeHostWorking {
|
||||
let manifest = MacNodeHostManifest(
|
||||
version: "test",
|
||||
caps: ["system", "mcp"],
|
||||
commands: ["system.run", "mcp.tools.call.v1"],
|
||||
pathEnv: "/usr/bin:/bin")
|
||||
private var requests: [BridgeInvokeRequest] = []
|
||||
|
||||
func start(command _: [String]) async throws -> MacNodeHostManifest { self.manifest }
|
||||
func supports(_ command: String) async -> Bool { self.manifest.commands.contains(command) }
|
||||
|
||||
func invoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
self.requests.append(request)
|
||||
return BridgeInvokeResponse(id: request.id, ok: true, payloadJSON: #"{"owner":"cli"}"#)
|
||||
}
|
||||
|
||||
func setRoute(_: GatewayNodeSessionRoute?, authorityGeneration _: UInt64) async -> Bool { true }
|
||||
func publishInventory(ifCurrentRoute _: GatewayNodeSessionRoute) async {}
|
||||
func stop() async {}
|
||||
func invokedCommands() -> [String] { self.requests.map(\.command) }
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
struct MacNodeHostWorkerTests {
|
||||
@Test func `Mac runtime forwards CLI node commands to the shared worker`() async {
|
||||
let worker = StubMacNodeHostWorker()
|
||||
let runtime = MacNodeRuntime(nodeHostWorker: worker)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "worker-run",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: #"{"command":["/usr/bin/true"]}"#))
|
||||
|
||||
#expect(response.ok)
|
||||
#expect(response.payloadJSON == #"{"owner":"cli"}"#)
|
||||
#expect(await worker.invokedCommands() == [OpenClawSystemCommand.run.rawValue])
|
||||
}
|
||||
|
||||
@Test func `capability union preserves native order and adds worker commands once`() {
|
||||
#expect(MacNodeModeCoordinator.mergingUnique(
|
||||
["canvas", "screen", "system"],
|
||||
["system", "mcp"]) == ["canvas", "screen", "system", "mcp"])
|
||||
}
|
||||
|
||||
@Test func `stale route updates cannot replace newer worker authority`() {
|
||||
#expect(MacNodeHostWorker.routeUpdateIsCurrent(candidateGeneration: 4, currentGeneration: 4))
|
||||
#expect(MacNodeHostWorker.routeUpdateIsCurrent(candidateGeneration: 5, currentGeneration: 4))
|
||||
#expect(!MacNodeHostWorker.routeUpdateIsCurrent(candidateGeneration: 3, currentGeneration: 4))
|
||||
}
|
||||
|
||||
@Test func `worker forces app exec host without fallback`() async throws {
|
||||
let worker = MacNodeHostWorker(session: GatewayNodeSession())
|
||||
let script = """
|
||||
test "$OPENCLAW_NODE_EXEC_HOST" = app || exit 42
|
||||
test "$OPENCLAW_NODE_EXEC_FALLBACK" = 0 || exit 43
|
||||
printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}'
|
||||
printf '%s\\n' '{"type":"gateway-request","id":"gateway-1","method":"skills.bins","params":{},"timeoutMs":1000}'
|
||||
IFS= read -r unavailable
|
||||
printf '%s' "$unavailable" | grep -q '"type":"gateway-response"' || exit 44
|
||||
printf '%s' "$unavailable" | grep -q '"ok":false' || exit 45
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
*'"type":"invoke"'*) printf '%s\\n' '{"type":"invoke-result","result":{"id":"worker-run","ok":true,"payload":{"owner":"cli"}}}' ;;
|
||||
esac
|
||||
done
|
||||
"""
|
||||
|
||||
let manifest = try await worker.start(command: ["/bin/sh", "-c", script])
|
||||
#expect(manifest.commands == ["system.run"])
|
||||
let response = await worker.invoke(BridgeInvokeRequest(
|
||||
id: "worker-run",
|
||||
command: "system.run",
|
||||
paramsJSON: #"{"command":["/usr/bin/true"]}"#))
|
||||
#expect(response.ok)
|
||||
#expect(response.payload != nil)
|
||||
await worker.stop()
|
||||
}
|
||||
|
||||
@Test func `ready worker exit notifies its route owner`() async throws {
|
||||
try await confirmation("unexpected worker exit") { confirmed in
|
||||
let worker = MacNodeHostWorker(session: GatewayNodeSession()) {
|
||||
confirmed()
|
||||
}
|
||||
let script = """
|
||||
printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}'
|
||||
sleep 0.05
|
||||
exit 7
|
||||
"""
|
||||
|
||||
_ = try await worker.start(command: ["/bin/sh", "-c", script])
|
||||
try? await Task.sleep(for: .milliseconds(200))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `changed worker command replaces the running process`() async throws {
|
||||
let worker = MacNodeHostWorker(session: GatewayNodeSession())
|
||||
let firstScript = """
|
||||
printf '%s\\n' '{"type":"ready","version":"first","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}'
|
||||
while IFS= read -r line; do :; done
|
||||
"""
|
||||
let secondScript = """
|
||||
printf '%s\\n' '{"type":"ready","version":"second","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}'
|
||||
while IFS= read -r line; do :; done
|
||||
"""
|
||||
|
||||
let first = try await worker.start(command: ["/bin/sh", "-c", firstScript])
|
||||
let second = try await worker.start(command: ["/bin/sh", "-c", secondScript])
|
||||
|
||||
#expect(first.version == "first")
|
||||
#expect(second.version == "second")
|
||||
await worker.stop()
|
||||
}
|
||||
|
||||
@Test func `worker drains stdout while a large stdin frame is backpressured`() async throws {
|
||||
let worker = MacNodeHostWorker(session: GatewayNodeSession())
|
||||
let script = """
|
||||
printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}'
|
||||
IFS= read -r first
|
||||
printf '{"type":"invoke-result","result":{"id":"first","ok":true,"payload":{"blob":"'
|
||||
head -c 2097152 /dev/zero | tr '\\000' x
|
||||
printf '"}}}\\n'
|
||||
IFS= read -r second
|
||||
printf '%s\\n' '{"type":"invoke-result","result":{"id":"second","ok":true,"payload":{"done":true}}}'
|
||||
"""
|
||||
_ = try await worker.start(command: ["/bin/sh", "-c", script])
|
||||
|
||||
let first = Task {
|
||||
await worker.invoke(BridgeInvokeRequest(
|
||||
id: "first",
|
||||
command: "system.run",
|
||||
paramsJSON: #"{"command":["/usr/bin/true"]}"#))
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
let largeParams = #"{"blob":""# + String(repeating: "x", count: 2 * 1024 * 1024) + #""}"#
|
||||
let second = Task {
|
||||
await worker.invoke(BridgeInvokeRequest(
|
||||
id: "second",
|
||||
command: "system.run",
|
||||
paramsJSON: largeParams))
|
||||
}
|
||||
|
||||
do {
|
||||
let responses = try await AsyncTimeout.withTimeout(
|
||||
seconds: 5,
|
||||
onTimeout: { WorkerBackpressureTimeout() },
|
||||
operation: { [first, second] in [await first.value, await second.value] })
|
||||
await worker.stop()
|
||||
#expect(responses.allSatisfy { $0.ok })
|
||||
} catch {
|
||||
await worker.stop()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,24 @@ private actor CoordinatorDrainSnapshotProbe {
|
||||
}
|
||||
}
|
||||
|
||||
private actor CoordinatorNodeHostWorkerProbe: MacNodeHostWorking {
|
||||
private var stopCount = 0
|
||||
|
||||
func start(command _: [String]) async throws -> MacNodeHostManifest {
|
||||
MacNodeHostManifest(version: "test", caps: [], commands: [], pathEnv: "/usr/bin:/bin")
|
||||
}
|
||||
|
||||
func supports(_: String) async -> Bool { false }
|
||||
func invoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
BridgeInvokeResponse(id: request.id, ok: false)
|
||||
}
|
||||
|
||||
func setRoute(_: GatewayNodeSessionRoute?, authorityGeneration _: UInt64) async -> Bool { true }
|
||||
func publishInventory(ifCurrentRoute _: GatewayNodeSessionRoute) async {}
|
||||
func stop() async { self.stopCount += 1 }
|
||||
func stops() -> Int { self.stopCount }
|
||||
}
|
||||
|
||||
struct MacNodeModeCoordinatorTests {
|
||||
private func waitUntil(
|
||||
_ description: String,
|
||||
@@ -125,6 +143,29 @@ struct MacNodeModeCoordinatorTests {
|
||||
currentGeneration: 8))
|
||||
}
|
||||
|
||||
@Test @MainActor func `config and CLI changes restart startup scoped node host worker`() async throws {
|
||||
let worker = CoordinatorNodeHostWorkerProbe()
|
||||
let session = GatewayNodeSession()
|
||||
let coordinator = MacNodeModeCoordinator(
|
||||
session: session,
|
||||
runtime: MacNodeRuntime(nodeHostWorker: worker),
|
||||
nodeHostWorker: worker,
|
||||
observeNotifications: true)
|
||||
_ = coordinator
|
||||
|
||||
NotificationCenter.default.post(name: .openclawConfigDidChange, object: nil)
|
||||
|
||||
try await self.waitUntil("node-host worker restart") {
|
||||
await worker.stops() == 1
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(name: .openclawCLIInstalled, object: nil)
|
||||
|
||||
try await self.waitUntil("node-host worker restart") {
|
||||
await worker.stops() == 2
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `paused node state requires route disconnect`() {
|
||||
#expect(MacNodeModeCoordinator.pausedStateRequiresDisconnect(true))
|
||||
#expect(!MacNodeModeCoordinator.pausedStateRequiresDisconnect(false))
|
||||
@@ -420,7 +461,7 @@ struct MacNodeModeCoordinatorTests {
|
||||
isExistingInstallation: false) == .primary)
|
||||
}
|
||||
|
||||
@Test func `remote mode does not advertise browser proxy`() {
|
||||
@Test func `native manifest excludes CLI-owned node commands`() {
|
||||
let caps = MacNodeModeCoordinator.resolvedCaps(
|
||||
browserControlEnabled: true,
|
||||
cameraEnabled: false,
|
||||
@@ -433,10 +474,11 @@ struct MacNodeModeCoordinatorTests {
|
||||
#expect(!commands.contains(OpenClawBrowserCommand.proxy.rawValue))
|
||||
#expect(commands.contains(OpenClawCanvasCommand.present.rawValue))
|
||||
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
|
||||
#expect(commands.contains(OpenClawFileSystemCommand.listDir.rawValue))
|
||||
#expect(!commands.contains(OpenClawFileSystemCommand.listDir.rawValue))
|
||||
#expect(!commands.contains(OpenClawSystemCommand.run.rawValue))
|
||||
}
|
||||
|
||||
@Test func `local mode advertises browser proxy when enabled`() {
|
||||
@Test func `local native manifest leaves browser proxy to the CLI worker`() {
|
||||
let caps = MacNodeModeCoordinator.resolvedCaps(
|
||||
browserControlEnabled: true,
|
||||
cameraEnabled: false,
|
||||
@@ -445,8 +487,8 @@ struct MacNodeModeCoordinatorTests {
|
||||
connectionMode: .local)
|
||||
let commands = MacNodeModeCoordinator.resolvedCommands(caps: caps)
|
||||
|
||||
#expect(caps.contains(OpenClawCapability.browser.rawValue))
|
||||
#expect(commands.contains(OpenClawBrowserCommand.proxy.rawValue))
|
||||
#expect(!caps.contains(OpenClawCapability.browser.rawValue))
|
||||
#expect(!commands.contains(OpenClawBrowserCommand.proxy.rawValue))
|
||||
}
|
||||
|
||||
@Test func `local mode omits native session catalogs`() {
|
||||
|
||||
@@ -61,37 +61,6 @@ struct MacNodeRuntimeTests {
|
||||
}
|
||||
}
|
||||
|
||||
actor ExecEventProbe {
|
||||
private var captured: [(event: String, json: String)] = []
|
||||
|
||||
func append(event: String, json: String?) {
|
||||
self.captured.append((event: event, json: json ?? ""))
|
||||
}
|
||||
|
||||
func events() -> [(event: String, json: String)] {
|
||||
self.captured
|
||||
}
|
||||
}
|
||||
|
||||
actor ShellRunProbe {
|
||||
private var commands: [[String]] = []
|
||||
|
||||
func run(_ command: [String]) -> ShellExecutor.ShellResult {
|
||||
self.commands.append(command)
|
||||
return ShellExecutor.ShellResult(
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
success: true,
|
||||
errorMessage: nil)
|
||||
}
|
||||
|
||||
func capturedCommands() -> [[String]] {
|
||||
self.commands
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ScreenSnapshotProbeServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
|
||||
var snapshotCallCount = 0
|
||||
@@ -183,69 +152,6 @@ struct MacNodeRuntimeTests {
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func `file system list directory returns only sorted directories`() async throws {
|
||||
struct Entry: Decodable, Equatable {
|
||||
var name: String
|
||||
var path: String
|
||||
var hidden: Bool?
|
||||
}
|
||||
struct Payload: Decodable {
|
||||
var path: String
|
||||
var parent: String?
|
||||
var home: String
|
||||
var entries: [Entry]
|
||||
}
|
||||
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("MacNodeRuntimeTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let projects = root.appendingPathComponent("Projects", isDirectory: true)
|
||||
let hidden = root.appendingPathComponent(".hidden", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: projects, withIntermediateDirectories: false)
|
||||
try FileManager.default.createDirectory(at: hidden, withIntermediateDirectories: false)
|
||||
try Data("not a directory".utf8).write(to: root.appendingPathComponent("notes.txt"))
|
||||
|
||||
let paramsJSON = try String(
|
||||
decoding: JSONEncoder().encode(["path": root.path]),
|
||||
as: UTF8.self)
|
||||
let response = await MacNodeRuntime().handleInvoke(BridgeInvokeRequest(
|
||||
id: "req-fs-list-dir",
|
||||
command: OpenClawFileSystemCommand.listDir.rawValue,
|
||||
paramsJSON: paramsJSON))
|
||||
let payloadJSON = try #require(response.payloadJSON)
|
||||
let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8))
|
||||
|
||||
#expect(response.ok)
|
||||
#expect(payload.path == root.path)
|
||||
#expect(payload.parent == root.deletingLastPathComponent().path)
|
||||
#expect(payload.home == FileManager.default.homeDirectoryForCurrentUser.path)
|
||||
let listedRoot = URL(fileURLWithPath: payload.path, isDirectory: true)
|
||||
#expect(payload.entries == [
|
||||
Entry(
|
||||
name: "Projects",
|
||||
path: listedRoot.appendingPathComponent("Projects", isDirectory: true).path,
|
||||
hidden: nil),
|
||||
Entry(
|
||||
name: ".hidden",
|
||||
path: listedRoot.appendingPathComponent(".hidden", isDirectory: true).path,
|
||||
hidden: true),
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `file system list directory rejects relative paths`() async throws {
|
||||
let paramsJSON = try String(
|
||||
decoding: JSONEncoder().encode(["path": "Projects"]),
|
||||
as: UTF8.self)
|
||||
let response = await MacNodeRuntime().handleInvoke(BridgeInvokeRequest(
|
||||
id: "req-fs-list-dir-relative",
|
||||
command: OpenClawFileSystemCommand.listDir.rawValue,
|
||||
paramsJSON: paramsJSON))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.code == .invalidRequest)
|
||||
}
|
||||
|
||||
@Test func `handle invoke returns injected Codex thread catalog`() async {
|
||||
let payload = #"{"sessions":[]}"#
|
||||
let runtime = MacNodeRuntime(
|
||||
@@ -376,214 +282,6 @@ struct MacNodeRuntimeTests {
|
||||
#expect(await probe.calls == 1)
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects empty system run`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemRunParams(command: [])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func `system run rejects raw command prompt spoof before execution`() async throws {
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/usr/bin/printf", "unsafe"],
|
||||
rawCommand: "echo safe")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "req-raw-command-spoof",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.code == .invalidRequest)
|
||||
#expect(response.error?.message.contains("rawCommand does not match command") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
|
||||
@Test func `system run rejects mismatched shell payload preview before execution`() async throws {
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/bin/sh", "-lc", "/usr/bin/printf unsafe"],
|
||||
rawCommand: "echo safe")
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "req-shell-preview-spoof",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.code == .invalidRequest)
|
||||
#expect(response.error?.message.contains("rawCommand does not match command") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
|
||||
@Test func `system run shares padded executable rejection with socket host`() async throws {
|
||||
let probe = ShellRunProbe()
|
||||
let runtime = MacNodeRuntime(
|
||||
shellRunner: { command, _, _, _ in await probe.run(command) })
|
||||
let params = OpenClawSystemRunParams(command: [" /usr/bin/printf ", "unsafe"])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
|
||||
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
||||
id: "req-padded-executable",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(!response.ok)
|
||||
#expect(response.error?.code == .invalidRequest)
|
||||
#expect(response.error?.message.contains("executable has surrounding whitespace") == true)
|
||||
#expect(await probe.capturedCommands().isEmpty)
|
||||
}
|
||||
|
||||
@Test func `exec approvals snapshot reports resolved host defaults`() async throws {
|
||||
let root = URL(
|
||||
fileURLWithPath: "/tmp/oc-appr-\(UUID().uuidString.prefix(8))",
|
||||
isDirectory: true)
|
||||
let home = root.appendingPathComponent("home", isDirectory: true)
|
||||
let stateDir = root.appendingPathComponent("state", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
|
||||
let initial = ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
try JSONEncoder().encode(initial)
|
||||
.write(to: stateDir.appendingPathComponent("exec-approvals.json"))
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_HOME": home.path,
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
]) {
|
||||
let runtime = MacNodeRuntime()
|
||||
let legacyResponse = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-approvals-get-legacy",
|
||||
command: OpenClawSystemCommand.execApprovalsGet.rawValue))
|
||||
#expect(legacyResponse.ok)
|
||||
let legacyPayloadJSON = try #require(legacyResponse.payloadJSON)
|
||||
let legacyPayload = try #require(
|
||||
JSONSerialization.jsonObject(with: Data(legacyPayloadJSON.utf8)) as? [String: Any])
|
||||
#expect(legacyPayload["resolvedDefaults"] == nil)
|
||||
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-approvals-get-resolved",
|
||||
command: OpenClawSystemCommand.execApprovalsGet.rawValue,
|
||||
paramsJSON: #"{"includeResolvedDefaults":true}"#))
|
||||
|
||||
#expect(response.ok)
|
||||
let payloadJSON = try #require(response.payloadJSON)
|
||||
struct Snapshot: Decodable {
|
||||
let resolvedDefaults: ExecApprovalsResolvedDefaults
|
||||
}
|
||||
let snapshot = try JSONDecoder().decode(Snapshot.self, from: Data(payloadJSON.utf8))
|
||||
#expect(snapshot.resolvedDefaults.security == .full)
|
||||
#expect(snapshot.resolvedDefaults.ask == .off)
|
||||
#expect(snapshot.resolvedDefaults.askFallback == .deny)
|
||||
#expect(snapshot.resolvedDefaults.autoAllowSkills == false)
|
||||
|
||||
try ExecApprovalsStore.updateDefaults { defaults in
|
||||
defaults.security = .deny
|
||||
defaults.ask = .onMiss
|
||||
defaults.askFallback = .deny
|
||||
defaults.autoAllowSkills = false
|
||||
}.get()
|
||||
let persistedResponse = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-approvals-get-persisted",
|
||||
command: OpenClawSystemCommand.execApprovalsGet.rawValue,
|
||||
paramsJSON: #"{"includeResolvedDefaults":true}"#))
|
||||
#expect(persistedResponse.ok)
|
||||
let persistedPayloadJSON = try #require(persistedResponse.payloadJSON)
|
||||
let persistedSnapshot = try JSONDecoder().decode(
|
||||
Snapshot.self,
|
||||
from: Data(persistedPayloadJSON.utf8))
|
||||
#expect(persistedSnapshot.resolvedDefaults.security == .deny)
|
||||
#expect(persistedSnapshot.resolvedDefaults.ask == .onMiss)
|
||||
#expect(persistedSnapshot.resolvedDefaults.askFallback == .deny)
|
||||
#expect(persistedSnapshot.resolvedDefaults.autoAllowSkills == false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `system run denied event preserves gateway run id`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
|
||||
let probe = ExecEventProbe()
|
||||
let runtime = MacNodeRuntime()
|
||||
await runtime.setEventSender { event, json in
|
||||
await probe.append(event: event, json: json)
|
||||
}
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/bin/sh", "-lc", "printf ok"],
|
||||
rawCommand: "printf ok",
|
||||
sessionKey: "agent:main:main",
|
||||
runId: "gateway-run-1",
|
||||
approvalDecision: ExecApprovalDecision.deny.rawValue)
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-run-id",
|
||||
command: OpenClawSystemCommand.run.rawValue,
|
||||
paramsJSON: json))
|
||||
|
||||
#expect(response.ok == false)
|
||||
let denied = try #require(await (probe.events()).first { $0.event == "exec.denied" })
|
||||
struct Payload: Decodable {
|
||||
var sessionKey: String
|
||||
var runId: String
|
||||
var command: String
|
||||
}
|
||||
let payload = try JSONDecoder().decode(Payload.self, from: Data(denied.json.utf8))
|
||||
#expect(payload.sessionKey == "agent:main:main")
|
||||
#expect(payload.runId == "gateway-run-1")
|
||||
#expect(payload.command == ExecCommandFormatter.displayString(for: params.command))
|
||||
#expect(payload.command != params.rawCommand)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects blocked system run env override before execution`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/bin/sh", "-lc", "echo ok"],
|
||||
env: ["CLASSPATH": "/tmp/evil-classpath"])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2c", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
|
||||
#expect(response.error?.message.contains("CLASSPATH") == true)
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects invalid system run env override key before execution`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/bin/sh", "-lc", "echo ok"],
|
||||
env: ["BAD-KEY": "x"])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2d", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
|
||||
#expect(response.error?.message.contains("BAD-KEY") == true)
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects empty system which`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemWhichParams(bins: [])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2b", command: OpenClawSystemCommand.which.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects empty notification`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemNotifyParams(title: "", body: "")
|
||||
@@ -1282,38 +980,4 @@ struct MacNodeRuntimeTests {
|
||||
#expect(controlHeavyProjection > 25 * 1024 * 1024)
|
||||
}
|
||||
|
||||
@Test func `handle invoke browser proxy uses injected request`() async {
|
||||
let runtime = MacNodeRuntime(
|
||||
browserProxyRequest: { paramsJSON in
|
||||
#expect(paramsJSON?.contains("/tabs") == true)
|
||||
return #"{"result":{"ok":true,"tabs":[{"id":"tab-1"}]}}"#
|
||||
},
|
||||
browserControlEnabled: { true })
|
||||
let paramsJSON = #"{"method":"GET","path":"/tabs","timeoutMs":2500}"#
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-browser",
|
||||
command: OpenClawBrowserCommand.proxy.rawValue,
|
||||
paramsJSON: paramsJSON))
|
||||
|
||||
#expect(response.ok == true)
|
||||
#expect(response.payloadJSON == #"{"result":{"ok":true,"tabs":[{"id":"tab-1"}]}}"#)
|
||||
}
|
||||
|
||||
@Test func `handle invoke browser proxy rejects disabled browser control`() async {
|
||||
let runtime = MacNodeRuntime(
|
||||
browserProxyRequest: { _ in
|
||||
Issue.record("browserProxyRequest should not run when browser control is disabled")
|
||||
return "{}"
|
||||
},
|
||||
browserControlEnabled: { false })
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "req-browser-disabled",
|
||||
command: OpenClawBrowserCommand.proxy.rawValue,
|
||||
paramsJSON: #"{"method":"GET","path":"/tabs"}"#))
|
||||
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("BROWSER_DISABLED") == true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,16 @@ struct OnboardingViewSmokeTests {
|
||||
#expect(!order.contains(2))
|
||||
}
|
||||
|
||||
@Test func `fresh remote setup installs CLI for the Mac node worker`() {
|
||||
let order = OnboardingView.pageOrder(
|
||||
for: .remote,
|
||||
requiresCLIInstall: true)
|
||||
|
||||
#expect(order.contains(2))
|
||||
#expect(!OnboardingView.shouldActivateLocalGateway(afterCLIInstallFor: .remote))
|
||||
#expect(OnboardingView.shouldActivateLocalGateway(afterCLIInstallFor: .local))
|
||||
}
|
||||
|
||||
@Test func `fresh onboarding defaults to this Mac`() {
|
||||
let state = AppState(preview: true)
|
||||
state.onboardingSeen = false
|
||||
|
||||
@@ -99,6 +99,22 @@ struct UpdateOrchestrationTests {
|
||||
launchAgentWriteDisabled: false))
|
||||
}
|
||||
|
||||
@Test func `CLI management follows configured node modes`() {
|
||||
#expect(CLIInstallPrompter.shouldManageCLI(connectionMode: .local))
|
||||
#expect(CLIInstallPrompter.shouldManageCLI(connectionMode: .remote))
|
||||
#expect(!CLIInstallPrompter.shouldManageCLI(connectionMode: .unconfigured))
|
||||
|
||||
#expect(CLIInstallPrompter.shouldRestartManagedGateway(
|
||||
requested: true,
|
||||
connectionMode: .local))
|
||||
#expect(!CLIInstallPrompter.shouldRestartManagedGateway(
|
||||
requested: true,
|
||||
connectionMode: .remote))
|
||||
#expect(!CLIInstallPrompter.shouldRestartManagedGateway(
|
||||
requested: false,
|
||||
connectionMode: .local))
|
||||
}
|
||||
|
||||
@Test func `managed repair only upgrades`() {
|
||||
#expect(CLIInstallPrompter.isManagedUpgrade(found: "2026.7.1", required: "2026.7.2"))
|
||||
#expect(!CLIInstallPrompter.isManagedUpgrade(found: "2026.7.2", required: "2026.7.1"))
|
||||
|
||||
@@ -34,6 +34,7 @@ public struct BridgeInvokeResponse: Codable, Sendable {
|
||||
public let type: String
|
||||
public let id: String
|
||||
public let ok: Bool
|
||||
public let payload: AnyCodable?
|
||||
public let payloadJSON: String?
|
||||
public let error: OpenClawNodeError?
|
||||
|
||||
@@ -41,12 +42,14 @@ public struct BridgeInvokeResponse: Codable, Sendable {
|
||||
type: String = "invoke-res",
|
||||
id: String,
|
||||
ok: Bool,
|
||||
payload: AnyCodable? = nil,
|
||||
payloadJSON: String? = nil,
|
||||
error: OpenClawNodeError? = nil)
|
||||
{
|
||||
self.type = type
|
||||
self.id = id
|
||||
self.ok = ok
|
||||
self.payload = payload
|
||||
self.payloadJSON = payloadJSON
|
||||
self.error = error
|
||||
}
|
||||
|
||||
@@ -459,19 +459,10 @@ public actor GatewayChannelActor {
|
||||
selectedAuth: selectedAuth)
|
||||
|
||||
let reqId = UUID().uuidString
|
||||
var client: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(clientId),
|
||||
"displayName": ProtoAnyCodable(clientDisplayName),
|
||||
"version": ProtoAnyCodable(
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
||||
"platform": ProtoAnyCodable(platform),
|
||||
"mode": ProtoAnyCodable(clientMode),
|
||||
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
|
||||
]
|
||||
client["deviceFamily"] = ProtoAnyCodable(InstanceIdentity.deviceFamily)
|
||||
if let model = InstanceIdentity.modelIdentifier {
|
||||
client["modelIdentifier"] = ProtoAnyCodable(model)
|
||||
}
|
||||
let client = GatewayConnectPayload.makeClient(
|
||||
options: options,
|
||||
displayName: clientDisplayName,
|
||||
platform: platform)
|
||||
var params: [String: ProtoAnyCodable] = [
|
||||
"minProtocol": ProtoAnyCodable(GATEWAY_MIN_PROTOCOL_VERSION),
|
||||
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
@@ -485,6 +476,9 @@ public actor GatewayChannelActor {
|
||||
if !options.commands.isEmpty {
|
||||
params["commands"] = ProtoAnyCodable(options.commands)
|
||||
}
|
||||
if let pathEnv = options.pathEnv?.trimmingCharacters(in: .whitespacesAndNewlines), !pathEnv.isEmpty {
|
||||
params["pathEnv"] = ProtoAnyCodable(pathEnv)
|
||||
}
|
||||
if !options.permissions.isEmpty {
|
||||
params["permissions"] = ProtoAnyCodable(options.permissions)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ public struct GatewayConnectOptions: Sendable {
|
||||
public var scopesAreExplicit: Bool
|
||||
public var caps: [String]
|
||||
public var commands: [String]
|
||||
public var pathEnv: String?
|
||||
public var permissions: [String: Bool]
|
||||
public var clientId: String
|
||||
public var clientMode: String
|
||||
@@ -26,6 +27,7 @@ public struct GatewayConnectOptions: Sendable {
|
||||
scopesAreExplicit: Bool = false,
|
||||
caps: [String],
|
||||
commands: [String],
|
||||
pathEnv: String? = nil,
|
||||
permissions: [String: Bool],
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
@@ -40,6 +42,7 @@ public struct GatewayConnectOptions: Sendable {
|
||||
self.scopesAreExplicit = scopesAreExplicit
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.pathEnv = pathEnv
|
||||
self.permissions = permissions
|
||||
self.clientId = clientId
|
||||
self.clientMode = clientMode
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
|
||||
enum GatewayConnectPayload {
|
||||
static func makeClient(
|
||||
options: GatewayConnectOptions,
|
||||
displayName: String,
|
||||
platform: String) -> [String: OpenClawProtocol.AnyCodable]
|
||||
{
|
||||
var client: [String: OpenClawProtocol.AnyCodable] = [
|
||||
"id": OpenClawProtocol.AnyCodable(options.clientId),
|
||||
"displayName": OpenClawProtocol.AnyCodable(displayName),
|
||||
"version": OpenClawProtocol.AnyCodable(
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
||||
"platform": OpenClawProtocol.AnyCodable(platform),
|
||||
"mode": OpenClawProtocol.AnyCodable(options.clientMode),
|
||||
"instanceId": OpenClawProtocol.AnyCodable(InstanceIdentity.instanceId),
|
||||
"deviceFamily": OpenClawProtocol.AnyCodable(InstanceIdentity.deviceFamily),
|
||||
]
|
||||
if let model = InstanceIdentity.modelIdentifier {
|
||||
client["modelIdentifier"] = OpenClawProtocol.AnyCodable(model)
|
||||
}
|
||||
return client
|
||||
}
|
||||
}
|
||||
@@ -289,6 +289,7 @@ public actor GatewayNodeSession {
|
||||
let scopes = sorted(options.scopes)
|
||||
let caps = sorted(options.caps)
|
||||
let commands = sorted(options.commands)
|
||||
let pathEnv = options.pathEnv?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -308,6 +309,7 @@ public actor GatewayNodeSession {
|
||||
scopes,
|
||||
caps,
|
||||
commands,
|
||||
pathEnv,
|
||||
clientId,
|
||||
clientMode,
|
||||
clientDisplayName,
|
||||
@@ -1309,6 +1311,7 @@ extension GatewayNodeSession {
|
||||
type: response.type,
|
||||
id: requestId,
|
||||
ok: response.ok,
|
||||
payload: response.payload,
|
||||
payloadJSON: response.payloadJSON,
|
||||
error: response.error)
|
||||
}
|
||||
@@ -1392,6 +1395,9 @@ extension GatewayNodeSession {
|
||||
if let payloadJSON = response.payloadJSON {
|
||||
params["payloadJSON"] = AnyCodable(payloadJSON)
|
||||
}
|
||||
if let payload = response.payload {
|
||||
params["payload"] = payload
|
||||
}
|
||||
if let error = response.error {
|
||||
params["error"] = AnyCodable([
|
||||
"code": error.code.rawValue,
|
||||
|
||||
@@ -1919,6 +1919,49 @@ struct GatewayNodeSessionTests {
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
func `node invoke result preserves structured worker payload`() async throws {
|
||||
let session = FakeGatewayWebSocketSession()
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: ["mcp"],
|
||||
commands: ["mcp.tools.call.v1"],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "macOS Test",
|
||||
includeDeviceIdentity: false)
|
||||
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://example.invalid")),
|
||||
credentials: .init(),
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { request in
|
||||
BridgeInvokeResponse(
|
||||
id: request.id,
|
||||
ok: true,
|
||||
payload: AnyCodable(["content": [["type": "text", "text": "worker-ok"]]]))
|
||||
})
|
||||
let task = try #require(session.latestTask())
|
||||
task.emitInvokeRequest(id: "mcp-structured", command: "mcp.tools.call.v1")
|
||||
|
||||
try await waitUntil("structured invoke result") {
|
||||
task.sentRequestCount(method: "node.invoke.result") == 1
|
||||
}
|
||||
let result = try #require(task.sentRequests(method: "node.invoke.result").first)
|
||||
let params = try #require(result["params"] as? [String: Any])
|
||||
let payload = try #require(params["payload"] as? [String: Any])
|
||||
let content = try #require(payload["content"] as? [[String: Any]])
|
||||
#expect(content.first?["text"] as? String == "worker-ok")
|
||||
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
func `computer invoke receipts deduplicate in flight and after reconnect`() async throws {
|
||||
let session = FakeGatewayWebSocketSession()
|
||||
|
||||
@@ -11,6 +11,11 @@ title: "Node"
|
||||
Run a **headless node host** that connects to the Gateway WebSocket and exposes
|
||||
`system.run` / `system.which` on this machine.
|
||||
|
||||
On macOS, the menu bar app already embeds this node-host runtime into its own
|
||||
node connection and adds native Mac capabilities. Use `openclaw node run` on a
|
||||
Mac only when you intentionally want a headless node without the app. Running
|
||||
both creates two node identities for the same machine.
|
||||
|
||||
## Why use a node host?
|
||||
|
||||
Use a node host when you want agents to **run commands on other machines** in your
|
||||
|
||||
+6
-1
@@ -11,7 +11,12 @@ A **node** is a companion device (macOS/iOS/watchOS/Android/headless) that conne
|
||||
|
||||
Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; historical only for current nodes).
|
||||
|
||||
macOS can also run in **node mode**: the menubar app connects to the Gateway's WS server and exposes its local canvas/camera commands as a node (so `openclaw nodes …` works against this Mac). In remote gateway mode, browser automation is handled by the CLI node host (`openclaw node run` or the installed node service), not by the native app node.
|
||||
macOS can also run in **node mode**: the menu bar app connects to the Gateway's
|
||||
WS server as one node (so `openclaw nodes …` works against this Mac). The app
|
||||
adds native Canvas, camera, screen, notification, and computer-control commands
|
||||
to the same node-host command surface used by `openclaw node run`. Do not start a
|
||||
second CLI node on that Mac; the app runs the matching CLI node-host runtime as
|
||||
an internal worker and remains the sole Gateway connection and node identity.
|
||||
|
||||
Nodes are **peripherals**, not gateways: they don't run the gateway service, and channel messages (Telegram, WhatsApp, etc.) land on the gateway, not on nodes.
|
||||
|
||||
|
||||
+11
-4
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Minimal Gateway request surface consumed by the reusable node-host runtime. */
|
||||
import type { GatewayClientRequestOptions } from "../gateway/client.js";
|
||||
|
||||
export type NodeHostClient = {
|
||||
request<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
opts?: GatewayClientRequestOptions,
|
||||
): Promise<T>;
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import {
|
||||
describeInterpreterInlineEval,
|
||||
type InterpreterInlineEvalHit,
|
||||
@@ -49,6 +48,7 @@ import { normalizeSystemRunApprovalPlan } from "../infra/system-run-approval-bin
|
||||
import { formatExecCommand, resolveSystemRunCommandRequest } from "../infra/system-run-command.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js";
|
||||
import {
|
||||
applyOutputTruncation,
|
||||
@@ -263,7 +263,7 @@ async function resolveSystemRunAutoReviewer(params: {
|
||||
}
|
||||
|
||||
export type HandleSystemRunInvokeOptions = {
|
||||
client: GatewayClient;
|
||||
client: NodeHostClient;
|
||||
params: SystemRunParams;
|
||||
skillBins: SkillBinsProvider;
|
||||
execHostEnforced: boolean;
|
||||
@@ -282,7 +282,7 @@ export type HandleSystemRunInvokeOptions = {
|
||||
approvals: ExecApprovalsResolved;
|
||||
request: ExecHostRequest;
|
||||
}) => Promise<ExecHostResponse | null>;
|
||||
sendNodeEvent: (client: GatewayClient, event: string, payload: unknown) => Promise<void>;
|
||||
sendNodeEvent: (client: NodeHostClient, event: string, payload: unknown) => Promise<void>;
|
||||
buildExecEventPayload: (payload: ExecEventPayload) => ExecEventPayload;
|
||||
sendInvokeResult: (result: SystemRunInvokeResult) => Promise<void>;
|
||||
sendExecFinishedEvent: (params: ExecFinishedEventParams) => Promise<void>;
|
||||
|
||||
+14
-14
@@ -8,7 +8,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
|
||||
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
|
||||
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { mcpContentBlockToAgentContent } from "../agents/mcp-content.js";
|
||||
import { GatewayClient } from "../gateway/client.js";
|
||||
import {
|
||||
analyzeArgvCommand,
|
||||
createExecApprovalPolicySnapshot,
|
||||
@@ -46,6 +45,7 @@ import {
|
||||
} from "../infra/windows-encoding.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { truncateUtf8Prefix } from "../utils/utf8-truncate.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
import {
|
||||
buildSystemRunApprovalPlan,
|
||||
handleSystemRunInvoke,
|
||||
@@ -226,7 +226,7 @@ type ExecApprovalsSnapshot = {
|
||||
file: ExecApprovalsFile;
|
||||
};
|
||||
|
||||
type NodeInvokeRequestPayload = {
|
||||
export type NodeInvokeRequestPayload = {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
command: string;
|
||||
@@ -540,7 +540,7 @@ function buildExecEventPayload(payload: ExecEventPayload): ExecEventPayload {
|
||||
|
||||
async function sendExecFinishedEvent(
|
||||
params: ExecFinishedEventParams & {
|
||||
client: GatewayClient;
|
||||
client: NodeHostClient;
|
||||
},
|
||||
) {
|
||||
const combined = [params.result.stdout, params.result.stderr, params.result.error]
|
||||
@@ -576,7 +576,7 @@ async function runViaMacAppExecHost(params: {
|
||||
}
|
||||
|
||||
async function sendJsonPayloadResult(
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
frame: NodeInvokeRequestPayload,
|
||||
payload: unknown,
|
||||
) {
|
||||
@@ -587,7 +587,7 @@ async function sendJsonPayloadResult(
|
||||
}
|
||||
|
||||
async function sendMcpPayloadResult(
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
frame: NodeInvokeRequestPayload,
|
||||
payload: unknown,
|
||||
) {
|
||||
@@ -595,7 +595,7 @@ async function sendMcpPayloadResult(
|
||||
}
|
||||
|
||||
async function sendRawPayloadResult(
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
frame: NodeInvokeRequestPayload,
|
||||
payloadJSON: string,
|
||||
) {
|
||||
@@ -606,7 +606,7 @@ async function sendRawPayloadResult(
|
||||
}
|
||||
|
||||
async function sendErrorResult(
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
frame: NodeInvokeRequestPayload,
|
||||
code: string,
|
||||
message: string,
|
||||
@@ -618,7 +618,7 @@ async function sendErrorResult(
|
||||
}
|
||||
|
||||
async function sendInvalidRequestResult(
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
frame: NodeInvokeRequestPayload,
|
||||
err: unknown,
|
||||
) {
|
||||
@@ -632,7 +632,7 @@ function classifyExecApprovalsStorageError(err: unknown): "TIMEOUT" | "UNAVAILAB
|
||||
}
|
||||
|
||||
async function sendExecApprovalsStorageErrorResult(
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
frame: NodeInvokeRequestPayload,
|
||||
err: unknown,
|
||||
) {
|
||||
@@ -642,7 +642,7 @@ async function sendExecApprovalsStorageErrorResult(
|
||||
/** Handles one node-host command invocation payload and returns serialized results. */
|
||||
export async function handleInvoke(
|
||||
frame: NodeInvokeRequestPayload,
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
skillBins: SkillBinsProvider,
|
||||
mcpManager?: NodeHostMcpManager,
|
||||
) {
|
||||
@@ -668,7 +668,7 @@ export async function handleInvoke(
|
||||
|
||||
async function dispatchInvoke(
|
||||
frame: NodeInvokeRequestPayload,
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
skillBins: SkillBinsProvider,
|
||||
mcpManager?: NodeHostMcpManager,
|
||||
) {
|
||||
@@ -1046,7 +1046,7 @@ function mcpToolErrorMessage(result: { content: readonly unknown[] }): string {
|
||||
|
||||
async function handleMcpToolsCall(
|
||||
frame: NodeInvokeRequestPayload,
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
mcpManager: NodeHostMcpManager | undefined,
|
||||
): Promise<void> {
|
||||
if (!mcpManager) {
|
||||
@@ -1126,7 +1126,7 @@ export function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayl
|
||||
}
|
||||
|
||||
async function sendInvokeResult(
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
frame: NodeInvokeRequestPayload,
|
||||
result: {
|
||||
ok: boolean;
|
||||
@@ -1193,7 +1193,7 @@ export function buildNodeEventParams(
|
||||
};
|
||||
}
|
||||
|
||||
async function sendNodeEvent(client: GatewayClient, event: string, payload: unknown) {
|
||||
async function sendNodeEvent(client: NodeHostClient, event: string, payload: unknown) {
|
||||
try {
|
||||
await client.request("node.event", buildNodeEventParams(event, payload));
|
||||
} catch {
|
||||
|
||||
+24
-170
@@ -1,5 +1,4 @@
|
||||
/** CLI runner for node-host stdin/stdout command dispatch. */
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
@@ -14,30 +13,11 @@ import {
|
||||
} from "../gateway/client.js";
|
||||
import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
|
||||
import { resolveExecutableFromPathEnv } from "../infra/executable-path.js";
|
||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import {
|
||||
NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
NODE_SYSTEM_RUN_COMMANDS,
|
||||
} from "../infra/node-commands.js";
|
||||
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
|
||||
import {
|
||||
coerceNodeInvokePayload,
|
||||
type SkillBinsProvider,
|
||||
buildNodeInvokeResultParams,
|
||||
handleInvoke,
|
||||
} from "./invoke.js";
|
||||
import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js";
|
||||
import {
|
||||
ensureNodeHostPluginRegistry,
|
||||
listRegisteredNodeHostCapsAndCommands,
|
||||
} from "./plugin-node-host.js";
|
||||
import { scanNodeHostedSkills } from "./skills.js";
|
||||
import { coerceNodeInvokePayload, buildNodeInvokeResultParams } from "./invoke.js";
|
||||
import { prepareNodeHostRuntime, type NodeHostInventory } from "./runtime.js";
|
||||
|
||||
export { buildNodeInvokeResultParams };
|
||||
export { buildNodeEventParams } from "./invoke.js";
|
||||
@@ -53,8 +33,6 @@ type NodeHostRunOptions = {
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
|
||||
export function resolveNodeHostGatewayPlatform(platform: NodeJS.Platform): string {
|
||||
switch (platform) {
|
||||
case "darwin":
|
||||
@@ -168,92 +146,6 @@ async function publishNodeSkills(client: GatewayClient, skills: unknown[]): Prom
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExecutablePathFromEnv(bin: string, pathEnv: string): string | null {
|
||||
if (bin.includes("/") || bin.includes("\\")) {
|
||||
return null;
|
||||
}
|
||||
return resolveExecutableFromPathEnv(bin, pathEnv) ?? null;
|
||||
}
|
||||
|
||||
function resolveExecutableTrustPathFromEnv(bin: string, pathEnv: string): string | null {
|
||||
const resolvedPath = resolveExecutablePathFromEnv(bin, pathEnv);
|
||||
if (!resolvedPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return fs.realpathSync(resolvedPath);
|
||||
} catch {
|
||||
return resolvedPath;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSkillBinTrustEntries(bins: string[], pathEnv: string): SkillBinTrustEntry[] {
|
||||
const trustEntries: SkillBinTrustEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const bin of bins) {
|
||||
const name = bin.trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const resolvedPath = resolveExecutableTrustPathFromEnv(name, pathEnv);
|
||||
if (!resolvedPath) {
|
||||
continue;
|
||||
}
|
||||
const key = `${name}\u0000${resolvedPath}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
trustEntries.push({ name, resolvedPath });
|
||||
}
|
||||
return trustEntries.toSorted(
|
||||
(left, right) =>
|
||||
left.name.localeCompare(right.name) || left.resolvedPath.localeCompare(right.resolvedPath),
|
||||
);
|
||||
}
|
||||
|
||||
class SkillBinsCache implements SkillBinsProvider {
|
||||
private bins: SkillBinTrustEntry[] = [];
|
||||
private lastRefresh = 0;
|
||||
private readonly ttlMs = 90_000;
|
||||
private readonly fetch: () => Promise<string[]>;
|
||||
private readonly pathEnv: string;
|
||||
|
||||
constructor(fetch: () => Promise<string[]>, pathEnv: string) {
|
||||
this.fetch = fetch;
|
||||
this.pathEnv = pathEnv;
|
||||
}
|
||||
|
||||
async current(force = false): Promise<SkillBinTrustEntry[]> {
|
||||
if (force || Date.now() - this.lastRefresh > this.ttlMs) {
|
||||
await this.refresh();
|
||||
}
|
||||
return this.bins;
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
try {
|
||||
const bins = await this.fetch();
|
||||
this.bins = resolveSkillBinTrustEntries(bins, this.pathEnv);
|
||||
this.lastRefresh = Date.now();
|
||||
} catch {
|
||||
if (!this.lastRefresh) {
|
||||
this.bins = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNodePathEnv(): string {
|
||||
ensureOpenClawCliOnPath({ pathEnv: process.env.PATH ?? "" });
|
||||
const current = process.env.PATH ?? "";
|
||||
if (current.trim()) {
|
||||
return current;
|
||||
}
|
||||
process.env.PATH = DEFAULT_NODE_PATH;
|
||||
return DEFAULT_NODE_PATH;
|
||||
}
|
||||
|
||||
export async function resolveNodeHostGatewayCredentials(params: {
|
||||
config: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -306,8 +198,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
await saveNodeHostConfig(config);
|
||||
|
||||
const cfg = getRuntimeConfig();
|
||||
await ensureNodeHostPluginRegistry({ config: cfg, env: process.env });
|
||||
const pluginNodeHost = listRegisteredNodeHostCapsAndCommands({ config: cfg, env: process.env });
|
||||
const preparedRuntime = await prepareNodeHostRuntime({ config: cfg, env: process.env });
|
||||
const { token, password } = await resolveNodeHostGatewayCredentials({
|
||||
config: cfg,
|
||||
env: process.env,
|
||||
@@ -322,33 +213,17 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
: `/${gateway.contextPath}`
|
||||
: "";
|
||||
const url = `${scheme}://${host}:${port}${contextPath}`;
|
||||
const pathEnv = ensureNodePathEnv();
|
||||
const mcpServers = cfg.nodeHost?.mcp?.servers;
|
||||
const nodeSkills = cfg.nodeHost?.skills?.enabled === false ? null : scanNodeHostedSkills();
|
||||
const mcpStartupAbort = new AbortController();
|
||||
const mcpRuntime: {
|
||||
manager?: NodeHostMcpManager;
|
||||
startup?: Promise<NodeHostMcpManager>;
|
||||
} = {};
|
||||
let inventory: NodeHostInventory = preparedRuntime.initialInventory;
|
||||
let gatewayHelloReceived = false;
|
||||
|
||||
const publishNodeToolsWhenReady = () => {
|
||||
if (!gatewayHelloReceived || !mcpRuntime.manager) {
|
||||
const publishInventory = () => {
|
||||
if (!gatewayHelloReceived) {
|
||||
return;
|
||||
}
|
||||
const nodePluginTools = [
|
||||
...pluginNodeHost.nodePluginTools,
|
||||
...mcpRuntime.manager.descriptors,
|
||||
].toSorted(
|
||||
(left, right) =>
|
||||
left.pluginId.localeCompare(right.pluginId) || left.name.localeCompare(right.name),
|
||||
);
|
||||
void publishNodePluginTools(client, nodePluginTools);
|
||||
};
|
||||
const closeMcpRuntime = async () => {
|
||||
mcpStartupAbort.abort();
|
||||
const manager = mcpRuntime.manager ?? (await mcpRuntime.startup?.catch(() => undefined));
|
||||
await manager?.close();
|
||||
if (inventory.skills) {
|
||||
void publishNodeSkills(client, inventory.skills);
|
||||
}
|
||||
void publishNodePluginTools(client, inventory.pluginTools);
|
||||
};
|
||||
|
||||
const client = new GatewayClient({
|
||||
@@ -367,15 +242,9 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
scopes: [],
|
||||
// Pair the built-in MCP command family up front. Server inventory is
|
||||
// restart-scoped availability, not a capability upgrade requiring re-pairing.
|
||||
caps: ["system", "mcp", ...pluginNodeHost.caps],
|
||||
commands: [
|
||||
...NODE_SYSTEM_RUN_COMMANDS,
|
||||
...NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
...pluginNodeHost.commands,
|
||||
],
|
||||
pathEnv,
|
||||
caps: preparedRuntime.manifest.caps,
|
||||
commands: preparedRuntime.manifest.commands,
|
||||
pathEnv: preparedRuntime.manifest.pathEnv,
|
||||
permissions: undefined,
|
||||
deviceIdentity: loadOrCreateDeviceIdentity(),
|
||||
tlsFingerprint: gateway.tlsFingerprint,
|
||||
@@ -387,20 +256,12 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
void handleInvoke(payload, client, skillBins, mcpRuntime.manager);
|
||||
void activeRuntime.invoke(payload);
|
||||
},
|
||||
onHelloOk: () => {
|
||||
writeStderrLine(`node host gateway connected: ${url}`);
|
||||
gatewayHelloReceived = true;
|
||||
if (nodeSkills) {
|
||||
void publishNodeSkills(client, nodeSkills);
|
||||
}
|
||||
if (mcpRuntime.manager) {
|
||||
publishNodeToolsWhenReady();
|
||||
} else {
|
||||
// Do not make existing plugin tools wait for optional MCP discovery.
|
||||
void publishNodePluginTools(client, pluginNodeHost.nodePluginTools);
|
||||
}
|
||||
publishInventory();
|
||||
},
|
||||
onConnectError: (err) => {
|
||||
// keep retrying (handled by GatewayClient)
|
||||
@@ -412,7 +273,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
client.stop();
|
||||
// Terminal auth/version pauses restart under a supervisor; close MCP
|
||||
// subprocesses first so restart loops cannot orphan server processes.
|
||||
void closeMcpRuntime().finally(() => process.exit(code));
|
||||
void activeRuntime.close().finally(() => process.exit(code));
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -420,12 +281,13 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
writeStderrLine(`node host gateway closed (${code}): ${reason}`);
|
||||
},
|
||||
});
|
||||
|
||||
const skillBins = new SkillBinsCache(async () => {
|
||||
const res = await client.request<{ bins: Array<unknown> }>("skills.bins", {});
|
||||
const bins = Array.isArray(res?.bins) ? res.bins.map((bin) => String(bin)) : [];
|
||||
return bins;
|
||||
}, pathEnv);
|
||||
const activeRuntime = preparedRuntime.start({
|
||||
client,
|
||||
onInventoryChanged: (nextInventory) => {
|
||||
inventory = nextInventory;
|
||||
publishInventory();
|
||||
},
|
||||
});
|
||||
|
||||
let stopping = false;
|
||||
let resolveStopped: (() => void) | undefined;
|
||||
@@ -442,7 +304,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
const stopClientAndMcp = async () => {
|
||||
client.stop();
|
||||
try {
|
||||
await closeMcpRuntime();
|
||||
await activeRuntime.close();
|
||||
} finally {
|
||||
clearInterval(lifetimeInterval);
|
||||
}
|
||||
@@ -468,14 +330,6 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
const readinessPromise = startGatewayClientWhenEventLoopReady(client, {
|
||||
clientOptions: { preauthHandshakeTimeoutMs: cfg.gateway?.handshakeTimeoutMs },
|
||||
});
|
||||
// Gateway startup begins first; optional MCP discovery must not delay core node availability.
|
||||
mcpRuntime.startup = startNodeHostMcpManager(mcpServers, { signal: mcpStartupAbort.signal }).then(
|
||||
(manager) => {
|
||||
mcpRuntime.manager = manager;
|
||||
publishNodeToolsWhenReady();
|
||||
return manager;
|
||||
},
|
||||
);
|
||||
let readiness;
|
||||
try {
|
||||
readiness = await readinessPromise;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/** Transport-independent CLI node-host runtime shared by Gateway and app workers. */
|
||||
import fs from "node:fs";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
|
||||
import { resolveExecutableFromPathEnv } from "../infra/executable-path.js";
|
||||
import {
|
||||
NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
NODE_SYSTEM_RUN_COMMANDS,
|
||||
} from "../infra/node-commands.js";
|
||||
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
import { handleInvoke, type NodeInvokeRequestPayload, type SkillBinsProvider } from "./invoke.js";
|
||||
import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js";
|
||||
import {
|
||||
ensureNodeHostPluginRegistry,
|
||||
listRegisteredNodeHostCapsAndCommands,
|
||||
} from "./plugin-node-host.js";
|
||||
import { scanNodeHostedSkills } from "./skills.js";
|
||||
|
||||
const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
|
||||
type NodeHostManifest = {
|
||||
caps: string[];
|
||||
commands: string[];
|
||||
pathEnv: string;
|
||||
};
|
||||
|
||||
export type NodeHostInventory = {
|
||||
skills: unknown[] | null;
|
||||
pluginTools: unknown[];
|
||||
};
|
||||
|
||||
type PreparedNodeHostRuntime = {
|
||||
manifest: NodeHostManifest;
|
||||
initialInventory: NodeHostInventory;
|
||||
start(params: {
|
||||
client: NodeHostClient;
|
||||
onInventoryChanged?: (inventory: NodeHostInventory) => void;
|
||||
}): ActiveNodeHostRuntime;
|
||||
};
|
||||
|
||||
type ActiveNodeHostRuntime = {
|
||||
invoke(frame: NodeInvokeRequestPayload): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
function resolveExecutablePathFromEnv(bin: string, pathEnv: string): string | null {
|
||||
if (bin.includes("/") || bin.includes("\\")) {
|
||||
return null;
|
||||
}
|
||||
return resolveExecutableFromPathEnv(bin, pathEnv) ?? null;
|
||||
}
|
||||
|
||||
function resolveExecutableTrustPathFromEnv(bin: string, pathEnv: string): string | null {
|
||||
const resolvedPath = resolveExecutablePathFromEnv(bin, pathEnv);
|
||||
if (!resolvedPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return fs.realpathSync(resolvedPath);
|
||||
} catch {
|
||||
return resolvedPath;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSkillBinTrustEntries(bins: string[], pathEnv: string): SkillBinTrustEntry[] {
|
||||
const trustEntries: SkillBinTrustEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of bins) {
|
||||
const name = raw.trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const resolvedPath = resolveExecutableTrustPathFromEnv(name, pathEnv);
|
||||
if (!resolvedPath) {
|
||||
continue;
|
||||
}
|
||||
const key = `${name}\u0000${resolvedPath}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
trustEntries.push({ name, resolvedPath });
|
||||
}
|
||||
return trustEntries.toSorted(
|
||||
(left, right) =>
|
||||
left.name.localeCompare(right.name) || left.resolvedPath.localeCompare(right.resolvedPath),
|
||||
);
|
||||
}
|
||||
|
||||
class SkillBinsCache implements SkillBinsProvider {
|
||||
private bins: SkillBinTrustEntry[] = [];
|
||||
private lastRefresh = 0;
|
||||
private readonly ttlMs = 90_000;
|
||||
|
||||
constructor(
|
||||
private readonly client: NodeHostClient,
|
||||
private readonly pathEnv: string,
|
||||
) {}
|
||||
|
||||
async current(force = false): Promise<SkillBinTrustEntry[]> {
|
||||
if (force || Date.now() - this.lastRefresh > this.ttlMs) {
|
||||
await this.refresh();
|
||||
}
|
||||
return this.bins;
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
try {
|
||||
const res = await this.client.request<{ bins: Array<unknown> }>("skills.bins", {});
|
||||
const bins = Array.isArray(res?.bins) ? res.bins.map((bin) => String(bin)) : [];
|
||||
this.bins = resolveSkillBinTrustEntries(bins, this.pathEnv);
|
||||
this.lastRefresh = Date.now();
|
||||
} catch {
|
||||
if (!this.lastRefresh) {
|
||||
this.bins = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNodePathEnv(): string {
|
||||
ensureOpenClawCliOnPath({ pathEnv: process.env.PATH ?? "" });
|
||||
const current = process.env.PATH ?? "";
|
||||
if (current.trim()) {
|
||||
return current;
|
||||
}
|
||||
process.env.PATH = DEFAULT_NODE_PATH;
|
||||
return DEFAULT_NODE_PATH;
|
||||
}
|
||||
|
||||
function createInventory(params: {
|
||||
skills: unknown[] | null;
|
||||
pluginTools: unknown[];
|
||||
mcpManager?: NodeHostMcpManager;
|
||||
}): NodeHostInventory {
|
||||
const pluginTools = [...params.pluginTools, ...(params.mcpManager?.descriptors ?? [])].toSorted(
|
||||
(left, right) => {
|
||||
const a = left as { pluginId?: string; name?: string };
|
||||
const b = right as { pluginId?: string; name?: string };
|
||||
return (
|
||||
(a.pluginId ?? "").localeCompare(b.pluginId ?? "") ||
|
||||
(a.name ?? "").localeCompare(b.name ?? "")
|
||||
);
|
||||
},
|
||||
);
|
||||
return { skills: params.skills, pluginTools };
|
||||
}
|
||||
|
||||
export async function prepareNodeHostRuntime(params?: {
|
||||
config?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<PreparedNodeHostRuntime> {
|
||||
const config = params?.config ?? getRuntimeConfig();
|
||||
const env = params?.env ?? process.env;
|
||||
await ensureNodeHostPluginRegistry({ config, env });
|
||||
const pluginNodeHost = listRegisteredNodeHostCapsAndCommands({ config, env });
|
||||
const pathEnv = ensureNodePathEnv();
|
||||
const skills = config.nodeHost?.skills?.enabled === false ? null : scanNodeHostedSkills();
|
||||
const manifest: NodeHostManifest = {
|
||||
caps: [...new Set(["system", "mcp", ...pluginNodeHost.caps])].toSorted(),
|
||||
commands: [
|
||||
...new Set([
|
||||
...NODE_SYSTEM_RUN_COMMANDS,
|
||||
...NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
...pluginNodeHost.commands,
|
||||
]),
|
||||
].toSorted(),
|
||||
pathEnv,
|
||||
};
|
||||
const initialInventory = createInventory({
|
||||
skills,
|
||||
pluginTools: pluginNodeHost.nodePluginTools,
|
||||
});
|
||||
|
||||
return {
|
||||
manifest,
|
||||
initialInventory,
|
||||
start({ client, onInventoryChanged }) {
|
||||
const mcpAbort = new AbortController();
|
||||
const skillBins = new SkillBinsCache(client, pathEnv);
|
||||
let manager: NodeHostMcpManager | undefined;
|
||||
const startup = startNodeHostMcpManager(config.nodeHost?.mcp?.servers, {
|
||||
signal: mcpAbort.signal,
|
||||
}).then((resolved) => {
|
||||
manager = resolved;
|
||||
onInventoryChanged?.(
|
||||
createInventory({
|
||||
skills,
|
||||
pluginTools: pluginNodeHost.nodePluginTools,
|
||||
mcpManager: manager,
|
||||
}),
|
||||
);
|
||||
return resolved;
|
||||
});
|
||||
return {
|
||||
async invoke(frame) {
|
||||
await handleInvoke(frame, client, skillBins, manager);
|
||||
},
|
||||
async close() {
|
||||
mcpAbort.abort();
|
||||
const resolved = manager ?? (await startup.catch(() => undefined));
|
||||
await resolved?.close();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { GatewayClientRequestOptions } from "../gateway/client.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
|
||||
export type NodeHostWorkerGatewayResponse =
|
||||
| { type: "gateway-response"; id: string; ok: true; result: unknown }
|
||||
| { type: "gateway-response"; id: string; ok: false; error: string };
|
||||
|
||||
type PendingGatewayRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
export class NodeHostWorkerBridgeClient implements NodeHostClient {
|
||||
private nextRequestId = 1;
|
||||
private readonly pending = new Map<string, PendingGatewayRequest>();
|
||||
|
||||
constructor(private readonly writeMessage: (message: unknown) => void) {}
|
||||
|
||||
async request<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
opts?: GatewayClientRequestOptions,
|
||||
): Promise<T> {
|
||||
if (method === "node.invoke.result") {
|
||||
this.writeMessage({ type: "invoke-result", result: params ?? {} });
|
||||
return {} as T;
|
||||
}
|
||||
if (method === "node.event") {
|
||||
this.writeMessage({ type: "node-event", event: params ?? {} });
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
const id = `gateway-${this.nextRequestId++}`;
|
||||
const timeoutMs = Math.max(1, opts?.timeoutMs ?? 15_000);
|
||||
const response = new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Gateway request timed out: ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
});
|
||||
this.writeMessage({ type: "gateway-request", id, method, params: params ?? {}, timeoutMs });
|
||||
return (await response) as T;
|
||||
}
|
||||
|
||||
handleResponse(message: NodeHostWorkerGatewayResponse): boolean {
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) {
|
||||
return false;
|
||||
}
|
||||
this.pending.delete(message.id);
|
||||
clearTimeout(pending.timer);
|
||||
if (message.ok) {
|
||||
pending.resolve(message.result);
|
||||
} else {
|
||||
pending.reject(new Error(message.error));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error("node-host worker stopped"));
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopNodeHostWorkerFromSignal(
|
||||
input: { close(): void },
|
||||
stop: (exitCode: number) => Promise<void>,
|
||||
exitCode: number,
|
||||
): Promise<void> {
|
||||
const stopped = stop(exitCode);
|
||||
input.close();
|
||||
await stopped;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NodeHostWorkerBridgeClient, stopNodeHostWorkerFromSignal } from "./worker-support.js";
|
||||
|
||||
describe("NodeHostWorkerBridgeClient", () => {
|
||||
it("forwards invoke results and events without creating gateway request waits", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const client = new NodeHostWorkerBridgeClient((message) => messages.push(message));
|
||||
|
||||
await client.request("node.invoke.result", { id: "invoke-1", ok: true });
|
||||
await client.request("node.event", { event: "exec.started", payloadJSON: "{}" });
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ type: "invoke-result", result: { id: "invoke-1", ok: true } },
|
||||
{ type: "node-event", event: { event: "exec.started", payloadJSON: "{}" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("tunnels runtime gateway requests and resolves their matching response", async () => {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const client = new NodeHostWorkerBridgeClient((message) => {
|
||||
messages.push(message as Record<string, unknown>);
|
||||
});
|
||||
|
||||
const response = client.request<{ bins: string[] }>("skills.bins", {}, { timeoutMs: 1_000 });
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "gateway-request",
|
||||
id: "gateway-1",
|
||||
method: "skills.bins",
|
||||
params: {},
|
||||
timeoutMs: 1_000,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
client.handleResponse({
|
||||
type: "gateway-response",
|
||||
id: "gateway-1",
|
||||
ok: true,
|
||||
result: { bins: ["rg"] },
|
||||
}),
|
||||
).toBe(true);
|
||||
await expect(response).resolves.toEqual({ bins: ["rg"] });
|
||||
});
|
||||
|
||||
it("fails pending gateway requests when the app worker stops", async () => {
|
||||
const client = new NodeHostWorkerBridgeClient(() => {});
|
||||
const response = client.request("skills.bins", {}, { timeoutMs: 1_000 });
|
||||
|
||||
client.close();
|
||||
|
||||
await expect(response).rejects.toThrow("node-host worker stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopNodeHostWorkerFromSignal", () => {
|
||||
it("preserves the signal exit code when closing stdin emits EOF", async () => {
|
||||
const calls: string[] = [];
|
||||
let stopping = false;
|
||||
const stop = async (exitCode: number) => {
|
||||
if (stopping) {
|
||||
return;
|
||||
}
|
||||
stopping = true;
|
||||
calls.push(`stop:${exitCode}`);
|
||||
};
|
||||
|
||||
await stopNodeHostWorkerFromSignal(
|
||||
{
|
||||
close: () => {
|
||||
calls.push("close");
|
||||
void stop(0);
|
||||
},
|
||||
},
|
||||
stop,
|
||||
143,
|
||||
);
|
||||
|
||||
expect(calls).toEqual(["stop:143", "close"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/** Private JSONL worker exposing the CLI node-host runtime to the macOS app. */
|
||||
import { createInterface } from "node:readline";
|
||||
import { VERSION } from "../version.js";
|
||||
import type { NodeInvokeRequestPayload } from "./invoke.js";
|
||||
import { prepareNodeHostRuntime, type NodeHostInventory } from "./runtime.js";
|
||||
import {
|
||||
NodeHostWorkerBridgeClient,
|
||||
type NodeHostWorkerGatewayResponse,
|
||||
stopNodeHostWorkerFromSignal,
|
||||
} from "./worker-support.js";
|
||||
|
||||
type WorkerInput =
|
||||
| { type: "invoke"; request: NodeInvokeRequestPayload }
|
||||
| NodeHostWorkerGatewayResponse
|
||||
| { type: "stop" };
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function writeMessage(message: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
function parseInput(line: string): WorkerInput | null {
|
||||
try {
|
||||
const parsed = asRecord(JSON.parse(line));
|
||||
const type = typeof parsed?.type === "string" ? parsed.type : "";
|
||||
if (type === "invoke") {
|
||||
const request = asRecord(parsed?.request);
|
||||
if (
|
||||
request &&
|
||||
typeof request.id === "string" &&
|
||||
typeof request.nodeId === "string" &&
|
||||
typeof request.command === "string"
|
||||
) {
|
||||
return { type, request: request as NodeInvokeRequestPayload };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (type === "gateway-response") {
|
||||
const id = typeof parsed?.id === "string" ? parsed.id : "";
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return parsed?.ok === true
|
||||
? { type, id, ok: true, result: parsed.result }
|
||||
: {
|
||||
type,
|
||||
id,
|
||||
ok: false,
|
||||
error: typeof parsed?.error === "string" ? parsed.error : "Gateway request failed",
|
||||
};
|
||||
}
|
||||
return type === "stop" ? { type } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function emitInventory(inventory: NodeHostInventory): void {
|
||||
writeMessage({ type: "inventory", inventory });
|
||||
}
|
||||
|
||||
export async function runNodeHostWorker(): Promise<void> {
|
||||
const prepared = await prepareNodeHostRuntime();
|
||||
const client = new NodeHostWorkerBridgeClient(writeMessage);
|
||||
let stopping = false;
|
||||
let resolveStopped: (() => void) | undefined;
|
||||
const stopped = new Promise<void>((resolve) => {
|
||||
resolveStopped = resolve;
|
||||
});
|
||||
|
||||
const stop = async (exitCode: number) => {
|
||||
if (stopping) {
|
||||
return;
|
||||
}
|
||||
stopping = true;
|
||||
try {
|
||||
client.close();
|
||||
await runtime.close();
|
||||
process.exitCode = exitCode;
|
||||
} finally {
|
||||
resolveStopped?.();
|
||||
}
|
||||
};
|
||||
|
||||
const runtime = prepared.start({ client, onInventoryChanged: emitInventory });
|
||||
writeMessage({
|
||||
type: "ready",
|
||||
version: VERSION,
|
||||
manifest: prepared.manifest,
|
||||
inventory: prepared.initialInventory,
|
||||
});
|
||||
|
||||
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
input.on("line", (line) => {
|
||||
const message = parseInput(line);
|
||||
if (!message) {
|
||||
writeMessage({ type: "protocol-error", error: "invalid worker request" });
|
||||
return;
|
||||
}
|
||||
if (message.type === "gateway-response") {
|
||||
client.handleResponse(message);
|
||||
return;
|
||||
}
|
||||
if (message.type === "stop") {
|
||||
input.close();
|
||||
void stop(0);
|
||||
return;
|
||||
}
|
||||
void runtime.invoke(message.request);
|
||||
});
|
||||
input.on("close", () => void stop(0));
|
||||
process.once("SIGINT", () => void stopNodeHostWorkerFromSignal(input, stop, 130));
|
||||
process.once("SIGTERM", () => void stopNodeHostWorkerFromSignal(input, stop, 143));
|
||||
await stopped;
|
||||
}
|
||||
Reference in New Issue
Block a user