diff --git a/CHANGELOG.md b/CHANGELOG.md index b146cf5b3ab3..9f1f27c4b3ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- **macOS app profiles:** isolate named app instances across state, preferences, Keychain, Gateway services, and duplicate-instance ownership while keeping host-global login and node services untouched. - **Developer workflow:** remove the obsolete scoped-commit helper and use standard Git commands in isolated worktrees. - **Plugin uninstall cleanup:** remove exact recorded install paths from `plugins.load.paths` for marketplace, npm, and other managed installs while preserving parent, child, prefix, and unrelated paths. - Fixed Crabbox hydration on unprivileged cloud sandboxes by falling back to a user-writable pnpm store when the shared `/var/cache/crabbox` cache is unavailable, preserving the hardlink import mode after hydration, and making Docker an explicit routed capability instead of an implicit install requirement. diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 9b7f76307894..03975880ca18 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -29834,8 +29834,16 @@ "id": "native.apple.5e0de63984ca40c9" }, { - "kind": "ui-call", - "line": 73, + "kind": "conditional-branch", + "line": 74, + "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", + "source": "App updates are unavailable while a profile is active.", + "surface": "apple", + "id": "native.apple.2318ba3118a022eb" + }, + { + "kind": "conditional-branch", + "line": 75, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Updates unavailable in this build.", "surface": "apple", @@ -29843,7 +29851,7 @@ }, { "kind": "ui-call", - "line": 79, + "line": 81, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "© 2026 OpenClaw Foundation — MIT License.", "surface": "apple", @@ -29851,7 +29859,7 @@ }, { "kind": "ui-call", - "line": 158, + "line": 160, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Copy full commit hash", "surface": "apple", @@ -29859,7 +29867,7 @@ }, { "kind": "ui-call", - "line": 162, + "line": 164, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Copy build info", "surface": "apple", @@ -29867,7 +29875,7 @@ }, { "kind": "ui-call", - "line": 168, + "line": 170, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Copy Commit", "surface": "apple", @@ -29875,7 +29883,7 @@ }, { "kind": "ui-call", - "line": 172, + "line": 174, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Copy Build Info", "surface": "apple", @@ -29883,7 +29891,7 @@ }, { "kind": "ui-call", - "line": 188, + "line": 190, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Unavailable", "surface": "apple", @@ -29891,7 +29899,7 @@ }, { "kind": "ui-call", - "line": 204, + "line": 206, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Version \\(version), commit \\(commit), built \\(built), timestamp \\(timestamp)", "surface": "apple", @@ -29899,7 +29907,7 @@ }, { "kind": "ui-call", - "line": 207, + "line": 209, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Version \\(version), commit \\(commit), build date unavailable", "surface": "apple", @@ -29907,7 +29915,7 @@ }, { "kind": "ui-call", - "line": 210, + "line": 212, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Version \\(version), commit unavailable, built \\(built), timestamp \\(timestamp)", "surface": "apple", @@ -29915,7 +29923,7 @@ }, { "kind": "ui-call", - "line": 212, + "line": 214, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Version \\(version), commit unavailable, build date unavailable", "surface": "apple", @@ -29947,7 +29955,7 @@ }, { "kind": "conditional-branch", - "line": 1014, + "line": 1017, "path": "apps/macos/Sources/OpenClaw/AppState.swift", "source": "\\(user)@\\(host)", "surface": "apple", @@ -29955,7 +29963,7 @@ }, { "kind": "conditional-branch", - "line": 1014, + "line": 1017, "path": "apps/macos/Sources/OpenClaw/AppState.swift", "source": "\\(user)@\\(host):\\(port)", "surface": "apple", @@ -30243,7 +30251,7 @@ }, { "kind": "ui-localized-call", - "line": 493, + "line": 495, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Repairing the OpenClaw Gateway update…", "surface": "apple", @@ -30251,7 +30259,7 @@ }, { "kind": "ui-localized-call", - "line": 494, + "line": 496, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Updating the OpenClaw Gateway to \\(targetVersion)…", "surface": "apple", @@ -30259,7 +30267,7 @@ }, { "kind": "ui-localized-call", - "line": 515, + "line": 517, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Gateway update needs attention.", "surface": "apple", @@ -30267,7 +30275,7 @@ }, { "kind": "ui-localized-call", - "line": 517, + "line": 519, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Gateway update failed.", "surface": "apple", @@ -30275,7 +30283,7 @@ }, { "kind": "ui-localized-call", - "line": 532, + "line": 534, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Gateway update finished, but verification failed.", "surface": "apple", @@ -30283,7 +30291,7 @@ }, { "kind": "ui-localized-call", - "line": 539, + "line": 541, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "OpenClaw Gateway \\(installedVersion) is installed.", "surface": "apple", @@ -32379,7 +32387,15 @@ }, { "kind": "ui-call", - "line": 517, + "line": 515, + "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", + "source": "Login-agent restart is unavailable under a profile.", + "surface": "apple", + "id": "native.apple.974d1478dd99ffb0" + }, + { + "kind": "ui-call", + "line": 522, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Restart OpenClaw", "surface": "apple", @@ -32387,7 +32403,7 @@ }, { "kind": "ui-call", - "line": 524, + "line": 530, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Restart app", "surface": "apple", @@ -32395,7 +32411,7 @@ }, { "kind": "ui-call", - "line": 525, + "line": 531, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Restart onboarding", "surface": "apple", @@ -32403,7 +32419,7 @@ }, { "kind": "ui-call", - "line": 526, + "line": 532, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Reveal app in Finder", "surface": "apple", @@ -32411,7 +32427,7 @@ }, { "kind": "ui-call", - "line": 537, + "line": 543, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Enable/disable Canvas in General settings.", "surface": "apple", @@ -32419,7 +32435,7 @@ }, { "kind": "ui-call", - "line": 542, + "line": 548, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Session", "surface": "apple", @@ -32427,7 +32443,7 @@ }, { "kind": "ui-call", - "line": 546, + "line": 552, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Show panel", "surface": "apple", @@ -32435,7 +32451,7 @@ }, { "kind": "ui-call", - "line": 550, + "line": 556, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Hide panel", "surface": "apple", @@ -32443,7 +32459,7 @@ }, { "kind": "ui-call", - "line": 556, + "line": 562, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Write sample page", "surface": "apple", @@ -32451,7 +32467,7 @@ }, { "kind": "ui-call", - "line": 564, + "line": 570, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Eval JS", "surface": "apple", @@ -32459,7 +32475,7 @@ }, { "kind": "ui-call", - "line": 568, + "line": 574, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Eval", "surface": "apple", @@ -32467,7 +32483,7 @@ }, { "kind": "ui-call", - "line": 572, + "line": 578, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Snapshot", "surface": "apple", @@ -32475,7 +32491,7 @@ }, { "kind": "ui-call", - "line": 586, + "line": 592, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "eval → \\(canvasEvalResult)", "surface": "apple", @@ -32483,7 +32499,7 @@ }, { "kind": "ui-call", - "line": 595, + "line": 601, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "snapshot → \\(canvasSnapshotPath)", "surface": "apple", @@ -32491,7 +32507,7 @@ }, { "kind": "ui-call", - "line": 601, + "line": 607, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Reveal", "surface": "apple", @@ -32499,7 +32515,7 @@ }, { "kind": "ui-call", - "line": 614, + "line": 620, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Tip: the session directory is returned by “Show panel”.", "surface": "apple", @@ -32507,7 +32523,7 @@ }, { "kind": "ui-call", - "line": 626, + "line": 632, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Icon override", "surface": "apple", @@ -32515,7 +32531,7 @@ }, { "kind": "ui-call", - "line": 636, + "line": 642, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Chat", "surface": "apple", @@ -32523,7 +32539,7 @@ }, { "kind": "ui-call", - "line": 637, + "line": 643, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Native SwiftUI", "surface": "apple", @@ -32531,7 +32547,7 @@ }, { "kind": "conditional-branch", - "line": 950, + "line": 956, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Unknown", "surface": "apple", @@ -32539,7 +32555,7 @@ }, { "kind": "conditional-branch", - "line": 951, + "line": 957, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Healthy", "surface": "apple", @@ -32547,7 +32563,7 @@ }, { "kind": "conditional-branch", - "line": 952, + "line": 958, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Needs Link", "surface": "apple", @@ -32555,7 +32571,7 @@ }, { "kind": "conditional-branch", - "line": 953, + "line": 959, "path": "apps/macos/Sources/OpenClaw/DebugSettings.swift", "source": "Degraded", "surface": "apple", @@ -32723,7 +32739,7 @@ }, { "kind": "conditional-branch", - "line": 947, + "line": 976, "path": "apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift", "source": "missing:\\(hash)", "surface": "apple", @@ -32865,14 +32881,6 @@ "surface": "apple", "id": "native.apple.c5f245b99f434c79" }, - { - "kind": "ui-localized-call", - "line": 49, - "path": "apps/macos/Sources/OpenClaw/GatewayConnectionPresentation.swift", - "source": "OpenClaw needs attention", - "surface": "apple", - "id": "native.apple.8a9259535246e7cf" - }, { "kind": "ui-localized-call", "line": 50, @@ -32883,7 +32891,15 @@ }, { "kind": "ui-localized-call", - "line": 73, + "line": 74, + "path": "apps/macos/Sources/OpenClaw/GatewayConnectionPresentation.swift", + "source": "OpenClaw needs attention", + "surface": "apple", + "id": "native.apple.8a9259535246e7cf" + }, + { + "kind": "ui-localized-call", + "line": 82, "path": "apps/macos/Sources/OpenClaw/GatewayConnectionPresentation.swift", "source": "OpenClaw paused", "surface": "apple", @@ -32891,7 +32907,7 @@ }, { "kind": "ui-localized-call", - "line": 74, + "line": 83, "path": "apps/macos/Sources/OpenClaw/GatewayConnectionPresentation.swift", "source": "Gateway work is paused; incoming messages will wait.", "surface": "apple", @@ -32899,7 +32915,7 @@ }, { "kind": "ui-localized-call", - "line": 84, + "line": 93, "path": "apps/macos/Sources/OpenClaw/GatewayConnectionPresentation.swift", "source": "Processing messages through the local Gateway on this Mac.", "surface": "apple", @@ -32907,7 +32923,7 @@ }, { "kind": "ui-localized-call", - "line": 90, + "line": 99, "path": "apps/macos/Sources/OpenClaw/GatewayConnectionPresentation.swift", "source": "OpenClaw active", "surface": "apple", @@ -32915,7 +32931,7 @@ }, { "kind": "ui-localized-call", - "line": 91, + "line": 100, "path": "apps/macos/Sources/OpenClaw/GatewayConnectionPresentation.swift", "source": "Ready to run after you choose a Gateway connection.", "surface": "apple", @@ -32963,7 +32979,7 @@ }, { "kind": "conditional-branch", - "line": 217, + "line": 242, "path": "apps/macos/Sources/OpenClaw/GatewayEnvironment.swift", "source": " (local: \\(projectEntrypoint ?? \"unknown\"))", "surface": "apple", @@ -32971,7 +32987,7 @@ }, { "kind": "conditional-branch", - "line": 221, + "line": 246, "path": "apps/macos/Sources/OpenClaw/GatewayEnvironment.swift", "source": "(\\(gatewayLabel))", "surface": "apple", @@ -33003,7 +33019,7 @@ }, { "kind": "conditional-branch", - "line": 598, + "line": 657, "path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift", "source": "not linked", "surface": "apple", @@ -33011,7 +33027,7 @@ }, { "kind": "conditional-branch", - "line": 611, + "line": 670, "path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift", "source": "unknown error", "surface": "apple", @@ -33233,22 +33249,6 @@ "surface": "apple", "id": "native.apple.a861b3a0aafb24cc" }, - { - "kind": "conditional-branch", - "line": 92, - "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", - "source": "Automatically start OpenClaw after you sign in.", - "surface": "apple", - "id": "native.apple.405e74c96498b7c0" - }, - { - "kind": "conditional-branch", - "line": 93, - "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", - "source": "Move OpenClaw to Applications before enabling launch at login.", - "surface": "apple", - "id": "native.apple.8b74758a60f78642" - }, { "kind": "ui-named-argument", "line": 98, @@ -33475,7 +33475,7 @@ }, { "kind": "ui-call", - "line": 248, + "line": 249, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Open Connection Settings", "surface": "apple", @@ -33483,7 +33483,7 @@ }, { "kind": "ui-call", - "line": 255, + "line": 256, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "OpenClaw active", "surface": "apple", @@ -33491,7 +33491,7 @@ }, { "kind": "ui-named-argument", - "line": 279, + "line": 280, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Connection", "surface": "apple", @@ -33499,7 +33499,7 @@ }, { "kind": "ui-named-argument", - "line": 280, + "line": 281, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Choose where the Gateway runs and how this Mac app reaches it.", "surface": "apple", @@ -33507,7 +33507,7 @@ }, { "kind": "ui-call", - "line": 376, + "line": 378, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "\\(Int(ping)) ms", "surface": "apple", @@ -33515,7 +33515,7 @@ }, { "kind": "conditional-branch", - "line": 410, + "line": 413, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Local Gateway", "surface": "apple", @@ -33523,7 +33523,7 @@ }, { "kind": "conditional-branch", - "line": 411, + "line": 414, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Remote Gateway direct", "surface": "apple", @@ -33531,7 +33531,7 @@ }, { "kind": "conditional-branch", - "line": 411, + "line": 414, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Remote Gateway via SSH", "surface": "apple", @@ -33539,7 +33539,7 @@ }, { "kind": "conditional-branch", - "line": 412, + "line": 415, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Gateway not configured", "surface": "apple", @@ -33547,7 +33547,7 @@ }, { "kind": "conditional-branch", - "line": 419, + "line": 423, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "OpenClaw starts and monitors the Gateway on this Mac.", "surface": "apple", @@ -33555,7 +33555,7 @@ }, { "kind": "conditional-branch", - "line": 428, + "line": 432, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Choose local or remote before the app can attach to a Gateway.", "surface": "apple", @@ -33563,7 +33563,7 @@ }, { "kind": "ui-call", - "line": 433, + "line": 441, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Gateway", "surface": "apple", @@ -33571,7 +33571,7 @@ }, { "kind": "ui-named-argument", - "line": 435, + "line": 443, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "OpenClaw runs", "surface": "apple", @@ -33579,7 +33579,7 @@ }, { "kind": "ui-named-argument", - "line": 436, + "line": 444, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Pick whether this app owns a local Gateway or attaches to another host.", "surface": "apple", @@ -33587,7 +33587,7 @@ }, { "kind": "ui-call", - "line": 439, + "line": 447, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Gateway location", "surface": "apple", @@ -33595,7 +33595,7 @@ }, { "kind": "ui-call", - "line": 440, + "line": 448, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Not configured", "surface": "apple", @@ -33603,7 +33603,7 @@ }, { "kind": "ui-call", - "line": 441, + "line": 449, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Local (this Mac)", "surface": "apple", @@ -33611,7 +33611,7 @@ }, { "kind": "ui-call", - "line": 442, + "line": 450, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Remote (another host)", "surface": "apple", @@ -33619,7 +33619,7 @@ }, { "kind": "ui-named-argument", - "line": 451, + "line": 459, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Setup needed", "surface": "apple", @@ -33627,7 +33627,7 @@ }, { "kind": "ui-named-argument-multiline", - "line": 452, + "line": 460, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Local is best for this Mac. Remote is best when the Gateway already runs on a Mac Studio or server.", "surface": "apple", @@ -33635,7 +33635,7 @@ }, { "kind": "ui-call", - "line": 483, + "line": 491, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Remote Access", "surface": "apple", @@ -33643,7 +33643,7 @@ }, { "kind": "ui-call", - "line": 495, + "line": 503, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Discovery & Status", "surface": "apple", @@ -33651,7 +33651,7 @@ }, { "kind": "ui-call", - "line": 511, + "line": 519, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Nearby gateways", "surface": "apple", @@ -33659,7 +33659,7 @@ }, { "kind": "ui-named-argument", - "line": 534, + "line": 542, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Remote test", "surface": "apple", @@ -33667,7 +33667,7 @@ }, { "kind": "ui-named-argument", - "line": 542, + "line": 550, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Control channel", "surface": "apple", @@ -33675,7 +33675,7 @@ }, { "kind": "ui-named-argument", - "line": 571, + "line": 579, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Recommended setup", "surface": "apple", @@ -33683,7 +33683,7 @@ }, { "kind": "conditional-branch", - "line": 573, + "line": 581, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Use Tailscale plus an SSH tunnel for stable private access.", "surface": "apple", @@ -33691,7 +33691,7 @@ }, { "kind": "conditional-branch", - "line": 574, + "line": 582, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Use Tailscale Serve so the gateway has a valid HTTPS certificate.", "surface": "apple", @@ -33699,7 +33699,7 @@ }, { "kind": "ui-call", - "line": 583, + "line": 591, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Advanced", "surface": "apple", @@ -33707,7 +33707,7 @@ }, { "kind": "ui-call", - "line": 586, + "line": 594, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Identity file", "surface": "apple", @@ -33715,7 +33715,7 @@ }, { "kind": "ui-named-argument", - "line": 588, + "line": 596, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "/Users/you/.ssh/id_ed25519", "surface": "apple", @@ -33723,7 +33723,7 @@ }, { "kind": "ui-call", - "line": 590, + "line": 598, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Project root", "surface": "apple", @@ -33731,7 +33731,7 @@ }, { "kind": "ui-named-argument", - "line": 592, + "line": 600, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "/home/you/Projects/openclaw", "surface": "apple", @@ -33739,7 +33739,7 @@ }, { "kind": "ui-call", - "line": 594, + "line": 602, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "CLI path", "surface": "apple", @@ -33747,7 +33747,7 @@ }, { "kind": "ui-named-argument", - "line": 596, + "line": 604, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "/Applications/OpenClaw.app/.../openclaw", "surface": "apple", @@ -33755,7 +33755,7 @@ }, { "kind": "ui-call", - "line": 601, + "line": 609, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "SSH command details", "surface": "apple", @@ -33763,7 +33763,7 @@ }, { "kind": "ui-named-argument", - "line": 621, + "line": 629, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Transport", "surface": "apple", @@ -33771,7 +33771,7 @@ }, { "kind": "ui-named-argument", - "line": 622, + "line": 630, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "SSH keeps the Gateway private; direct is best for HTTPS or Tailscale Serve.", "surface": "apple", @@ -33779,7 +33779,7 @@ }, { "kind": "ui-call", - "line": 625, + "line": 633, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "SSH tunnel", "surface": "apple", @@ -33787,7 +33787,7 @@ }, { "kind": "ui-call", - "line": 626, + "line": 634, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Direct (ws/wss)", "surface": "apple", @@ -33795,7 +33795,7 @@ }, { "kind": "ui-named-argument", - "line": 639, + "line": 647, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "SSH target", "surface": "apple", @@ -33803,7 +33803,7 @@ }, { "kind": "ui-named-argument", - "line": 639, + "line": 647, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "User and host for the remote Gateway machine.", "surface": "apple", @@ -33811,7 +33811,7 @@ }, { "kind": "ui-named-argument", - "line": 656, + "line": 664, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Gateway URL", "surface": "apple", @@ -33819,7 +33819,7 @@ }, { "kind": "ui-named-argument", - "line": 656, + "line": 664, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "The WebSocket URL exposed by the remote Gateway.", "surface": "apple", @@ -33827,7 +33827,7 @@ }, { "kind": "ui-call", - "line": 659, + "line": 667, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "wss://gateway.example.ts.net", "surface": "apple", @@ -33835,7 +33835,7 @@ }, { "kind": "ui-call", - "line": 665, + "line": 673, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Use wss:// for public hosts. ws:// is allowed for localhost, LAN, .local, and Tailnet hosts.", "surface": "apple", @@ -33843,7 +33843,7 @@ }, { "kind": "ui-named-argument", - "line": 676, + "line": 684, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Gateway token", "surface": "apple", @@ -33851,7 +33851,7 @@ }, { "kind": "ui-named-argument", - "line": 677, + "line": 685, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Used when the remote gateway requires token auth.", "surface": "apple", @@ -33859,7 +33859,7 @@ }, { "kind": "ui-call", - "line": 680, + "line": 688, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "remote gateway auth token (gateway.remote.token)", "surface": "apple", @@ -33867,7 +33867,7 @@ }, { "kind": "ui-call-concatenated", - "line": 685, + "line": 693, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.", "surface": "apple", @@ -33875,7 +33875,7 @@ }, { "kind": "ui-call", - "line": 704, + "line": 712, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Test remote", "surface": "apple", @@ -33883,7 +33883,7 @@ }, { "kind": "ui-call", - "line": 722, + "line": 730, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Testing…", "surface": "apple", @@ -33891,7 +33891,7 @@ }, { "kind": "ui-call", - "line": 760, + "line": 768, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Installed: \\(gatewayVersion) · Required: \\(required)", "surface": "apple", @@ -33899,7 +33899,7 @@ }, { "kind": "ui-call", - "line": 764, + "line": 772, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Gateway \\(gatewayVersion) detected", "surface": "apple", @@ -33907,7 +33907,7 @@ }, { "kind": "ui-call", - "line": 770, + "line": 778, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Node \\(node)", "surface": "apple", @@ -33915,7 +33915,7 @@ }, { "kind": "ui-call", - "line": 782, + "line": 790, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Last failure: \\(failure)", "surface": "apple", @@ -33923,7 +33923,7 @@ }, { "kind": "ui-call", - "line": 787, + "line": 795, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Recheck", "surface": "apple", @@ -33931,7 +33931,7 @@ }, { "kind": "ui-call", - "line": 790, + "line": 798, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Gateway auto-starts in local mode via launchd (\\(gatewayLaunchdLabel)).", "surface": "apple", @@ -33939,7 +33939,7 @@ }, { "kind": "ui-call", - "line": 843, + "line": 852, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Retry now", "surface": "apple", @@ -33947,12 +33947,36 @@ }, { "kind": "ui-call", - "line": 848, + "line": 857, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Open logs", "surface": "apple", "id": "native.apple.6393591d17837eb0" }, + { + "kind": "ui-localized-call", + "line": 923, + "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", + "source": "Launch at login is unavailable while an app profile is active.", + "surface": "apple", + "id": "native.apple.39f0173209fd7e74" + }, + { + "kind": "ui-localized-call", + "line": 928, + "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", + "source": "Automatically start OpenClaw after you sign in.", + "surface": "apple", + "id": "native.apple.405e74c96498b7c0" + }, + { + "kind": "ui-localized-call", + "line": 929, + "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", + "source": "Move OpenClaw to Applications before enabling launch at login.", + "surface": "apple", + "id": "native.apple.8b74758a60f78642" + }, { "kind": "conditional-branch", "line": 226, @@ -34195,7 +34219,7 @@ }, { "kind": "ui-call", - "line": 104, + "line": 133, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "New Gateway Window…", "surface": "apple", @@ -34203,7 +34227,7 @@ }, { "kind": "ui-call", - "line": 109, + "line": 138, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "New Thread", "surface": "apple", @@ -34211,7 +34235,7 @@ }, { "kind": "ui-call", - "line": 115, + "line": 144, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Settings...", "surface": "apple", @@ -34219,7 +34243,7 @@ }, { "kind": "ui-call", - "line": 123, + "line": 152, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Back", "surface": "apple", @@ -34227,7 +34251,7 @@ }, { "kind": "ui-call", - "line": 128, + "line": 157, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Forward", "surface": "apple", @@ -34235,7 +34259,7 @@ }, { "kind": "ui-call", - "line": 135, + "line": 164, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Command Palette…", "surface": "apple", @@ -34243,7 +34267,7 @@ }, { "kind": "conditional-branch", - "line": 149, + "line": 178, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "OpenClaw - Voice Wake live meter active", "surface": "apple", @@ -34251,7 +34275,7 @@ }, { "kind": "conditional-branch", - "line": 150, + "line": 179, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "OpenClaw", "surface": "apple", @@ -34259,7 +34283,7 @@ }, { "kind": "conditional-branch", - "line": 407, + "line": 483, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Close Canvas", "surface": "apple", @@ -34267,7 +34291,7 @@ }, { "kind": "conditional-branch", - "line": 407, + "line": 483, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Open Canvas", "surface": "apple", @@ -34275,7 +34299,7 @@ }, { "kind": "ui-named-argument", - "line": 54, + "line": 56, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Pairing approval pending (\\(self.pairingPrompter.pendingCount))", "surface": "apple", @@ -34283,7 +34307,7 @@ }, { "kind": "conditional-branch", - "line": 58, + "line": 60, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": " · \\(repairCount) repair", "surface": "apple", @@ -34291,7 +34315,7 @@ }, { "kind": "ui-named-argument", - "line": 60, + "line": 62, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Device pairing pending (\\(self.devicePairingPrompter.pendingCount))\\(repairSuffix)", "surface": "apple", @@ -34299,7 +34323,7 @@ }, { "kind": "ui-call", - "line": 69, + "line": 71, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Send Heartbeats", "surface": "apple", @@ -34307,7 +34331,7 @@ }, { "kind": "ui-call", - "line": 81, + "line": 83, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Browser Control", "surface": "apple", @@ -34315,7 +34339,7 @@ }, { "kind": "ui-call", - "line": 84, + "line": 86, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Allow Camera", "surface": "apple", @@ -34323,7 +34347,7 @@ }, { "kind": "ui-call", - "line": 93, + "line": 95, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Exec Approvals", "surface": "apple", @@ -34331,7 +34355,7 @@ }, { "kind": "ui-call", - "line": 96, + "line": 98, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Loading Exec Approvals…", "surface": "apple", @@ -34339,7 +34363,7 @@ }, { "kind": "ui-call", - "line": 102, + "line": 104, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Retry Exec Approvals", "surface": "apple", @@ -34347,7 +34371,7 @@ }, { "kind": "ui-call", - "line": 116, + "line": 118, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Allow Canvas", "surface": "apple", @@ -34355,7 +34379,7 @@ }, { "kind": "ui-call", - "line": 124, + "line": 126, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Voice Wake", "surface": "apple", @@ -34363,7 +34387,7 @@ }, { "kind": "ui-call", - "line": 135, + "line": 137, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Open Dashboard", "surface": "apple", @@ -34371,7 +34395,7 @@ }, { "kind": "ui-call", - "line": 140, + "line": 142, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Open Chat", "surface": "apple", @@ -34379,7 +34403,7 @@ }, { "kind": "ui-call", - "line": 146, + "line": 148, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Quick Chat", "surface": "apple", @@ -34387,7 +34411,7 @@ }, { "kind": "conditional-branch", - "line": 155, + "line": 157, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Close Canvas", "surface": "apple", @@ -34395,7 +34419,7 @@ }, { "kind": "conditional-branch", - "line": 155, + "line": 157, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Open Canvas", "surface": "apple", @@ -34403,7 +34427,7 @@ }, { "kind": "conditional-branch", - "line": 162, + "line": 164, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Stop Talk Mode", "surface": "apple", @@ -34411,7 +34435,7 @@ }, { "kind": "conditional-branch", - "line": 162, + "line": 164, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Talk Mode", "surface": "apple", @@ -34419,7 +34443,7 @@ }, { "kind": "ui-call", - "line": 167, + "line": 169, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Settings…", "surface": "apple", @@ -34427,7 +34451,7 @@ }, { "kind": "ui-call", - "line": 170, + "line": 172, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "About OpenClaw", "surface": "apple", @@ -34435,7 +34459,7 @@ }, { "kind": "ui-call", - "line": 172, + "line": 174, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Update ready, restart now?", "surface": "apple", @@ -34443,7 +34467,7 @@ }, { "kind": "ui-call", - "line": 174, + "line": 176, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Quit", "surface": "apple", @@ -34451,7 +34475,7 @@ }, { "kind": "ui-call", - "line": 246, + "line": 248, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Debug", "surface": "apple", @@ -34459,7 +34483,7 @@ }, { "kind": "ui-call", - "line": 250, + "line": 252, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Open Config Folder", "surface": "apple", @@ -34467,7 +34491,7 @@ }, { "kind": "ui-call", - "line": 255, + "line": 257, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Run Health Check Now", "surface": "apple", @@ -34475,7 +34499,7 @@ }, { "kind": "ui-call", - "line": 260, + "line": 262, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Send Test Heartbeat", "surface": "apple", @@ -34483,7 +34507,7 @@ }, { "kind": "ui-call", - "line": 266, + "line": 268, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Show Pairing Panel (Demo)", "surface": "apple", @@ -34491,7 +34515,7 @@ }, { "kind": "ui-named-argument", - "line": 273, + "line": 275, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Remote Tunnel", "surface": "apple", @@ -34499,7 +34523,7 @@ }, { "kind": "ui-call", - "line": 276, + "line": 278, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Reset Remote Tunnel", "surface": "apple", @@ -34507,7 +34531,7 @@ }, { "kind": "conditional-branch", - "line": 284, + "line": 286, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Verbose Logging (Main): On", "surface": "apple", @@ -34515,7 +34539,7 @@ }, { "kind": "conditional-branch", - "line": 285, + "line": 287, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Verbose Logging (Main): Off", "surface": "apple", @@ -34523,7 +34547,7 @@ }, { "kind": "ui-call", - "line": 289, + "line": 291, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Verbosity", "surface": "apple", @@ -34531,7 +34555,7 @@ }, { "kind": "conditional-branch", - "line": 297, + "line": 299, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "File Logging: On", "surface": "apple", @@ -34539,7 +34563,7 @@ }, { "kind": "conditional-branch", - "line": 298, + "line": 300, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "File Logging: Off", "surface": "apple", @@ -34547,7 +34571,7 @@ }, { "kind": "ui-call", - "line": 302, + "line": 304, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "App Logging", "surface": "apple", @@ -34555,7 +34579,7 @@ }, { "kind": "ui-call", - "line": 307, + "line": 309, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Open Session Store", "surface": "apple", @@ -34563,7 +34587,7 @@ }, { "kind": "ui-call", - "line": 313, + "line": 315, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Open Agent Events…", "surface": "apple", @@ -34571,7 +34595,7 @@ }, { "kind": "ui-call", - "line": 318, + "line": 320, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Open Log", "surface": "apple", @@ -34579,7 +34603,7 @@ }, { "kind": "ui-call", - "line": 323, + "line": 325, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Send Debug Voice Text", "surface": "apple", @@ -34587,7 +34611,7 @@ }, { "kind": "ui-call", - "line": 328, + "line": 330, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Send Test Notification", "surface": "apple", @@ -34595,7 +34619,7 @@ }, { "kind": "ui-call", - "line": 335, + "line": 337, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Restart Gateway", "surface": "apple", @@ -34603,7 +34627,7 @@ }, { "kind": "ui-call", - "line": 341, + "line": 343, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Restart Onboarding", "surface": "apple", @@ -34611,7 +34635,7 @@ }, { "kind": "ui-call", - "line": 346, + "line": 348, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Restart App", "surface": "apple", @@ -34619,7 +34643,7 @@ }, { "kind": "conditional-branch", - "line": 404, + "line": 411, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Main", "surface": "apple", @@ -34627,7 +34651,7 @@ }, { "kind": "conditional-branch", - "line": 404, + "line": 411, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Other", "surface": "apple", @@ -34635,7 +34659,7 @@ }, { "kind": "ui-modifier", - "line": 478, + "line": 485, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Show pairing requests", "surface": "apple", @@ -34643,7 +34667,7 @@ }, { "kind": "ui-call", - "line": 503, + "line": 510, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Refreshing microphones…", "surface": "apple", @@ -34651,7 +34675,7 @@ }, { "kind": "ui-call", - "line": 510, + "line": 517, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Microphone", "surface": "apple", @@ -34659,7 +34683,7 @@ }, { "kind": "ui-call", - "line": 535, + "line": 542, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Disconnected (using System default)", "surface": "apple", @@ -34715,7 +34739,7 @@ }, { "kind": "conditional-branch", - "line": 245, + "line": 252, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "Loading devices...", "surface": "apple", @@ -34723,7 +34747,7 @@ }, { "kind": "conditional-branch", - "line": 245, + "line": 252, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "No devices yet", "surface": "apple", @@ -34731,7 +34755,7 @@ }, { "kind": "conditional-branch", - "line": 1003, + "line": 1011, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "No", "surface": "apple", @@ -34739,7 +34763,7 @@ }, { "kind": "conditional-branch", - "line": 1003, + "line": 1011, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "Yes", "surface": "apple", @@ -34747,7 +34771,7 @@ }, { "kind": "ui-named-argument", - "line": 1059, + "line": 1067, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "Update thinking failed", "surface": "apple", @@ -34755,7 +34779,7 @@ }, { "kind": "ui-named-argument", - "line": 1077, + "line": 1085, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "Update verbose failed", "surface": "apple", @@ -34763,7 +34787,7 @@ }, { "kind": "ui-named-argument", - "line": 1097, + "line": 1105, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "Reset session?", "surface": "apple", @@ -34771,7 +34795,7 @@ }, { "kind": "ui-named-argument", - "line": 1098, + "line": 1106, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "Starts a new session id for “\\(key)”.", "surface": "apple", @@ -34779,7 +34803,7 @@ }, { "kind": "ui-named-argument", - "line": 1106, + "line": 1114, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "Reset failed", "surface": "apple", @@ -34787,7 +34811,7 @@ }, { "kind": "ui-named-argument", - "line": 1116, + "line": 1124, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "Compact session log?", "surface": "apple", @@ -34795,7 +34819,7 @@ }, { "kind": "ui-named-argument", - "line": 1117, + "line": 1125, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "Keeps the last 400 lines; archives the old file.", "surface": "apple", @@ -34803,7 +34827,7 @@ }, { "kind": "ui-named-argument", - "line": 1125, + "line": 1133, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "Compact failed", "surface": "apple", @@ -34811,7 +34835,7 @@ }, { "kind": "ui-named-argument", - "line": 1135, + "line": 1143, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "Delete session?", "surface": "apple", @@ -34819,7 +34843,7 @@ }, { "kind": "ui-named-argument", - "line": 1136, + "line": 1144, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "Deletes the “\\(key)” entry and archives its transcript.", "surface": "apple", @@ -34827,7 +34851,7 @@ }, { "kind": "ui-named-argument", - "line": 1144, + "line": 1152, "path": "apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift", "source": "Delete failed", "surface": "apple", @@ -34865,6 +34889,14 @@ "surface": "apple", "id": "native.apple.4443962db00a2def" }, + { + "kind": "conditional-branch", + "line": 76, + "path": "apps/macos/Sources/OpenClaw/NodesStore.swift", + "source": "Persistent Mac node service unavailable under app profile; runtime node remains available.", + "surface": "apple", + "id": "native.apple.5ca13315339f6c15" + }, { "kind": "conditional-branch", "line": 752, @@ -36163,7 +36195,7 @@ }, { "kind": "ui-call", - "line": 1050, + "line": 1053, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Launch at login", "surface": "apple", @@ -36171,7 +36203,7 @@ }, { "kind": "ui-call", - "line": 1077, + "line": 1081, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Skills included", "surface": "apple", @@ -36179,7 +36211,7 @@ }, { "kind": "ui-call", - "line": 1084, + "line": 1088, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Refresh", "surface": "apple", @@ -36187,7 +36219,7 @@ }, { "kind": "ui-call", - "line": 1093, + "line": 1097, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Couldn’t load skills from the Gateway.", "surface": "apple", @@ -36195,7 +36227,7 @@ }, { "kind": "ui-call-concatenated", - "line": 1096, + "line": 1100, "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", @@ -36203,7 +36235,7 @@ }, { "kind": "ui-call", - "line": 1102, + "line": 1106, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Details: \\(error)", "surface": "apple", @@ -36211,7 +36243,7 @@ }, { "kind": "ui-call", - "line": 1108, + "line": 1112, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "No skills reported yet.", "surface": "apple", @@ -36467,7 +36499,7 @@ }, { "kind": "ui-named-argument", - "line": 103, + "line": 104, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Location access", "surface": "apple", @@ -36475,7 +36507,7 @@ }, { "kind": "ui-named-argument", - "line": 104, + "line": 105, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Allow agents to use device location when a tool asks for it.", "surface": "apple", @@ -36483,7 +36515,7 @@ }, { "kind": "ui-call", - "line": 106, + "line": 107, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Location Access", "surface": "apple", @@ -36491,7 +36523,7 @@ }, { "kind": "ui-call", - "line": 107, + "line": 108, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Off", "surface": "apple", @@ -36499,7 +36531,7 @@ }, { "kind": "ui-call", - "line": 108, + "line": 109, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "While Using", "surface": "apple", @@ -36507,7 +36539,7 @@ }, { "kind": "ui-call", - "line": 109, + "line": 110, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Always", "surface": "apple", @@ -36515,7 +36547,7 @@ }, { "kind": "ui-named-argument", - "line": 117, + "line": 118, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Precise location", "surface": "apple", @@ -36523,7 +36555,7 @@ }, { "kind": "ui-named-argument", - "line": 118, + "line": 119, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Always may require System Settings to approve background location.", "surface": "apple", @@ -36531,7 +36563,7 @@ }, { "kind": "ui-call", - "line": 121, + "line": 122, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Precise Location", "surface": "apple", @@ -36539,7 +36571,7 @@ }, { "kind": "ui-named-argument", - "line": 216, + "line": 217, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Refresh status", "surface": "apple", @@ -36547,7 +36579,7 @@ }, { "kind": "ui-named-argument", - "line": 217, + "line": 218, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Recheck macOS after approving access in System Settings.", "surface": "apple", @@ -36555,7 +36587,7 @@ }, { "kind": "ui-call", - "line": 223, + "line": 224, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Refresh", "surface": "apple", @@ -36563,7 +36595,7 @@ }, { "kind": "conditional-branch", - "line": 309, + "line": 310, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Check", "surface": "apple", @@ -36571,7 +36603,7 @@ }, { "kind": "conditional-branch", - "line": 309, + "line": 310, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Grant", "surface": "apple", @@ -36579,7 +36611,7 @@ }, { "kind": "ui-call", - "line": 319, + "line": 320, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Granted", "surface": "apple", @@ -36587,7 +36619,7 @@ }, { "kind": "ui-call", - "line": 323, + "line": 324, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Checking…", "surface": "apple", @@ -36595,7 +36627,7 @@ }, { "kind": "conditional-branch", - "line": 327, + "line": 328, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Request access", "surface": "apple", @@ -36603,7 +36635,7 @@ }, { "kind": "conditional-branch", - "line": 327, + "line": 328, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Status unavailable", "surface": "apple", @@ -36611,7 +36643,7 @@ }, { "kind": "conditional-branch", - "line": 355, + "line": 356, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Control Terminal for automation actions; other apps request access separately", "surface": "apple", @@ -36619,7 +36651,7 @@ }, { "kind": "conditional-branch", - "line": 356, + "line": 357, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Show desktop alerts for agent activity", "surface": "apple", @@ -36627,7 +36659,7 @@ }, { "kind": "conditional-branch", - "line": 357, + "line": 358, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Control UI elements when an action requires it", "surface": "apple", @@ -36635,7 +36667,7 @@ }, { "kind": "conditional-branch", - "line": 358, + "line": 359, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Capture the screen for context or screenshots", "surface": "apple", @@ -36643,7 +36675,7 @@ }, { "kind": "conditional-branch", - "line": 359, + "line": 360, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Allow Voice Wake, push-to-talk, Talk Mode, and Quick Chat dictation", "surface": "apple", @@ -36651,7 +36683,7 @@ }, { "kind": "conditional-branch", - "line": 360, + "line": 361, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Use Apple Speech; passive Voice Wake stays on-device", "surface": "apple", @@ -36659,7 +36691,7 @@ }, { "kind": "conditional-branch", - "line": 361, + "line": 362, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Capture photos and video from the camera", "surface": "apple", @@ -36667,7 +36699,7 @@ }, { "kind": "conditional-branch", - "line": 362, + "line": 363, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Share location when requested by the agent", "surface": "apple", @@ -36675,7 +36707,7 @@ }, { "kind": "ui-localized-call", - "line": 312, + "line": 313, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "OpenClaw updated", "surface": "apple", @@ -36683,7 +36715,7 @@ }, { "kind": "ui-localized-call", - "line": 331, + "line": 332, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "Finishing your OpenClaw update", "surface": "apple", @@ -36691,7 +36723,7 @@ }, { "kind": "ui-localized-call", - "line": 332, + "line": 333, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "Checking the Mac app and Gateway…", "surface": "apple", @@ -36699,7 +36731,7 @@ }, { "kind": "ui-localized-call", - "line": 440, + "line": 445, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "Gateway recovery failed.", "surface": "apple", @@ -36707,7 +36739,7 @@ }, { "kind": "ui-localized-call", - "line": 441, + "line": 446, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "The managed OpenClaw runtime could not be reinstalled.", "surface": "apple", @@ -36715,7 +36747,7 @@ }, { "kind": "ui-localized-call", - "line": 448, + "line": 453, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "Restarting and verifying the Gateway…", "surface": "apple", @@ -36723,7 +36755,7 @@ }, { "kind": "ui-localized-call", - "line": 449, + "line": 454, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "Verifying the Mac node runtime…", "surface": "apple", @@ -36731,7 +36763,7 @@ }, { "kind": "ui-localized-call", - "line": 463, + "line": 468, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "Letting your agent know you’re back…", "surface": "apple", @@ -36739,7 +36771,7 @@ }, { "kind": "ui-localized-call", - "line": 495, + "line": 500, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "Welcome back", "surface": "apple", @@ -36747,7 +36779,7 @@ }, { "kind": "ui-localized-call", - "line": 497, + "line": 502, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "OpenClaw \\(receipt.toVersion) and its Gateway are ready.", "surface": "apple", @@ -36755,7 +36787,7 @@ }, { "kind": "ui-localized-call", - "line": 498, + "line": 503, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "OpenClaw \\(receipt.toVersion) and its Mac node runtime are ready.", "surface": "apple", @@ -36763,7 +36795,7 @@ }, { "kind": "ui-localized-call", - "line": 501, + "line": 506, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "Your agent could not be notified yet. OpenClaw will retry after the next app launch.", "surface": "apple", @@ -36771,7 +36803,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 505, + "line": 510, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "OpenClaw could not notify your agent automatically. The app and Gateway update are complete.", "surface": "apple", @@ -36779,7 +36811,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 511, + "line": 516, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "OpenClaw could not notify your agent automatically. The app and Mac node update are complete.", "surface": "apple", @@ -36787,7 +36819,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 518, + "line": 523, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "OpenClaw could not confirm the agent notification. It will not retry, to avoid a duplicate welcome.", "surface": "apple", @@ -36795,7 +36827,7 @@ }, { "kind": "ui-localized-call", - "line": 524, + "line": 529, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "The remote Gateway is older than this Mac app, so OpenClaw skipped the agent notification.", "surface": "apple", @@ -36803,7 +36835,7 @@ }, { "kind": "ui-localized-call", - "line": 526, + "line": 531, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "The Gateway remains paused, so OpenClaw did not wake your agent.", "surface": "apple", @@ -36811,7 +36843,7 @@ }, { "kind": "ui-localized-call", - "line": 554, + "line": 560, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "Gateway verification failed.", "surface": "apple", @@ -36819,7 +36851,7 @@ }, { "kind": "ui-localized-call", - "line": 555, + "line": 561, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "The managed runtime does not match the updated Mac app.", "surface": "apple", @@ -36827,7 +36859,7 @@ }, { "kind": "ui-localized-call", - "line": 561, + "line": 567, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "The Mac node did not restart.", "surface": "apple", @@ -36835,7 +36867,7 @@ }, { "kind": "ui-localized-call", - "line": 567, + "line": 573, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "The Mac node did not become ready.", "surface": "apple", @@ -36843,7 +36875,7 @@ }, { "kind": "ui-localized-call", - "line": 568, + "line": 574, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "The node service restarted but did not remain running.", "surface": "apple", @@ -36851,7 +36883,7 @@ }, { "kind": "ui-localized-call", - "line": 579, + "line": 585, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "The Gateway did not start.", "surface": "apple", @@ -36859,7 +36891,7 @@ }, { "kind": "ui-localized-call", - "line": 580, + "line": 586, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "The update is installed, but Gateway health did not become ready.", "surface": "apple", @@ -36867,7 +36899,7 @@ }, { "kind": "ui-localized-call", - "line": 592, + "line": 598, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "The Gateway could not reconnect.", "surface": "apple", @@ -36875,7 +36907,7 @@ }, { "kind": "ui-localized-call", - "line": 594, + "line": 600, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "OpenClaw installed the update but could not verify the Gateway connection.", "surface": "apple", @@ -36883,7 +36915,7 @@ }, { "kind": "ui-localized-call", - "line": 799, + "line": 805, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "The Gateway could not be checked.", "surface": "apple", @@ -36891,7 +36923,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 801, + "line": 807, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "OpenClaw could not read the Gateway service ownership record. Retry after checking the Gateway LaunchAgent.", "surface": "apple", @@ -36899,7 +36931,7 @@ }, { "kind": "ui-localized-call", - "line": 807, + "line": 813, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "The Mac node could not be checked.", "surface": "apple", @@ -36907,7 +36939,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 809, + "line": 815, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "OpenClaw could not read the node service ownership record. Retry after checking the node LaunchAgent.", "surface": "apple", @@ -36915,7 +36947,7 @@ }, { "kind": "ui-localized-call", - "line": 820, + "line": 826, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "Gateway update needs help", "surface": "apple", @@ -36923,7 +36955,7 @@ }, { "kind": "ui-call", - "line": 877, + "line": 883, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "Update guide", "surface": "apple", @@ -36931,7 +36963,7 @@ }, { "kind": "ui-call", - "line": 878, + "line": 884, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "Ask Discord", "surface": "apple", @@ -36939,7 +36971,7 @@ }, { "kind": "ui-call", - "line": 880, + "line": 886, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "Retry", "surface": "apple", @@ -36947,7 +36979,7 @@ }, { "kind": "ui-call", - "line": 886, + "line": 892, "path": "apps/macos/Sources/OpenClaw/PostUpdate.swift", "source": "Continue", "surface": "apple", @@ -37859,7 +37891,7 @@ }, { "kind": "ui-call", - "line": 180, + "line": 181, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Managed by Nix", "surface": "apple", @@ -37867,7 +37899,7 @@ }, { "kind": "ui-call", - "line": 186, + "line": 187, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Config: \\(configPath)", "surface": "apple", @@ -37875,7 +37907,7 @@ }, { "kind": "ui-call", - "line": 187, + "line": 188, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "State: \\(stateDir)", "surface": "apple", @@ -37883,7 +37915,7 @@ }, { "kind": "ui-call", - "line": 276, + "line": 277, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Legacy pane — this now lives in the Dashboard", "surface": "apple", @@ -37891,7 +37923,7 @@ }, { "kind": "ui-call", - "line": 280, + "line": 281, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Open in Dashboard", "surface": "apple", @@ -37899,7 +37931,7 @@ }, { "kind": "conditional-branch", - "line": 510, + "line": 511, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "General", "surface": "apple", @@ -37907,7 +37939,7 @@ }, { "kind": "conditional-branch", - "line": 511, + "line": 512, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Connection", "surface": "apple", @@ -37915,7 +37947,7 @@ }, { "kind": "conditional-branch", - "line": 512, + "line": 513, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Gateways", "surface": "apple", @@ -37923,7 +37955,7 @@ }, { "kind": "conditional-branch", - "line": 513, + "line": 514, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Permissions", "surface": "apple", @@ -37931,7 +37963,7 @@ }, { "kind": "conditional-branch", - "line": 514, + "line": 515, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Voice & Talk", "surface": "apple", @@ -37939,7 +37971,7 @@ }, { "kind": "conditional-branch", - "line": 515, + "line": 516, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "OpenClaw", "surface": "apple", @@ -37947,7 +37979,7 @@ }, { "kind": "conditional-branch", - "line": 516, + "line": 517, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Channels", "surface": "apple", @@ -37955,7 +37987,7 @@ }, { "kind": "conditional-branch", - "line": 517, + "line": 518, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Skills", "surface": "apple", @@ -37963,7 +37995,7 @@ }, { "kind": "conditional-branch", - "line": 518, + "line": 519, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Cron Jobs", "surface": "apple", @@ -37971,7 +38003,7 @@ }, { "kind": "conditional-branch", - "line": 519, + "line": 520, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Exec Approvals", "surface": "apple", @@ -37979,7 +38011,7 @@ }, { "kind": "conditional-branch", - "line": 520, + "line": 521, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Threads", "surface": "apple", @@ -37987,7 +38019,7 @@ }, { "kind": "conditional-branch", - "line": 521, + "line": 522, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Instances", "surface": "apple", @@ -37995,7 +38027,7 @@ }, { "kind": "conditional-branch", - "line": 522, + "line": 523, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Config", "surface": "apple", @@ -38003,7 +38035,7 @@ }, { "kind": "conditional-branch", - "line": 523, + "line": 524, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Debug", "surface": "apple", @@ -38011,7 +38043,7 @@ }, { "kind": "conditional-branch", - "line": 524, + "line": 525, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "About", "surface": "apple", diff --git a/apps/macos/README.md b/apps/macos/README.md index 55bf93794256..8e05b25a7515 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -20,6 +20,32 @@ the `--chat`/`--dashboard` auto-open helpers. Pairing, control-channel, and Mac node services still start. Combine it with `--attach-only` when an external process owns the local Gateway. +## App profiles + +Launch a fully isolated app instance with the same profile name used by the CLI: + +```bash +OPENCLAW_PROFILE=work /Applications/OpenClaw.app/Contents/MacOS/OpenClaw +``` + +Profile names use 1–64 lowercase letters, numbers, underscores, or hyphens and +must start with a letter or number. `default` selects the normal app; `gateway`, +`mac`, and `node` are reserved LaunchAgent identities. + +`scripts/restart-mac.sh` intentionally rejects named profiles because its +packaging cleanup is host-global. Build/package normally, then launch the named +profile directly with the command above. + +A named profile keeps state in `~/.openclaw-`, uses its own app defaults, +Keychain services, duplicate-instance lock, and the CLI-managed Gateway service +`ai.openclaw.`. Unless config or environment selects a port, each profile +derives a stable port in the profile `20000...59999` range. The app does not +install or modify the host-global Mac node +service or OpenClaw login item while a profile is active. The runtime child node +still runs in process as usual. App relocation, Sparkle updates, and post-update +service repair are disabled in profile mode; update the installed app through +the normal default-profile workflow. + ## Packaging flow ```bash diff --git a/apps/macos/Sources/OpenClaw/AboutSettings.swift b/apps/macos/Sources/OpenClaw/AboutSettings.swift index d8d0283e71d2..48258121e153 100644 --- a/apps/macos/Sources/OpenClaw/AboutSettings.swift +++ b/apps/macos/Sources/OpenClaw/AboutSettings.swift @@ -7,7 +7,7 @@ struct AboutSettings: View { weak var updater: UpdaterProviding? @Environment(\.colorScheme) private var colorScheme @State private var iconHover = false - @AppStorage("autoUpdateEnabled") private var autoCheckEnabled = true + @AppStorage("autoUpdateEnabled", store: AppDefaults.standard) private var autoCheckEnabled = true @State private var didLoadUpdaterState = false var body: some View { @@ -70,7 +70,9 @@ struct AboutSettings: View { Button("Check for Updates…") { updater.checkForUpdates(nil) } } } else { - Text("Updates unavailable in this build.") + Text(AppProfile.current.isActive + ? "App updates are unavailable while a profile is active." + : "Updates unavailable in this build.") .foregroundStyle(.secondary) .padding(.top, 4) } diff --git a/apps/macos/Sources/OpenClaw/AppProfile.swift b/apps/macos/Sources/OpenClaw/AppProfile.swift new file mode 100644 index 000000000000..e7ed225009df --- /dev/null +++ b/apps/macos/Sources/OpenClaw/AppProfile.swift @@ -0,0 +1,125 @@ +import Darwin +import Foundation + +struct AppProfile: Equatable, Sendable { + struct ValidationError: LocalizedError, Equatable, Sendable { + let rawValue: String + let reason: String + + var errorDescription: String? { + "Invalid OPENCLAW_PROFILE \"\(self.rawValue)\": \(self.reason)" + } + } + + static let current = Self(environment: ProcessInfo.processInfo.environment) + private static let reservedLaunchAgentNames: Set = ["gateway", "mac", "node"] + + let name: String? + let validationError: ValidationError? + + init(environment: [String: String]) { + let raw = environment["OPENCLAW_PROFILE"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if raw.isEmpty || raw.lowercased() == "default" { + self.name = nil + self.validationError = nil + return + } + if let reason = Self.invalidReason(raw) { + self.name = nil + self.validationError = ValidationError(rawValue: raw, reason: reason) + return + } + self.name = raw + self.validationError = nil + } + + var isActive: Bool { + self.name != nil + } + + var gatewayLaunchAgentLabel: String { + // Keep this byte-for-byte aligned with src/daemon/constants.ts + // resolveGatewayLaunchAgentLabel; the CLI owns the managed service. + self.name.map { "ai.openclaw.\($0)" } ?? "ai.openclaw.gateway" + } + + var defaultsSuiteName: String? { + // Named profiles need a stable domain even when dev and packaged bundle ids differ. + self.name.map { "\(launchdLabel).profile.\($0)" } + } + + var keychainServiceSuffix: String { + self.name.map { ".profile.\($0)" } ?? "" + } + + func keychainService(base: String) -> String { + base + self.keychainServiceSuffix + } + + func stateDirectoryURL(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { + let directory = self.name.map { ".openclaw-\($0)" } ?? ".openclaw" + return homeDirectory.appendingPathComponent(directory, isDirectory: true) + } + + var cliRootArguments: [String] { + self.name.map { ["--profile", $0] } ?? [] + } + + func localCLICommand(prefix: [String], arguments: [String]) -> [String] { + prefix + self.cliRootArguments + arguments + } + + var instanceLockName: String { + self.name.map { "ai.openclaw.mac.profile.\($0)" } ?? "ai.openclaw.mac" + } + + func instanceLockURL(systemTemporaryDirectory: URL = URL(fileURLWithPath: "/tmp", isDirectory: true)) -> URL { + // Service/defaults/Keychain identity follows only the profile; state overrides must not split this lock. + systemTemporaryDirectory + .appendingPathComponent("openclaw-\(geteuid())-app-instances", isDirectory: true) + .appendingPathComponent("\(self.instanceLockName).lock", isDirectory: false) + } + + var defaultGatewayPort: Int { + guard let name else { return 18789 } + var hash: UInt32 = 2_166_136_261 + for byte in name.utf8 { + hash = (hash ^ UInt32(byte)) &* 16_777_619 + } + return 20000 + Int(hash % 40000) + } + + private static func invalidReason(_ value: String) -> String? { + // Mirror src/cli/profile-utils.ts, then apply the stricter macOS native-service rules. + guard value.utf8.count <= 64, + value.utf8.first.map(self.isASCIIAlphanumeric) == true, + value.utf8.allSatisfy({ Self.isASCIIAlphanumeric($0) || $0 == 45 || $0 == 95 }) + else { + return "use 1-64 letters, numbers, underscores, or hyphens, starting with a letter or number" + } + guard value == value.lowercased() else { + return "macOS profile names must be lowercase so state and LaunchAgent identities cannot collide" + } + guard !self.reservedLaunchAgentNames.contains(value) else { + return "\"\(value)\" is reserved by an existing OpenClaw LaunchAgent" + } + return nil + } + + private static func isASCIIAlphanumeric(_ byte: UInt8) -> Bool { + (48...57).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte) + } +} + +enum AppDefaults { + /// UserDefaults synchronizes access internally; the selected suite is immutable for this process. + nonisolated(unsafe) static let standard: UserDefaults = { + guard let suiteName = AppProfile.current.defaultsSuiteName else { + return UserDefaults.standard + } + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Could not create UserDefaults suite \(suiteName)") + } + return defaults + }() +} diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index 8e3d6206f44b..2f7bac1d01f4 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -115,7 +115,7 @@ final class AppState { @ObservationIgnored private var activeComputerPresenceUpdateGeneration: UInt64 = 0 var isPaused: Bool { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } } + didSet { self.ifNotPreview { AppDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } } } var launchAtLogin: Bool { @@ -131,13 +131,13 @@ final class AppState { } var onboardingSeen: Bool { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: onboardingSeenKey) } + didSet { self.ifNotPreview { AppDefaults.standard.set(self.onboardingSeen, forKey: onboardingSeenKey) } } } var debugPaneEnabled: Bool { didSet { - self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: debugPaneEnabledKey) } + self.ifNotPreview { AppDefaults.standard.set(self.debugPaneEnabled, forKey: debugPaneEnabledKey) } CanvasManager.shared.refreshDebugStatus() } } @@ -145,7 +145,7 @@ final class AppState { var nativeSettingsPanesEnabled: Bool { didSet { self.ifNotPreview { - UserDefaults.standard.set(self.nativeSettingsPanesEnabled, forKey: nativeSettingsPanesEnabledKey) + AppDefaults.standard.set(self.nativeSettingsPanesEnabled, forKey: nativeSettingsPanesEnabledKey) } } } @@ -153,7 +153,7 @@ final class AppState { var swabbleEnabled: Bool { didSet { self.ifNotPreview { - UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey) + AppDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey) Task { await VoiceWakeRuntime.shared.refresh(state: self) } } } @@ -163,7 +163,7 @@ final class AppState { didSet { // Preserve the raw editing state; sanitization happens when we actually use the triggers. self.ifNotPreview { - UserDefaults.standard.set(self.swabbleTriggerWords, forKey: swabbleTriggersKey) + AppDefaults.standard.set(self.swabbleTriggerWords, forKey: swabbleTriggersKey) if self.swabbleEnabled { Task { await VoiceWakeRuntime.shared.refresh(state: self) } } @@ -181,7 +181,7 @@ final class AppState { } var iconAnimationsEnabled: Bool { - didSet { self.ifNotPreview { UserDefaults.standard.set( + didSet { self.ifNotPreview { AppDefaults.standard.set( self.iconAnimationsEnabled, forKey: iconAnimationsEnabledKey) } } } @@ -189,7 +189,7 @@ final class AppState { var showDockIcon: Bool { didSet { self.ifNotPreview { - UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey) + AppDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey) AppActivationPolicy.apply(showDockIcon: self.showDockIcon) } } @@ -198,7 +198,7 @@ final class AppState { var voiceWakeMicID: String { didSet { self.ifNotPreview { - UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey) + AppDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey) if self.swabbleEnabled, !self.talkEnabled { Task { await VoiceWakeRuntime.shared.refresh(state: self) } } @@ -210,13 +210,13 @@ final class AppState { } var voiceWakeMicName: String { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.voiceWakeMicName, forKey: voiceWakeMicNameKey) } } + didSet { self.ifNotPreview { AppDefaults.standard.set(self.voiceWakeMicName, forKey: voiceWakeMicNameKey) } } } var voiceWakeLocaleID: String { didSet { self.ifNotPreview { - UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey) + AppDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey) if self.swabbleEnabled { Task { await VoiceWakeRuntime.shared.refresh(state: self) } } @@ -225,13 +225,13 @@ final class AppState { } var voiceWakeAdditionalLocaleIDs: [String] { - didSet { self.ifNotPreview { UserDefaults.standard.set( + didSet { self.ifNotPreview { AppDefaults.standard.set( self.voiceWakeAdditionalLocaleIDs, forKey: voiceWakeAdditionalLocalesKey) } } } var voicePushToTalkEnabled: Bool { - didSet { self.ifNotPreview { UserDefaults.standard.set( + didSet { self.ifNotPreview { AppDefaults.standard.set( self.voicePushToTalkEnabled, forKey: voicePushToTalkEnabledKey) } } } @@ -239,7 +239,7 @@ final class AppState { var voiceWakeTriggersTalkMode: Bool { didSet { self.ifNotPreview { - UserDefaults.standard.set(self.voiceWakeTriggersTalkMode, forKey: voiceWakeTriggersTalkModeKey) + AppDefaults.standard.set(self.voiceWakeTriggersTalkMode, forKey: voiceWakeTriggersTalkModeKey) if self.swabbleEnabled { Task { await VoiceWakeRuntime.shared.refresh(state: self) } } @@ -252,7 +252,7 @@ final class AppState { var talkEnabled: Bool { didSet { self.ifNotPreview { - UserDefaults.standard.set(self.talkEnabled, forKey: talkEnabledKey) + AppDefaults.standard.set(self.talkEnabled, forKey: talkEnabledKey) Task { await TalkModeController.shared.setEnabled(self.talkEnabled) } } } @@ -261,7 +261,7 @@ final class AppState { var talkPhaseSoundsEnabled: Bool { didSet { self.ifNotPreview { - UserDefaults.standard.set(self.talkPhaseSoundsEnabled, forKey: talkPhaseSoundsEnabledKey) + AppDefaults.standard.set(self.talkPhaseSoundsEnabled, forKey: talkPhaseSoundsEnabledKey) } } } @@ -269,7 +269,7 @@ final class AppState { var talkShiftToStopEnabled: Bool { didSet { self.ifNotPreview { - UserDefaults.standard.set(self.talkShiftToStopEnabled, forKey: talkShiftToStopEnabledKey) + AppDefaults.standard.set(self.talkShiftToStopEnabled, forKey: talkShiftToStopEnabledKey) Task { TalkSpeechInterruptMonitor.shared.setEnabled(self.talkShiftToStopEnabled && self.talkEnabled) } } } @@ -279,7 +279,7 @@ final class AppState { var seamColorHex: String? var iconOverride: IconOverrideSelection { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } } + didSet { self.ifNotPreview { AppDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } } } var isWorking: Bool = false @@ -289,7 +289,7 @@ final class AppState { var heartbeatsEnabled: Bool { didSet { self.ifNotPreview { - UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey) + AppDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey) Task { _ = await GatewayConnection.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) } } } @@ -297,7 +297,7 @@ final class AppState { var connectionMode: ConnectionMode { didSet { - self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) } + self.ifNotPreview { AppDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) } if oldValue != self.connectionMode { self.markGatewayConfigDirty([.mode]) } @@ -321,11 +321,11 @@ final class AppState { } var canvasEnabled: Bool { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } + didSet { self.ifNotPreview { AppDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } } var quickChatEnabled: Bool { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.quickChatEnabled, forKey: quickChatEnabledKey) } } + didSet { self.ifNotPreview { AppDefaults.standard.set(self.quickChatEnabled, forKey: quickChatEnabledKey) } } } var activeComputerPresenceEnabled: Bool { @@ -342,7 +342,7 @@ final class AppState { var peekabooBridgeEnabled: Bool { didSet { self.ifNotPreview { - UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey) + AppDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey) } self.applyPeekabooBridgeHostState() } @@ -361,7 +361,7 @@ final class AppState { var remoteTarget: String { didSet { - self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } + self.ifNotPreview { AppDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } if oldValue != self.remoteTarget { self.markGatewayConfigDirty([.remoteTarget, .remoteUrl, .remoteHostKeyPolicy]) } @@ -410,7 +410,7 @@ final class AppState { var remoteIdentity: String { didSet { - self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } + self.ifNotPreview { AppDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } if oldValue != self.remoteIdentity { self.markGatewayConfigDirty([.remoteIdentity]) } @@ -419,11 +419,11 @@ final class AppState { } var remoteProjectRoot: String { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } } + didSet { self.ifNotPreview { AppDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } } } var remoteCliPath: String { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteCliPath, forKey: remoteCliPathKey) } } + didSet { self.ifNotPreview { AppDefaults.standard.set(self.remoteCliPath, forKey: remoteCliPathKey) } } } @ObservationIgnored private var earBoostTask: Task? @@ -442,19 +442,20 @@ final class AppState { let isPreview = preview || ProcessInfo.processInfo.isRunningTests self.isPreview = isPreview self.bundleLocationAllowsPersistentIntegration = - isPreview || ApplicationRelocator.currentBundleAllowsPersistentIntegration() + !AppProfile.current.isActive && + (isPreview || ApplicationRelocator.currentBundleAllowsPersistentIntegration()) self.execApprovalsDefaultsAsyncResolver = execApprovalsDefaultsAsyncResolver self.execApprovalsReadRetryDelay = execApprovalsReadRetryDelay self.gatewayConfigSaver = gatewayConfigSaver - let onboardingSeen = UserDefaults.standard.bool(forKey: onboardingSeenKey) - self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) + let onboardingSeen = AppDefaults.standard.bool(forKey: onboardingSeenKey) + self.isPaused = AppDefaults.standard.bool(forKey: pauseDefaultsKey) self.launchAtLogin = false self.onboardingSeen = onboardingSeen - self.debugPaneEnabled = UserDefaults.standard.bool(forKey: debugPaneEnabledKey) - self.nativeSettingsPanesEnabled = UserDefaults.standard.bool(forKey: nativeSettingsPanesEnabledKey) - let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey) + self.debugPaneEnabled = AppDefaults.standard.bool(forKey: debugPaneEnabledKey) + self.nativeSettingsPanesEnabled = AppDefaults.standard.bool(forKey: nativeSettingsPanesEnabledKey) + let savedVoiceWake = AppDefaults.standard.bool(forKey: swabbleEnabledKey) self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false - self.swabbleTriggerWords = UserDefaults.standard + self.swabbleTriggerWords = AppDefaults.standard .stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers self.voiceWakeTriggerChime = Self.loadChime( key: voiceWakeTriggerChimeKey, @@ -462,54 +463,54 @@ final class AppState { self.voiceWakeSendChime = Self.loadChime( key: voiceWakeSendChimeKey, fallback: .system(name: "Glass")) - if let storedIconAnimations = UserDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool { + if let storedIconAnimations = AppDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool { self.iconAnimationsEnabled = storedIconAnimations } else { self.iconAnimationsEnabled = true - UserDefaults.standard.set(true, forKey: iconAnimationsEnabledKey) + AppDefaults.standard.set(true, forKey: iconAnimationsEnabledKey) } - if let storedShowDockIcon = UserDefaults.standard.object(forKey: showDockIconKey) as? Bool { + if let storedShowDockIcon = AppDefaults.standard.object(forKey: showDockIconKey) as? Bool { self.showDockIcon = storedShowDockIcon } else { self.showDockIcon = true - UserDefaults.standard.set(true, forKey: showDockIconKey) + AppDefaults.standard.set(true, forKey: showDockIconKey) } - self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? "" - self.voiceWakeMicName = UserDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? "" - self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier - self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard + self.voiceWakeMicID = AppDefaults.standard.string(forKey: voiceWakeMicKey) ?? "" + self.voiceWakeMicName = AppDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? "" + self.voiceWakeLocaleID = AppDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier + self.voiceWakeAdditionalLocaleIDs = AppDefaults.standard .stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? [] - self.voicePushToTalkEnabled = UserDefaults.standard + self.voicePushToTalkEnabled = AppDefaults.standard .object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false - self.voiceWakeTriggersTalkMode = UserDefaults.standard + self.voiceWakeTriggersTalkMode = AppDefaults.standard .object(forKey: voiceWakeTriggersTalkModeKey) as? Bool ?? false - self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey) - if let storedPhaseSounds = UserDefaults.standard.object(forKey: talkPhaseSoundsEnabledKey) as? Bool { + self.talkEnabled = AppDefaults.standard.bool(forKey: talkEnabledKey) + if let storedPhaseSounds = AppDefaults.standard.object(forKey: talkPhaseSoundsEnabledKey) as? Bool { self.talkPhaseSoundsEnabled = storedPhaseSounds } else { self.talkPhaseSoundsEnabled = true - UserDefaults.standard.set(true, forKey: talkPhaseSoundsEnabledKey) + AppDefaults.standard.set(true, forKey: talkPhaseSoundsEnabledKey) } - if let storedShiftToStop = UserDefaults.standard.object(forKey: talkShiftToStopEnabledKey) as? Bool { + if let storedShiftToStop = AppDefaults.standard.object(forKey: talkShiftToStopEnabledKey) as? Bool { self.talkShiftToStopEnabled = storedShiftToStop } else { self.talkShiftToStopEnabled = true - UserDefaults.standard.set(true, forKey: talkShiftToStopEnabledKey) + AppDefaults.standard.set(true, forKey: talkShiftToStopEnabledKey) } self.seamColorHex = nil - if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool { + if let storedHeartbeats = AppDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool { self.heartbeatsEnabled = storedHeartbeats } else { self.heartbeatsEnabled = true - UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey) + AppDefaults.standard.set(true, forKey: heartbeatsEnabledKey) } - if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey), + if let storedOverride = AppDefaults.standard.string(forKey: iconOverrideKey), let selection = IconOverrideSelection(rawValue: storedOverride) { self.iconOverride = selection } else { self.iconOverride = .system - UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey) + AppDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey) } let configRoot = OpenClawConfigFile.loadDict() @@ -528,7 +529,7 @@ final class AppState { let hasConfigRemoteTarget = configRemote?.keys.contains("sshTarget") == true let configRemoteTarget = (configRemote?["sshTarget"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" + let storedRemoteTarget = AppDefaults.standard.string(forKey: remoteTargetKey) ?? "" if resolvedConnectionMode == .remote, hasConfigRemoteTarget { @@ -551,21 +552,23 @@ final class AppState { .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" self.remoteIdentity = hasConfigRemoteIdentity ? configRemoteIdentity - : UserDefaults.standard.string(forKey: remoteIdentityKey)?.nonEmpty ?? "" - self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey)?.nonEmpty ?? "" - self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey)?.nonEmpty ?? "" - self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true - self.quickChatEnabled = UserDefaults.standard.object(forKey: quickChatEnabledKey) as? Bool ?? true + : AppDefaults.standard.string(forKey: remoteIdentityKey)?.nonEmpty ?? "" + self.remoteProjectRoot = AppDefaults.standard.string(forKey: remoteProjectRootKey)?.nonEmpty ?? "" + self.remoteCliPath = AppDefaults.standard.string(forKey: remoteCliPathKey)?.nonEmpty ?? "" + self.canvasEnabled = AppDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true + self.quickChatEnabled = AppDefaults.standard.object(forKey: quickChatEnabledKey) as? Bool ?? true self.activeComputerPresenceEnabled = Self.resolveActiveComputerPresenceEnabled() self.execApprovalMode = .deny self.execApprovalPolicyLoadState = .loading - self.peekabooBridgeEnabled = UserDefaults.standard + self.peekabooBridgeEnabled = AppDefaults.standard .object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true - if !self.isPreview { + if !self.isPreview, !AppProfile.current.isActive { Task.detached(priority: .utility) { [weak self] in let current = await LaunchAgentManager.status() await MainActor.run { [weak self] in self?.hydrateLaunchAtLogin(current) } } + } else if !self.isPreview, AppProfile.current.isActive { + Self.logger.info("login-agent status skipped (unavailable under app profile)") } if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() { @@ -1125,7 +1128,7 @@ extension AppState { // MARK: - Chime persistence private static func loadChime(key: String, fallback: VoiceWakeChime) -> VoiceWakeChime { - guard let data = UserDefaults.standard.data(forKey: key) else { return fallback } + guard let data = AppDefaults.standard.data(forKey: key) else { return fallback } if let decoded = try? JSONDecoder().decode(VoiceWakeChime.self, from: data) { return decoded } @@ -1134,7 +1137,7 @@ extension AppState { private func storeChime(_ chime: VoiceWakeChime, key: String) { guard let data = try? JSONEncoder().encode(chime) else { return } - UserDefaults.standard.set(data, forKey: key) + AppDefaults.standard.set(data, forKey: key) } } @@ -1556,7 +1559,7 @@ extension AppState { } extension AppState { - static func resolveActiveComputerPresenceEnabled(defaults: UserDefaults = .standard) -> Bool { + static func resolveActiveComputerPresenceEnabled(defaults: UserDefaults = AppDefaults.standard) -> Bool { defaults.bool(forKey: activeComputerPresenceEnabledKey) } @@ -1575,7 +1578,7 @@ extension AppState { private func scheduleActiveComputerPresenceUpdate() { self.ifNotPreview { let enabled = self.activeComputerPresenceEnabled - UserDefaults.standard.set(enabled, forKey: activeComputerPresenceEnabledKey) + AppDefaults.standard.set(enabled, forKey: activeComputerPresenceEnabledKey) PresenceReporter.shared.sendImmediate(reason: "activity-sharing-changed") self.activeComputerPresenceUpdateGeneration &+= 1 let generation = self.activeComputerPresenceUpdateGeneration diff --git a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift index 01a45d4c1af9..5bfcdf234f39 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift @@ -56,9 +56,9 @@ final class CLIInstallPrompter { if Self.hasPendingManagedRestart() { return } } guard !status.isReady else { return } - let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey) + let lastPrompt = AppDefaults.standard.string(forKey: cliInstallPromptedVersionKey) guard lastPrompt != version else { return } - UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey) + AppDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey) if let target = self.installTargetForCurrentBuild(confirmStable: true) { Task { _ = await self.installCLI(target: target) } @@ -224,15 +224,15 @@ final class CLIInstallPrompter { } static func hasPendingManagedRestart() -> Bool { - UserDefaults.standard.bool(forKey: cliManagedRestartPendingKey) + AppDefaults.standard.bool(forKey: cliManagedRestartPendingKey) } static func setPendingManagedRestart() { - UserDefaults.standard.set(true, forKey: cliManagedRestartPendingKey) + AppDefaults.standard.set(true, forKey: cliManagedRestartPendingKey) } static func clearPendingManagedRestart() { - UserDefaults.standard.removeObject(forKey: cliManagedRestartPendingKey) + AppDefaults.standard.removeObject(forKey: cliManagedRestartPendingKey) } static func shouldManageCLI(connectionMode: AppState.ConnectionMode) -> Bool { diff --git a/apps/macos/Sources/OpenClaw/CLIInstaller.swift b/apps/macos/Sources/OpenClaw/CLIInstaller.swift index 73870a898400..a3ee10c40e3c 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstaller.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstaller.swift @@ -22,14 +22,14 @@ enum CLIInstallBuild { } enum CLIInstallPolicy { - static func storedPolicy(defaults: UserDefaults = .standard) -> String? { + static func storedPolicy(defaults: UserDefaults = AppDefaults.standard) -> String? { defaults.string(forKey: cliInstallPolicyKey) } static func requiredGatewayVersionString( appVersion: String?, isDebug: Bool, - defaults: UserDefaults = .standard) -> String? + defaults: UserDefaults = AppDefaults.standard) -> String? { guard !CLIInstallBuild.isStable(appVersion: appVersion, isDebug: isDebug) else { return appVersion @@ -315,8 +315,8 @@ enum CLIInstaller { private static func rememberValidated(_ status: Status) { guard case let .ready(location, version) = status else { return } - UserDefaults.standard.set(location, forKey: cliValidatedExecutableKey) - UserDefaults.standard.set(version, forKey: cliValidatedVersionKey) + AppDefaults.standard.set(location, forKey: cliValidatedExecutableKey) + AppDefaults.standard.set(version, forKey: cliValidatedVersionKey) } @discardableResult @@ -470,11 +470,13 @@ enum CLIInstaller { executable: String, targetVersion: String, restartGateway: Bool = true, - repair: Bool = false) -> [String] + repair: Bool = false, + profile: AppProfile = .current) -> [String] { - var command = repair - ? [executable, "update", "repair", "--json", "--timeout", "900", "--yes"] - : [executable, "update", "--tag", targetVersion, "--json", "--timeout", "900"] + let arguments = repair + ? ["update", "repair", "--json", "--timeout", "900", "--yes"] + : ["update", "--tag", targetVersion, "--json", "--timeout", "900"] + var command = profile.localCLICommand(prefix: [executable], arguments: arguments) if !restartGateway { command.append("--no-restart") } @@ -559,7 +561,7 @@ enum CLIInstaller { case .exact: "exact" case let .channel(channel): channel.rawValue } - UserDefaults.standard.set(policy, forKey: cliInstallPolicyKey) + AppDefaults.standard.set(policy, forKey: cliInstallPolicyKey) } private static func devCheckoutLocation(prefix: String) -> String { diff --git a/apps/macos/Sources/OpenClaw/CanvasWindowController+Helpers.swift b/apps/macos/Sources/OpenClaw/CanvasWindowController+Helpers.swift index a7d10f95b562..48acec311a34 100644 --- a/apps/macos/Sources/OpenClaw/CanvasWindowController+Helpers.swift +++ b/apps/macos/Sources/OpenClaw/CanvasWindowController+Helpers.swift @@ -28,7 +28,7 @@ extension CanvasWindowController { static func loadRestoredFrame(sessionKey: String) -> NSRect? { let key = self.storedFrameDefaultsKey(sessionKey: sessionKey) - guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil } + guard let arr = AppDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil } let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3]) if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil } return rect @@ -36,7 +36,7 @@ extension CanvasWindowController { static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) { let key = self.storedFrameDefaultsKey(sessionKey: sessionKey) - UserDefaults.standard.set( + AppDefaults.standard.set( [Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)], forKey: key) } diff --git a/apps/macos/Sources/OpenClaw/CommandResolver.swift b/apps/macos/Sources/OpenClaw/CommandResolver.swift index d03af558beb0..3a0a5839b187 100644 --- a/apps/macos/Sources/OpenClaw/CommandResolver.swift +++ b/apps/macos/Sources/OpenClaw/CommandResolver.swift @@ -24,9 +24,12 @@ enum CommandResolver { runtime: RuntimeResolution, entrypoint: String, subcommand: String, - extraArgs: [String]) -> [String] + extraArgs: [String], + profile: AppProfile = .current) -> [String] { - [runtime.path, entrypoint, subcommand] + extraArgs + profile.localCLICommand( + prefix: [runtime.path, entrypoint], + arguments: [subcommand] + extraArgs) } static func runtimeErrorCommand(_ error: RuntimeResolutionError) -> [String] { @@ -45,7 +48,7 @@ enum CommandResolver { } static func projectRoot() -> URL { - if let stored = UserDefaults.standard.string(forKey: projectRootDefaultsKey), + if let stored = AppDefaults.standard.string(forKey: projectRootDefaultsKey), let url = expandPath(stored), FileManager().fileExists(atPath: url.path) { @@ -60,7 +63,7 @@ enum CommandResolver { } static func setProjectRoot(_ path: String) { - UserDefaults.standard.set(path, forKey: self.projectRootDefaultsKey) + AppDefaults.standard.set(path, forKey: self.projectRootDefaultsKey) } static func projectRootPath() -> String { @@ -73,7 +76,7 @@ enum CommandResolver { let home = FileManager().homeDirectoryForCurrentUser let projectRoot = self.projectRoot() let validatedExecutable = self.validatedOpenClawExecutable( - defaults: .standard, + defaults: AppDefaults.standard, fileManager: .default, requiredVersion: GatewayEnvironment.expectedGatewayVersionString()) return self.preferredPaths( @@ -267,7 +270,8 @@ enum CommandResolver { switch await self.runtimeResolution(searchPaths: searchPaths) { case let .success(runtime): return MacNodeHostWorkerLaunch( - command: [runtime.path, sourceRunner.path, "node", "worker"], + command: self.nodeHostWorkerCommand( + prefix: [runtime.path, sourceRunner.path]), currentDirectoryURL: root) case let .failure(error): throw error @@ -277,13 +281,21 @@ enum CommandResolver { #endif } + static func nodeHostWorkerCommand( + prefix: [String], + profile: AppProfile = .current) -> [String] + { + profile.localCLICommand(prefix: prefix, arguments: ["node", "worker"]) + } + static func openclawNodeCommand( subcommand: String, extraArgs: [String] = [], - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppDefaults.standard, configRoot: [String: Any]? = nil, searchPaths: [String]? = nil, - projectRoot: URL? = nil) async -> [String] + projectRoot: URL? = nil, + profile: AppProfile = .current) async -> [String] { let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot) if settings.mode == .remote, settings.transport == .ssh { @@ -299,10 +311,10 @@ enum CommandResolver { let root = projectRoot ?? self.projectRoot() if let openclawPath = projectOpenClawExecutable(projectRoot: root) { - return [openclawPath, subcommand] + extraArgs + return profile.localCLICommand(prefix: [openclawPath], arguments: [subcommand] + extraArgs) } if let openclawPath = openclawExecutable(searchPaths: searchPaths) { - return [openclawPath, subcommand] + extraArgs + return profile.localCLICommand(prefix: [openclawPath], arguments: [subcommand] + extraArgs) } let runtimeResult = await self.runtimeResolution(searchPaths: searchPaths) @@ -313,7 +325,8 @@ enum CommandResolver { runtime: runtime, entrypoint: entry, subcommand: subcommand, - extraArgs: extraArgs) + extraArgs: extraArgs, + profile: profile) } case .failure: break @@ -321,7 +334,9 @@ enum CommandResolver { if let pnpm = findExecutable(named: "pnpm", searchPaths: searchPaths) { // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. - return [pnpm, "--silent", "openclaw", subcommand] + extraArgs + return profile.localCLICommand( + prefix: [pnpm, "--silent", "openclaw"], + arguments: [subcommand] + extraArgs) } switch runtimeResult { @@ -338,10 +353,11 @@ enum CommandResolver { static func openclawCommand( subcommand: String, extraArgs: [String] = [], - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppDefaults.standard, configRoot: [String: Any]? = nil, searchPaths: [String]? = nil, - projectRoot: URL? = nil) async -> [String] + projectRoot: URL? = nil, + profile: AppProfile = .current) async -> [String] { await self.openclawNodeCommand( subcommand: subcommand, @@ -349,7 +365,8 @@ enum CommandResolver { defaults: defaults, configRoot: configRoot, searchPaths: searchPaths, - projectRoot: projectRoot) + projectRoot: projectRoot, + profile: profile) } // MARK: - SSH helpers @@ -503,7 +520,7 @@ enum CommandResolver { } static func connectionSettings( - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppDefaults.standard, configRoot: [String: Any]? = nil) -> RemoteSettings { let root = configRoot ?? OpenClawConfigFile.loadDict() @@ -545,7 +562,7 @@ enum CommandResolver { sshHostKeyPolicy: sshHostKeyPolicy) } - static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool { + static func connectionModeIsRemote(defaults: UserDefaults = AppDefaults.standard) -> Bool { self.connectionSettings(defaults: defaults).mode == .remote } diff --git a/apps/macos/Sources/OpenClaw/ConnectionModeResolver.swift b/apps/macos/Sources/OpenClaw/ConnectionModeResolver.swift index 506673947494..a99640038d34 100644 --- a/apps/macos/Sources/OpenClaw/ConnectionModeResolver.swift +++ b/apps/macos/Sources/OpenClaw/ConnectionModeResolver.swift @@ -15,7 +15,7 @@ struct EffectiveConnectionMode: Equatable { enum ConnectionModeResolver { static func resolve( root: [String: Any], - defaults: UserDefaults = .standard) -> EffectiveConnectionMode + defaults: UserDefaults = AppDefaults.standard) -> EffectiveConnectionMode { let gateway = root["gateway"] as? [String: Any] let configModeRaw = (gateway?["mode"] as? String) ?? "" diff --git a/apps/macos/Sources/OpenClaw/Constants.swift b/apps/macos/Sources/OpenClaw/Constants.swift index 40d61f750c68..4a76c001a576 100644 --- a/apps/macos/Sources/OpenClaw/Constants.swift +++ b/apps/macos/Sources/OpenClaw/Constants.swift @@ -3,7 +3,10 @@ import Foundation // Stable identifier used for both the macOS LaunchAgent label and Nix-managed defaults suite. // nix-openclaw writes app defaults into this suite to survive app bundle identifier churn. let launchdLabel = "ai.openclaw.mac" -let gatewayLaunchdLabel = "ai.openclaw.gateway" +var gatewayLaunchdLabel: String { + AppProfile.current.gatewayLaunchAgentLabel +} + let nodeLaunchdLabel = "ai.openclaw.node" let onboardingVersionKey = "openclaw.onboardingVersion" let onboardingSeenKey = "openclaw.onboardingSeen" @@ -41,7 +44,7 @@ let quickChatEnabledKey = "openclaw.quickChatEnabled" let cameraEnabledKey = "openclaw.cameraEnabled" let computerControlEnabledKey = "openclaw.computerControlEnabled" -func isComputerControlEnabled(defaults: UserDefaults = .standard) -> Bool { +func isComputerControlEnabled(defaults: UserDefaults = AppDefaults.standard) -> Bool { // object(forKey:) preserves an explicit false; bool(forKey:) would conflate it with an unset default. defaults.object(forKey: computerControlEnabledKey) as? Bool ?? true } diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift index fd1572cff761..5fb7674ed969 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -465,7 +465,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, } private func persistedLinkBrowserWidth() -> CGFloat? { - guard let number = UserDefaults.standard.object( + guard let number = AppDefaults.standard.object( forKey: DashboardWindowLayout.linkBrowserWidthDefaultsKey) as? NSNumber else { return nil } let width = CGFloat(number.doubleValue) @@ -492,7 +492,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, guard !self.linkBrowserItem.isCollapsed else { return } let width = self.linkBrowser.frame.width guard width.isFinite, width >= DashboardWindowLayout.linkBrowserMinWidth else { return } - UserDefaults.standard.set(Double(width), forKey: DashboardWindowLayout.linkBrowserWidthDefaultsKey) + AppDefaults.standard.set(Double(width), forKey: DashboardWindowLayout.linkBrowserWidthDefaultsKey) } private func requestBrowserProfileImportOfferIfNeeded() { diff --git a/apps/macos/Sources/OpenClaw/DebugActions.swift b/apps/macos/Sources/OpenClaw/DebugActions.swift index 107ea9a28023..63704f05e511 100644 --- a/apps/macos/Sources/OpenClaw/DebugActions.swift +++ b/apps/macos/Sources/OpenClaw/DebugActions.swift @@ -159,12 +159,12 @@ enum DebugActions { } static var verboseLoggingEnabledMain: Bool { - UserDefaults.standard.bool(forKey: self.verboseDefaultsKey) + AppDefaults.standard.bool(forKey: self.verboseDefaultsKey) } static func toggleVerboseLoggingMain() async -> Bool { let newValue = !self.verboseLoggingEnabledMain - UserDefaults.standard.set(newValue, forKey: self.verboseDefaultsKey) + AppDefaults.standard.set(newValue, forKey: self.verboseDefaultsKey) _ = try? await ControlChannel.shared.request( method: "system-event", params: ["text": AnyHashable("verbose-main:\(newValue ? "on" : "off")")]) @@ -177,15 +177,25 @@ enum DebugActions { let task = Process() // Relaunch shortly after this instance exits so we get a true restart even in debug. task.launchPath = "/bin/sh" - task.arguments = ["-c", "sleep 0.2; open -n \"$1\"", "_", url.path] + if let profile = AppProfile.current.name { + task.arguments = [ + "-c", + "sleep 0.2; open -n --env OPENCLAW_PROFILE=\"$2\" \"$1\"", + "_", + url.path, + profile, + ] + } else { + task.arguments = ["-c", "sleep 0.2; open -n \"$1\"", "_", url.path] + } try? task.run() NSApp.terminate(nil) } @MainActor static func restartOnboarding() { - UserDefaults.standard.set(false, forKey: self.onboardingSeenKey) - UserDefaults.standard.set(0, forKey: onboardingVersionKey) + AppDefaults.standard.set(false, forKey: self.onboardingSeenKey) + AppDefaults.standard.set(0, forKey: onboardingVersionKey) AppStateStore.shared.onboardingSeen = false OnboardingController.shared.restart() } diff --git a/apps/macos/Sources/OpenClaw/DebugSettings.swift b/apps/macos/Sources/OpenClaw/DebugSettings.swift index 75bab79f570a..5cdeeff81e83 100644 --- a/apps/macos/Sources/OpenClaw/DebugSettings.swift +++ b/apps/macos/Sources/OpenClaw/DebugSettings.swift @@ -511,13 +511,19 @@ struct DebugSettings: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - Button { - LaunchdManager.startOpenClaw() - } label: { - Label("Restart OpenClaw", systemImage: "arrow.counterclockwise") + if AppProfile.current.isActive { + Text("Login-agent restart is unavailable under a profile.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Button { + LaunchdManager.startOpenClaw() + } label: { + Label("Restart OpenClaw", systemImage: "arrow.counterclockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) } - .buttonStyle(.bordered) - .controlSize(.small) } HStack(spacing: 8) { diff --git a/apps/macos/Sources/OpenClaw/DeepLinks.swift b/apps/macos/Sources/OpenClaw/DeepLinks.swift index 72436504182e..b11559c9c37b 100644 --- a/apps/macos/Sources/OpenClaw/DeepLinks.swift +++ b/apps/macos/Sources/OpenClaw/DeepLinks.swift @@ -150,7 +150,7 @@ final class DeepLinkHandler { } private static func expectedKey() -> String { - let defaults = UserDefaults.standard + let defaults = AppDefaults.standard if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty { return key } diff --git a/apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift b/apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift index e3300bf5bde2..4f6ed9b7da88 100644 --- a/apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift +++ b/apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift @@ -16,7 +16,7 @@ actor DiagnosticsFileLog { } nonisolated static func isEnabled() -> Bool { - UserDefaults.standard.bool(forKey: debugFileLogEnabledKey) + AppDefaults.standard.bool(forKey: debugFileLogEnabledKey) } nonisolated static func logDirectoryURL() -> URL { diff --git a/apps/macos/Sources/OpenClaw/DockIconManager.swift b/apps/macos/Sources/OpenClaw/DockIconManager.swift index e656f6e75e62..0370ee70d695 100644 --- a/apps/macos/Sources/OpenClaw/DockIconManager.swift +++ b/apps/macos/Sources/OpenClaw/DockIconManager.swift @@ -28,7 +28,7 @@ final class DockIconManager: NSObject, @unchecked Sendable { return } - let userWantsDockHidden = (UserDefaults.standard.object(forKey: showDockIconKey) as? Bool) == false + let userWantsDockHidden = (AppDefaults.standard.object(forKey: showDockIconKey) as? Bool) == false let visibleWindows = NSApp?.windows.filter { window in window.isVisible && window.frame.width > 1 && @@ -107,7 +107,7 @@ final class DockIconManager: NSObject, @unchecked Sendable { @objc private func dockPreferenceChanged(_ notification: Notification) { guard let userDefaults = notification.object as? UserDefaults, - userDefaults == UserDefaults.standard + userDefaults == AppDefaults.standard else { return } Task { @MainActor in diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift index 0807f4941d4e..c5018e7f2883 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift @@ -146,7 +146,34 @@ enum ExecApprovalsStore { } static func socketPath() -> String { - self.stateDirURL().appendingPathComponent("exec-approvals.sock").path + self.socketPath( + stateDirectoryURL: self.stateDirURL(), + profileActive: AppProfile.current.isActive) + } + + static func socketPath(stateDirectoryURL: URL, profileActive: Bool) -> String { + let canonical = stateDirectoryURL.appendingPathComponent("exec-approvals.sock").path + let maximumLength = MemoryLayout.size(ofValue: sockaddr_un().sun_path) + guard canonical.utf8.count >= maximumLength, profileActive else { + return canonical + } + let digest = SHA256.hash(data: Data(canonical.utf8)) + .prefix(8) + .map { String(format: "%02x", $0) } + .joined() + return "/tmp/openclaw-\(geteuid())/exec-approvals-\(digest).sock" + } + + static func resolvedPersistedSocketPath( + existing: String?, + stateDirectoryURL: URL, + computed: String) -> String + { + let existing = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let oldCanonical = stateDirectoryURL.appendingPathComponent("exec-approvals.sock").path + return existing.isEmpty || (existing == oldCanonical && computed != oldCanonical) + ? computed + : existing } private static func homeURL() -> URL { @@ -163,7 +190,7 @@ enum ExecApprovalsStore { return scopedStateDirectoryURL } guard let configured = OpenClawEnv.path("OPENCLAW_STATE_DIR") else { - return self.homeURL().appendingPathComponent(".openclaw", isDirectory: true) + return AppProfile.current.stateDirectoryURL(homeDirectory: self.homeURL()) } let home = self.homeURL().path let expanded: String = if configured == "~" { @@ -293,10 +320,12 @@ enum ExecApprovalsStore { if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) } - let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if path.isEmpty { - file.socket?.path = self.socketPath() - } + let existingSocketPath = file.socket?.path + let resolvedSocketPath = self.resolvedPersistedSocketPath( + existing: existingSocketPath, + stateDirectoryURL: self.stateDirURL(), + computed: self.socketPath()) + file.socket?.path = resolvedSocketPath let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if token.isEmpty { file.socket?.token = self.generateToken() diff --git a/apps/macos/Sources/OpenClaw/GatewayConnectionPresentation.swift b/apps/macos/Sources/OpenClaw/GatewayConnectionPresentation.swift index fca0dcd9c787..44fc1f2d8ccc 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnectionPresentation.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnectionPresentation.swift @@ -66,8 +66,17 @@ struct GeneralStatusPresentation: Equatable { static func resolve( mode: AppState.ConnectionMode, isPaused: Bool, - controlState: ControlChannel.ConnectionState) -> Self + controlState: ControlChannel.ConnectionState, + localFailure: String? = nil) -> Self { + if mode == .local, let localFailure { + return Self( + title: String(localized: "OpenClaw needs attention"), + subtitle: localFailure, + symbolName: "exclamationmark.triangle.fill", + tone: .attention, + showsConnectionAction: true) + } if isPaused { return Self( title: String(localized: "OpenClaw paused"), diff --git a/apps/macos/Sources/OpenClaw/GatewayConnectionSupport.swift b/apps/macos/Sources/OpenClaw/GatewayConnectionSupport.swift index 28c1bc342042..886c347da122 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnectionSupport.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnectionSupport.swift @@ -16,10 +16,14 @@ enum GatewayActivationBindingKeyStore { // keychain password on every read. DEBUG is a config heuristic, not a // signing check — same accepted tradeoff as MacGatewayProfileStore.service. #if DEBUG - private static let service = "ai.openclaw.onboarding-route-binding.debug" + private static let baseService = "ai.openclaw.onboarding-route-binding.debug" #else - private static let service = "ai.openclaw.onboarding-route-binding" + private static let baseService = "ai.openclaw.onboarding-route-binding" #endif + static var service: String { + AppProfile.current.keychainService(base: self.baseService) + } + private static let account = "credential-binding-v1" private static let byteCount = 32 diff --git a/apps/macos/Sources/OpenClaw/GatewayDiscoveryPreferences.swift b/apps/macos/Sources/OpenClaw/GatewayDiscoveryPreferences.swift index bc39714a1b3d..48e09c35ac05 100644 --- a/apps/macos/Sources/OpenClaw/GatewayDiscoveryPreferences.swift +++ b/apps/macos/Sources/OpenClaw/GatewayDiscoveryPreferences.swift @@ -6,7 +6,7 @@ enum GatewayDiscoveryPreferences { private static let preferredRouteBindingKey = "gateway.preferredStableIDRouteBinding.v1" static func preferredStableID() -> String? { - let defaults = UserDefaults.standard + let defaults = AppDefaults.standard let raw = defaults.string(forKey: self.preferredStableIDKey) ?? defaults.string(forKey: self.legacyPreferredStableIDKey) let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -16,19 +16,19 @@ enum GatewayDiscoveryPreferences { static func setPreferredStableID(_ stableID: String?) { // A caller without an endpoint binding cannot prove that a prior binding // belongs to this id. The bound overload installs a fresh one below. - UserDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey) + AppDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey) let trimmed = stableID?.trimmingCharacters(in: .whitespacesAndNewlines) if let trimmed, !trimmed.isEmpty { - UserDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey) - UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey) + AppDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey) + AppDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey) } else { - UserDefaults.standard.removeObject(forKey: self.preferredStableIDKey) - UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey) + AppDefaults.standard.removeObject(forKey: self.preferredStableIDKey) + AppDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey) } } static func preferredRouteBinding() -> String? { - let raw = UserDefaults.standard.string(forKey: self.preferredRouteBindingKey) + let raw = AppDefaults.standard.string(forKey: self.preferredRouteBindingKey) let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed?.isEmpty == false ? trimmed : nil } @@ -38,10 +38,10 @@ enum GatewayDiscoveryPreferences { guard self.preferredStableID() != nil, let routeBinding = self.normalized(routeBinding) else { - UserDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey) + AppDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey) return } - UserDefaults.standard.set(routeBinding, forKey: self.preferredRouteBindingKey) + AppDefaults.standard.set(routeBinding, forKey: self.preferredRouteBindingKey) } /// Discovery ids name one concrete Gateway. Persist the non-secret fallback @@ -97,7 +97,7 @@ enum GatewayDiscoveryPreferences { @discardableResult static func clearPreferredStableIDIfRouteBindingMismatch(_ currentRouteBinding: String?) -> Bool { guard self.preferredStableID() != nil else { - UserDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey) + AppDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey) return false } guard let stored = self.preferredRouteBinding(), diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift index cef648256118..1626f5e3afc8 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift @@ -86,6 +86,7 @@ actor GatewayEndpointStore { let token: @Sendable () -> String? let password: @Sendable () -> String? let localPort: @Sendable () -> Int + let localUnavailableReason: @Sendable () -> String? let remoteRouteIfRunning: @Sendable () async -> RemoteTunnelManager.Route? let remoteRouteIsCurrent: @Sendable (RemoteTunnelManager.Route) async -> Bool let canStartRemoteTunnel: @Sendable () -> Bool @@ -113,6 +114,7 @@ actor GatewayEndpointStore { launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) }, localPort: { GatewayEnvironment.gatewayPort() }, + localUnavailableReason: { GatewayEnvironment.profileGatewayPortConflict() }, remoteRouteIfRunning: { await RemoteTunnelManager.shared.controlTunnelRouteIfRunning() }, remoteRouteIsCurrent: { await RemoteTunnelManager.shared.isCurrentRoute($0) }, canStartRemoteTunnel: { GatewayEndpointStore.primaryAppLaunchAdmitted.withValue { $0 } }, @@ -364,19 +366,21 @@ actor GatewayEndpointStore { private var endpointRevision: UInt64 = 0 private var resolutionGeneration: UInt64 = 0 private var activeSource: SourceSnapshot? + private var localUnavailableReason: String? init(deps: Deps = .live) { self.deps = deps - let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey) + let modeRaw = AppDefaults.standard.string(forKey: connectionModeKey) let initialMode: AppState.ConnectionMode if let modeRaw { initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local } else { - let seen = UserDefaults.standard.bool(forKey: "openclaw.onboardingSeen") + let seen = AppDefaults.standard.bool(forKey: "openclaw.onboardingSeen") initialMode = seen ? .local : .unconfigured } let port = deps.localPort() + self.localUnavailableReason = deps.localUnavailableReason() let root = OpenClawConfigFile.loadDict() let bind = GatewayEndpointStore.resolveGatewayBindMode( root: root, @@ -398,6 +402,10 @@ actor GatewayEndpointStore { remoteTarget: "") switch initialMode { case .local: + if let reason = self.localUnavailableReason { + self.state = .unavailable(mode: .local, reason: reason) + return + } let url = URL(string: "\(scheme)://\(host):\(port)")! self.endpointRevision = 1 self.state = .ready( @@ -440,6 +448,13 @@ actor GatewayEndpointStore { self.state } + func setLocalUnavailableReason(_ reason: String?) { + self.localUnavailableReason = reason + if let reason { + self.setState(.unavailable(mode: .local, reason: reason)) + } + } + func refresh() async { _ = await self.refreshIfCurrent() } @@ -506,6 +521,10 @@ actor GatewayEndpointStore { case .local: self.cancelRemoteEnsure() guard await self.sourceIsCurrent(source, generation: generation) else { return } + if let reason = self.localUnavailableReason { + self.setState(.unavailable(mode: .local, reason: reason)) + return + } let url = URL(string: "\(source.scheme)://\(source.localHost):\(source.localPort)")! self.publishReadyEndpoint(source: source, url: url) case .remote: @@ -914,6 +933,7 @@ extension GatewayEndpointStore { generationIsCurrent: { generation in AppStateStore.shared.gatewayRoutingGeneration == generation }, + profile: .current, beforeConfigRead: {}) } @@ -933,6 +953,7 @@ extension GatewayEndpointStore { private static func liveSourceSnapshot( appSnapshot: @escaping @MainActor @Sendable () -> LiveAppSnapshot, generationIsCurrent: @escaping @MainActor @Sendable (UInt64) -> Bool, + profile: AppProfile, beforeConfigRead: @escaping @Sendable () async -> Void) async -> SourceSnapshot { // Capture MainActor-owned selection facts before reading config. The @@ -994,7 +1015,7 @@ extension GatewayEndpointStore { env: env, launchdSnapshot: launchdSnapshot), deviceAuthGatewayID: deviceAuthGatewayID, - localPort: self.resolveGatewayPort(root: root, env: env), + localPort: self.resolveGatewayPort(root: root, env: env, profile: profile), localHost: self.resolveLocalGatewayHost( bindMode: bindMode, customBindHost: customBindHost, @@ -1039,16 +1060,11 @@ extension GatewayEndpointStore { private static func resolveGatewayPort( root: [String: Any], env: [String: String], - defaults: UserDefaults = .standard) -> Int + defaults: UserDefaults = AppDefaults.standard, + profile: AppProfile) -> Int { - if let raw = env["OPENCLAW_GATEWAY_PORT"], - let port = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)), - port > 0 - { - return port - } - if let gateway = root["gateway"] as? [String: Any] { - let port: Int? = switch gateway["port"] { + let configPort: Int? = if let gateway = root["gateway"] as? [String: Any] { + switch gateway["port"] { case let value as Int: value case let value as NSNumber: @@ -1058,12 +1074,14 @@ extension GatewayEndpointStore { default: nil } - if let port, port > 0 { - return port - } + } else { + nil } - let stored = defaults.integer(forKey: "gatewayPort") - return stored > 0 ? stored : 18789 + return GatewayEnvironment.resolvedGatewayPort( + environment: env, + configPort: configPort, + storedPort: defaults.integer(forKey: "gatewayPort"), + profile: profile) } private static func resolveGatewayBindMode( @@ -1249,6 +1267,7 @@ extension GatewayEndpointStore { @MainActor static func _testLiveSourceSnapshot( state: AppState, + profile: AppProfile = .current, beforeConfigRead: @escaping @Sendable () async -> Void) async -> SourceSnapshot { await self.liveSourceSnapshot( @@ -1262,6 +1281,7 @@ extension GatewayEndpointStore { generationIsCurrent: { generation in state.gatewayRoutingGeneration == generation }, + profile: profile, beforeConfigRead: beforeConfigRead) } diff --git a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift index 75185b088fcd..ed4fbec92621 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift @@ -108,17 +108,42 @@ enum GatewayEnvironment { private static let logger = Logger(subsystem: "ai.openclaw", category: "gateway.env") private static let supportedBindModes: Set = ["loopback", "tailnet", "lan", "auto"] + private static let profilePortReservation: ProfileGatewayPortReservation = .acquire( + profile: .current, + port: GatewayEnvironment.selectedGatewayPort()) static func gatewayPort() -> Int { - if let raw = ProcessInfo.processInfo.environment["OPENCLAW_GATEWAY_PORT"] { + guard AppProfile.current.isActive else { return self.selectedGatewayPort() } + return self.profilePortReservation.port + } + + static func profileGatewayPortConflict() -> String? { + guard AppProfile.current.isActive else { return nil } + return self.profilePortReservation.conflict + } + + private static func selectedGatewayPort() -> Int { + self.resolvedGatewayPort( + environment: ProcessInfo.processInfo.environment, + configPort: OpenClawConfigFile.gatewayPort(), + storedPort: AppDefaults.standard.integer(forKey: "gatewayPort"), + profile: .current) + } + + static func resolvedGatewayPort( + environment: [String: String], + configPort: Int?, + storedPort: Int, + profile: AppProfile) -> Int + { + if let raw = environment["OPENCLAW_GATEWAY_PORT"] { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if let parsed = Int(trimmed), parsed > 0 { return parsed } } - if let configPort = OpenClawConfigFile.gatewayPort(), configPort > 0 { + if let configPort, configPort > 0 { return configPort } - let stored = UserDefaults.standard.integer(forKey: "gatewayPort") - return stored > 0 ? stored : 18789 + return storedPort > 0 ? storedPort : profile.defaultGatewayPort } static func expectedGatewayVersion() -> Semver? { @@ -238,6 +263,7 @@ enum GatewayEnvironment { } static func resolveGatewayCommand( + profile: AppProfile = .current, searchPathsProvider: @Sendable () async -> [String] = CommandResolver.preferredPathsAsync) async -> GatewayCommandResolution { @@ -261,16 +287,26 @@ enum GatewayEnvironment { let bind = self.preferredGatewayBind() ?? "loopback" switch environment.commandSource { case let .executable(gatewayBin): - let cmd = [gatewayBin, "gateway", "--port", "\(port)", "--bind", bind] + let cmd = self.gatewayCommand(prefix: [gatewayBin], port: port, bind: bind, profile: profile) return GatewayCommandResolution(status: environment.status, command: cmd) case let .project(runtime, entrypoint): - let cmd = [runtime.path, entrypoint, "gateway", "--port", "\(port)", "--bind", bind] + let cmd = self.gatewayCommand( + prefix: [runtime.path, entrypoint], + port: port, + bind: bind, + profile: profile) return GatewayCommandResolution(status: environment.status, command: cmd) case nil: return GatewayCommandResolution(status: environment.status, command: nil) } } + static func gatewayCommand(prefix: [String], port: Int, bind: String, profile: AppProfile) -> [String] { + profile.localCLICommand( + prefix: prefix, + arguments: ["gateway", "--port", "\(port)", "--bind", bind]) + } + private static func preferredGatewayBind() -> String? { if CommandResolver.connectionModeIsRemote() { return nil diff --git a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift index 7bff6a706f76..77eaee70d127 100644 --- a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift @@ -7,7 +7,7 @@ enum GatewayLaunchAgentManager { } private static let logger = Logger(subsystem: "ai.openclaw", category: "gateway.launchd") - private static let disableLaunchAgentMarker = ".openclaw/disable-launchagent" + private static let disableLaunchAgentMarker = "disable-launchagent" /// A first-run daemon command may wait behind state integrity checks and the shared startup- /// migration lease. Keep the app from killing healthy migration work before it can finish. static let startupMigrationTolerance: TimeInterval = 120 @@ -18,13 +18,75 @@ enum GatewayLaunchAgentManager { return testingDisableLaunchAgentMarkerURL } #endif - return FileManager().homeDirectoryForCurrentUser - .appendingPathComponent(self.disableLaunchAgentMarker) + let root = AppProfile.current.isActive + ? OpenClawPaths.stateDirURL + : FileManager().homeDirectoryForCurrentUser.appendingPathComponent(".openclaw", isDirectory: true) + return root.appendingPathComponent(self.disableLaunchAgentMarker) } private static var plistURL: URL { - FileManager().homeDirectoryForCurrentUser - .appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist") + self.plistURL( + homeDirectory: FileManager().homeDirectoryForCurrentUser, + profile: .current) + } + + static func plistURL(homeDirectory: URL, profile: AppProfile) -> URL { + homeDirectory.appendingPathComponent( + "Library/LaunchAgents/\(profile.gatewayLaunchAgentLabel).plist") + } + + static func conflictingProfileClaimOwner( + port: Int, + excludingLabel: String, + homeDirectory: URL) -> String? + { + let directory = homeDirectory.appendingPathComponent("Library/LaunchAgents", isDirectory: true) + guard FileManager.default.fileExists(atPath: directory.path) else { return nil } + guard let entries = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil) + else { + return "installed profile Gateway claims cannot be inspected" + } + for url in entries { + guard url.pathExtension == "plist" else { continue } + let label = url.deletingPathExtension().lastPathComponent + guard label != excludingLabel, + let profile = self.profile(forLaunchAgentLabel: label) + else { continue } + let owner = profile.name ?? "default" + let artifacts = self.generatedEnvironmentArtifacts( + directory: profile.stateDirectoryURL(homeDirectory: homeDirectory) + .appendingPathComponent("service-env", isDirectory: true), + profile: profile) + guard let snapshot = LaunchAgentPlist.snapshot( + url: url, + generatedEnvironmentFileURL: artifacts.environment, + generatedEnvironmentWrapperURL: artifacts.wrapper), + self.isCanonicalGatewayClaim(snapshot) + else { continue } + guard let claimedPort = snapshot.port else { + return "profile \"\(owner)\" has an unreadable Gateway reservation" + } + if claimedPort == port { return "profile \"\(owner)\" already reserves it" } + } + return nil + } + + private static func profile(forLaunchAgentLabel label: String) -> AppProfile? { + let base = AppProfile(environment: [:]) + if label == base.gatewayLaunchAgentLabel { return base } + let prefix = "ai.openclaw." + guard label.hasPrefix(prefix) else { return nil } + let name = String(label.dropFirst(prefix.count)) + let profile = AppProfile(environment: ["OPENCLAW_PROFILE": name]) + return profile.name == name && profile.gatewayLaunchAgentLabel == label ? profile : nil + } + + private static func isCanonicalGatewayClaim(_ snapshot: LaunchAgentPlistSnapshot) -> Bool { + snapshot.environment["OPENCLAW_SERVICE_MARKER"] == "openclaw" && + snapshot.environment["OPENCLAW_SERVICE_KIND"] == "gateway" && + snapshot.programArguments.contains("gateway") } private static var generatedEnvironmentDirectoryURL: URL { @@ -138,11 +200,20 @@ enum GatewayLaunchAgentManager { static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? { let directory = self.generatedEnvironmentDirectoryURL + let artifacts = self.generatedEnvironmentArtifacts(directory: directory, profile: .current) return LaunchAgentPlist.snapshot( url: self.plistURL, - generatedEnvironmentFileURL: directory.appendingPathComponent("\(gatewayLaunchdLabel).env"), - generatedEnvironmentWrapperURL: directory.appendingPathComponent( - "\(gatewayLaunchdLabel)-env-wrapper.sh")) + generatedEnvironmentFileURL: artifacts.environment, + generatedEnvironmentWrapperURL: artifacts.wrapper) + } + + static func generatedEnvironmentArtifacts( + directory: URL, + profile: AppProfile) -> (environment: URL, wrapper: URL) + { + ( + directory.appendingPathComponent("\(profile.gatewayLaunchAgentLabel).env"), + directory.appendingPathComponent("\(profile.gatewayLaunchAgentLabel)-env-wrapper.sh")) } /// Empty means no Gateway LaunchAgent. Nil preserves an unreadable diff --git a/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift b/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift index 3047444c9240..be90481b2515 100644 --- a/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift +++ b/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift @@ -113,6 +113,7 @@ final class GatewayProcessManager { private var launchAgentReadinessRevision: UInt64 = 0 private var launchAgentInstallGeneration: UInt64? private var launchAgentFreshInstallGeneration: UInt64? + private var profilePortConflict: String? private var lastObservedGatewayPID: Int32? /// Async readiness audits may outlive stop/restart. Only the current generation may publish /// their failure state or retain a PID for a later repair. @@ -146,6 +147,16 @@ final class GatewayProcessManager { self.logger.info("gateway process skipped: remote mode active") return } + if active, self.profilePortConflict != nil { + self.profilePortConflict = nil + Task { await GatewayEndpointStore.shared.setLocalUnavailableReason(nil) } + } + if active, let conflict = GatewayEnvironment.profileGatewayPortConflict() { + self.desiredActive = false + self.recordProfilePortConflict(conflict) + Task { await GatewayEndpointStore.shared.setLocalUnavailableReason(conflict) } + return + } self.logger.debug("gateway active requested active=\(active)") self.desiredActive = active self.refreshEnvironmentStatus() @@ -159,6 +170,7 @@ final class GatewayProcessManager { func ensureLaunchAgentEnabledIfNeeded() async -> Bool { guard !CommandResolver.connectionModeIsRemote() else { return false } guard self.desiredActive else { return false } + guard self.profilePortConflict == nil else { return false } if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() { self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n") self.logger.info("gateway launchd auto-enable skipped (disable marker set)") @@ -522,6 +534,11 @@ final class GatewayProcessManager { guard self.isCurrentGatewayStart(startGeneration) else { return true } let instanceText = instance.map { self.describe(instance: $0) } let hasListener = instance != nil + if hasListener, + await !(self.profileOwnsGateway(instance, port: port)) + { + return true + } let attemptAttach = { try await self.probeGatewayHealth(timeoutMs: 2000) @@ -534,6 +551,9 @@ final class GatewayProcessManager { guard self.isCurrentGatewayStart(startGeneration) else { return true } let attachedInstance = await PortGuardian.shared.describe(port: port) guard self.isCurrentGatewayStart(startGeneration) else { return true } + if await !(self.profileOwnsGateway(attachedInstance, port: port)) { + return true + } let snap = decodeHealthSnapshot(from: data) let attachedInstanceText = attachedInstance.map { self.describe(instance: $0) } let details = self.describe(details: attachedInstanceText, port: port, snap: snap) @@ -581,6 +601,45 @@ final class GatewayProcessManager { return false } + static func profileAllowsExistingGatewayAttachment( + profile: AppProfile, + listenerPID: Int32?, + managedServicePID: Int32?) -> Bool + { + guard profile.isActive else { return true } + guard let listenerPID, let managedServicePID else { return false } + return listenerPID == managedServicePID + } + + private func profileOwnsGateway(_ instance: PortGuardian.Descriptor?, port: Int) async -> Bool { + guard AppProfile.current.isActive else { return true } + let managedPID = await GatewayLaunchAgentManager.runningGatewayPID() + guard Self.profileAllowsExistingGatewayAttachment( + profile: .current, + listenerPID: instance?.pid, + managedServicePID: managedPID) + else { + await self.failProfilePortOwnership(port: port) + return false + } + return true + } + + private func failProfilePortOwnership(port: Int) async { + let message = "Gateway port \(port) is already owned by another process or OpenClaw profile. " + + "Set gateway.port to a free port for profile \(AppProfile.current.name ?? "named")." + self.recordProfilePortConflict(message) + await GatewayEndpointStore.shared.setLocalUnavailableReason(message) + } + + private func recordProfilePortConflict(_ message: String) { + self.profilePortConflict = message + self.status = .failed(message) + self.lastFailureReason = message + self.appendLog("[gateway] \(message)\n") + self.logger.error("\(message, privacy: .public)") + } + private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String { let instanceText = instance ?? "pid unknown" if let snap { @@ -737,6 +796,7 @@ extension GatewayProcessManager { _ = try await self.probeGatewayHealth(timeoutMs: min(1500, remainingMs)) guard !Task.isCancelled else { return } let instance = await PortGuardian.shared.describe(port: context.port) + guard await self.profileOwnsGateway(instance, port: context.port) else { return } guard self.publishLaunchdGatewayReady( instance: instance, context: context, @@ -1000,6 +1060,7 @@ extension GatewayProcessManager { _ = try await self.probeGatewayHealth(timeoutMs: min(1500, remainingMs)) guard !Task.isCancelled else { return false } let instance = await PortGuardian.shared.describe(port: readinessPort) + guard await self.profileOwnsGateway(instance, port: readinessPort) else { return false } return self.publishGatewayReadinessSuccess( instance: instance, startGeneration: startGeneration, diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index b0312b708181..daef720afec6 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -88,11 +88,11 @@ struct GeneralSettings: View { SettingsCardGroup("App") { SettingsCardToggleRow( title: "Launch at login", - subtitle: self.state.bundleLocationAllowsPersistentIntegration - ? "Automatically start OpenClaw after you sign in." - : "Move OpenClaw to Applications before enabling launch at login.", + subtitle: .verbatim( + self + .launchAtLoginPresentation.subtitle), binding: self.$state.launchAtLogin) - .disabled(!self.state.bundleLocationAllowsPersistentIntegration && !self.state.launchAtLogin) + .disabled(self.launchAtLoginPresentation.isDisabled) SettingsCardToggleRow( title: "Show Dock icon", @@ -221,7 +221,8 @@ struct GeneralSettings: View { let presentation = GeneralStatusPresentation.resolve( mode: self.state.connectionMode, isPaused: self.state.isPaused, - controlState: ControlChannel.shared.state) + controlState: ControlChannel.shared.state, + localFailure: self.gatewayManager.lastFailureReason) return HStack(alignment: .center, spacing: 14) { ZStack { @@ -371,6 +372,7 @@ struct GeneralSettings: View { Spacer(minLength: 18) if ControlChannel.shared.state == .connected, + self.localGatewayFailure == nil, let ping = ControlChannel.shared.lastPingMs { Text("\(Int(ping)) ms") @@ -399,9 +401,10 @@ struct GeneralSettings: View { } private var connectionStatusTint: Color { + if self.localGatewayFailure != nil { return .red } switch ControlChannel.shared.state { - case .connected: .green - case .connecting, .disconnected, .degraded: .orange + case .connected: return .green + case .connecting, .disconnected, .degraded: return .orange } } @@ -414,6 +417,7 @@ struct GeneralSettings: View { } private var connectionStatusSubtitle: String { + if let failure = self.localGatewayFailure { return failure } switch self.state.connectionMode { case .local: return "OpenClaw starts and monitors the Gateway on this Mac." @@ -429,6 +433,10 @@ struct GeneralSettings: View { } } + private var localGatewayFailure: String? { + self.state.connectionMode == .local ? self.gatewayManager.lastFailureReason : nil + } + private var gatewayModeGroup: some View { SettingsCardGroup("Gateway") { SettingsCardRow( @@ -805,10 +813,11 @@ struct GeneralSettings: View { } private var gatewayStatusColor: Color { + if self.localGatewayFailure != nil { return .red } switch self.gatewayStatus.kind { - case .ok: .green - case .checking: .secondary - case .missingNode, .missingGateway, .incompatible, .error: .orange + case .ok: return .green + case .checking: return .secondary + case .missingNode, .missingGateway, .incompatible, .error: return .orange } } } @@ -887,12 +896,41 @@ extension GeneralSettings { alert.runModal() } + private var launchAtLoginPresentation: LaunchAtLoginPresentation { + .resolve( + profile: .current, + bundleLocationAllowsPersistentIntegration: self.state.bundleLocationAllowsPersistentIntegration, + isEnabled: self.state.launchAtLogin) + } + private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state) MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID, state: self.state) } } +struct LaunchAtLoginPresentation: Equatable { + let subtitle: String + let isDisabled: Bool + + static func resolve( + profile: AppProfile, + bundleLocationAllowsPersistentIntegration: Bool, + isEnabled: Bool) -> Self + { + if profile.isActive { + return Self( + subtitle: String(localized: "Launch at login is unavailable while an app profile is active."), + isDisabled: true) + } + return Self( + subtitle: bundleLocationAllowsPersistentIntegration + ? String(localized: "Automatically start OpenClaw after you sign in.") + : String(localized: "Move OpenClaw to Applications before enabling launch at login."), + isDisabled: !bundleLocationAllowsPersistentIntegration && !isEnabled) + } +} + #if DEBUG struct GeneralSettings_Previews: PreviewProvider { static var previews: some View { diff --git a/apps/macos/Sources/OpenClaw/LaunchAgentManager.swift b/apps/macos/Sources/OpenClaw/LaunchAgentManager.swift index 903a062323c0..1c778b2c69c1 100644 --- a/apps/macos/Sources/OpenClaw/LaunchAgentManager.swift +++ b/apps/macos/Sources/OpenClaw/LaunchAgentManager.swift @@ -1,12 +1,18 @@ import Foundation +import OSLog enum LaunchAgentManager { + private static let logger = Logger(subsystem: "ai.openclaw", category: "app.login-agent") private static var plistURL: URL { FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Library/LaunchAgents/ai.openclaw.mac.plist") } - static func status() async -> Bool { + static func status(profile: AppProfile = .current) async -> Bool { + if profile.isActive { + self.logger.info("login-agent status skipped (unavailable under app profile)") + return false + } guard FileManager().fileExists(atPath: self.plistURL.path) else { return false } return await self.isLoaded() } @@ -20,9 +26,14 @@ enum LaunchAgentManager { static func set( enabled: Bool, bundlePath: String, + profile: AppProfile = .current, loaded: Bool? = nil, writePlist: ((String) -> Void)? = nil) async -> Bool { + if profile.isActive { + self.logger.info("login-agent change skipped (unavailable under app profile)") + return false + } if enabled { let persist = writePlist ?? { self.writePlist(bundlePath: $0) } persist(bundlePath) @@ -107,6 +118,9 @@ enum LaunchAgentManager { @discardableResult private static func runLaunchctl(_ args: [String]) async -> Int32 { + #if DEBUG + self.testingLaunchctlCalls.append(args) + #endif do { return try await BoundedProcess.run( path: "/bin/launchctl", @@ -117,3 +131,17 @@ enum LaunchAgentManager { } } } + +#if DEBUG +extension LaunchAgentManager { + private nonisolated(unsafe) static var testingLaunchctlCalls: [[String]] = [] + + static func _testResetLaunchctlCalls() { + self.testingLaunchctlCalls = [] + } + + static func _testLaunchctlCallSnapshot() -> [[String]] { + self.testingLaunchctlCalls + } +} +#endif diff --git a/apps/macos/Sources/OpenClaw/LaunchdManager.swift b/apps/macos/Sources/OpenClaw/LaunchdManager.swift index 59cd63aebc5e..b66526f53257 100644 --- a/apps/macos/Sources/OpenClaw/LaunchdManager.swift +++ b/apps/macos/Sources/OpenClaw/LaunchdManager.swift @@ -1,6 +1,8 @@ import Foundation +import OSLog enum LaunchdManager { + private static let logger = Logger(subsystem: "ai.openclaw", category: "app.login-agent") private static func runLaunchctl(_ args: [String]) { let process = Process() process.launchPath = "/bin/launchctl" @@ -9,6 +11,10 @@ enum LaunchdManager { } static func startOpenClaw() { + guard !AppProfile.current.isActive else { + self.logger.info("login-agent restart skipped (unavailable under app profile)") + return + } let userTarget = "gui/\(getuid())/\(launchdLabel)" self.runLaunchctl(["kickstart", "-k", userTarget]) } diff --git a/apps/macos/Sources/OpenClaw/LogLocator.swift b/apps/macos/Sources/OpenClaw/LogLocator.swift index b504ab02acec..0aa4767b1030 100644 --- a/apps/macos/Sources/OpenClaw/LogLocator.swift +++ b/apps/macos/Sources/OpenClaw/LogLocator.swift @@ -15,7 +15,8 @@ enum LogLocator { } private static var gatewayLog: URL { - logDir.appendingPathComponent("openclaw-gateway.log") + let suffix = AppProfile.current.name.map { "-\($0)" } ?? "" + return logDir.appendingPathComponent("openclaw-gateway\(suffix).log") } private static func ensureLogDirExists() { diff --git a/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift b/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift index 7c2bb2c723e1..54bc6b275bdc 100644 --- a/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift +++ b/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift @@ -9,7 +9,7 @@ enum AppLogSettings { static let logLevelKey = appLogLevelKey static func logLevel() -> Logger.Level { - if let raw = UserDefaults.standard.string(forKey: self.logLevelKey), + if let raw = AppDefaults.standard.string(forKey: self.logLevelKey), let level = Logger.Level(rawValue: raw) { return level @@ -18,11 +18,11 @@ enum AppLogSettings { } static func setLogLevel(_ level: Logger.Level) { - UserDefaults.standard.set(level.rawValue, forKey: self.logLevelKey) + AppDefaults.standard.set(level.rawValue, forKey: self.logLevelKey) } static func fileLoggingEnabled() -> Bool { - UserDefaults.standard.bool(forKey: debugFileLogEnabledKey) + AppDefaults.standard.bool(forKey: debugFileLogEnabledKey) } } diff --git a/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift b/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift index e16f841ba000..3d47f97abe25 100644 --- a/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift +++ b/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift @@ -62,10 +62,14 @@ actor MacGatewayProfileStore { // poisoning path. Release-config ad-hoc builds stay out of scope; running // those against saved Keychain items is already unsupported. #if DEBUG - private static let service = "ai.openclaw.gateway-profiles.debug" + private static let baseService = "ai.openclaw.gateway-profiles.debug" #else - private static let service = "ai.openclaw.gateway-profiles" + private static let baseService = "ai.openclaw.gateway-profiles" #endif + static var service: String { + AppProfile.current.keychainService(base: self.baseService) + } + private static let registryAccount = "registry-v1" private static let currentLegacyPrimaryMigrationVersion = 1 diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 3fe033155b9c..52d283b8c721 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -1,8 +1,10 @@ import AppKit import Darwin +import Dispatch import Foundation import MenuBarExtraAccess import Observation +import OpenClawKit import OSLog import Security import SwiftUI @@ -13,14 +15,25 @@ struct OpenClawApp: App { @Environment(\.openWindow) private var openWindow @State private var state: AppState private static let logger = Logger(subsystem: "ai.openclaw", category: "app") - private let gatewayManager = GatewayProcessManager.shared - private let controlChannel = ControlChannel.shared - private let activityStore = WorkActivityStore.shared + private var gatewayManager: GatewayProcessManager { + .shared + } + + private var controlChannel: ControlChannel { + .shared + } + + private var activityStore: WorkActivityStore { + .shared + } + @State private var statusItem: NSStatusItem? @State private var statusItemMouseRouter = StatusItemMouseRouter() @State private var isMenuPresented = false @State private var isChatWindowVisible = false - @State private var tailscaleService = TailscaleService.shared + private var tailscaleService: TailscaleService { + .shared + } @MainActor private func updateStatusHighlight() { @@ -28,6 +41,22 @@ struct OpenClawApp: App { } init() { + if let error = AppProfile.current.validationError { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "OpenClaw profile is invalid" + alert.informativeText = error.localizedDescription + alert.runModal() + Darwin.exit(2) + } + if AppProfile.current.isActive, + !DeviceIdentityStore.configureStateDirectory(OpenClawPaths.stateDirURL) + { + fatalError("Device identity state root was already used before app profile configuration") + } + guard GatewayTLSStore.configureKeychainServiceSuffix(AppProfile.current.keychainServiceSuffix) else { + fatalError("Gateway TLS Keychain namespace was already used by another app profile") + } OpenClawLogging.bootstrapIfNeeded() Self.applyAttachOnlyOverrideIfNeeded() @@ -366,11 +395,11 @@ private struct SettingsWindowOpenRegistrar: View { @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { - private static let dashboardURL = URL(string: "openclaw://dashboard")! private var state: AppState? private var terminationCleanupTask: Task? private var terminationDeadlineTask: Task? private var terminationCleanupFinished = false + private var profileInstanceLock: AppInstanceLock? private let webChatAutoLogger = Logger(subsystem: "ai.openclaw", category: "Chat") var nodeTerminationCleanup: @MainActor () async -> Void = { await TalkMLXSpeechSynthesizer.shared.shutdown() @@ -386,7 +415,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } var openDashboardAction: @MainActor () -> Void = { AppNavigationActions.openDashboard() } - let updaterController: UpdaterProviding = makeUpdaterController() + let updaterController: UpdaterProviding + + override init() { + let environment = ProcessInfo.processInfo.environment + let hasReplacementMetadata = ApplicationRelocator.hasReplacementHandoffMetadata( + environment: environment) + let isReplacementHandoff = hasReplacementMetadata && + ApplicationRelocator.acceptReplacementHandoff(environment: environment) + if hasReplacementMetadata, !isReplacementHandoff { + fputs("OpenClaw replacement handoff authentication failed.\n", stderr) + Darwin.exit(2) + } + let ownership = AppInstanceLock.acquire( + url: AppProfile.current.instanceLockURL(), + waitMilliseconds: isReplacementHandoff ? 5000 : 0) + if let exitCode = Self.processExitCode(for: ownership) { + fputs("OpenClaw profile is already running.\n", stderr) + Darwin.exit(exitCode) + } + var profileInstanceLock: AppInstanceLock? + var instanceOwnershipFailure: String? + switch ownership { + case let .acquired(lock): + profileInstanceLock = lock + case .busy: + break + case let .failed(message): + instanceOwnershipFailure = message + } + self.profileInstanceLock = profileInstanceLock + self.updaterController = instanceOwnershipFailure == nil + ? makeUpdaterController() + : DisabledUpdaterController() + super.init() + if let instanceOwnershipFailure { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "OpenClaw could not claim its instance lock" + alert.informativeText = instanceOwnershipFailure + alert.runModal() + Darwin.exit(2) + } + } + + static func processExitCode(for ownership: AppInstanceLockAcquisition) -> Int32? { + if case .busy = ownership { return 0 } + return nil + } func applicationWillFinishLaunching(_: Notification) { // URL/reopen callbacks can create the dashboard before didFinishLaunching. @@ -471,34 +547,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } #endif - let environment = ProcessInfo.processInfo.environment let launchPolicy = AppLaunchPresentationPolicy.current - let hasReplacementHandoff = ApplicationRelocator.hasReplacementHandoffMetadata( - environment: environment) - let isReplacementHandoff = ApplicationRelocator.acceptReplacementHandoff( - environment: environment) - if hasReplacementHandoff, !isReplacementHandoff { - NSApp.terminate(nil) - return - } - // Only a child whose signed parent and inherited readiness pipe authenticate - // may overlap the old process during replacement handoff. - if !isReplacementHandoff, self.isDuplicateInstance() { - if launchPolicy.allowsAutomaticPresentation { - NSWorkspace.shared.open(Self.dashboardURL) - } - NSApp.terminate(nil) - return - } - switch ApplicationRelocator.handleLaunch() { - case .terminating: - return - case let .continueLaunch(startUpdater): - if startUpdater { - if OpenClawConfigFile.gatewayUpdateChannel() == nil { - self.updaterController.startAfterResolvingGatewayUpdateChannel() - } else { - self.updaterController.start() + if !AppProfile.current.isActive { + switch ApplicationRelocator.handleLaunch() { + case .terminating: + return + case let .continueLaunch(startUpdater): + if startUpdater { + if OpenClawConfigFile.gatewayUpdateChannel() == nil { + self.updaterController.startAfterResolvingGatewayUpdateChannel() + } else { + self.updaterController.start() + } } } } @@ -741,7 +801,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { expectedConnectionMode: AppState.ConnectionMode, expectedRouteIdentity: String?) { - let seenVersion = UserDefaults.standard.integer(forKey: onboardingVersionKey) + let seenVersion = AppDefaults.standard.integer(forKey: onboardingVersionKey) let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen guard shouldShow else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { @@ -756,11 +816,71 @@ final class AppDelegate: NSObject, NSApplicationDelegate { OnboardingController.shared.show() } } +} - private func isDuplicateInstance() -> Bool { - guard let bundleID = Bundle.main.bundleIdentifier else { return false } - let running = NSWorkspace.shared.runningApplications.filter { $0.bundleIdentifier == bundleID } - return running.count > 1 +enum AppInstanceLockAcquisition { + case acquired(AppInstanceLock) + case busy + case failed(String) +} + +final class AppInstanceLock { + /// Keep the descriptor open for the process lifetime. Never unlink the path: + /// another opener could then lock a different inode and admit a duplicate. + private let descriptor: Int32 + + private init(descriptor: Int32) { + self.descriptor = descriptor + } + + static func acquire(url: URL, waitMilliseconds: Int = 0) -> AppInstanceLockAcquisition { + if let error = self.preparePrivateStateRoot(url.deletingLastPathComponent()) { + return .failed(error) + } + let descriptor = Darwin.open(url.path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0o600) + guard descriptor >= 0 else { return .failed(String(cString: strerror(errno))) } + var status = stat() + guard fstat(descriptor, &status) == 0, + status.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG), + status.st_uid == geteuid() + else { + Darwin.close(descriptor) + return .failed("Instance lock is not a safe file owned by the current user.") + } + _ = fchmod(descriptor, 0o600) + let deadline = DispatchTime.now() + .milliseconds(max(0, waitMilliseconds)) + while flock(descriptor, LOCK_EX | LOCK_NB) != 0 { + guard errno == EWOULDBLOCK, DispatchTime.now() < deadline else { + let result: AppInstanceLockAcquisition = errno == EWOULDBLOCK + ? .busy + : .failed(String(cString: strerror(errno))) + Darwin.close(descriptor) + return result + } + usleep(50000) + } + return .acquired(AppInstanceLock(descriptor: descriptor)) + } + + private static func preparePrivateStateRoot(_ root: URL) -> String? { + var status = stat() + if lstat(root.path, &status) != 0 { + guard errno == ENOENT else { return String(cString: strerror(errno)) } + guard mkdir(root.path, 0o700) == 0 else { return String(cString: strerror(errno)) } + guard lstat(root.path, &status) == 0 else { return String(cString: strerror(errno)) } + } + guard status.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR), + status.st_uid == geteuid(), + status.st_mode & 0o777 == 0o700 + else { + return "App profile state directory must be an owner-only 0700 directory." + } + return nil + } + + deinit { + _ = flock(self.descriptor, LOCK_UN) + Darwin.close(self.descriptor) } } @@ -991,11 +1111,14 @@ private func isDeveloperIDSigned(bundleURL: URL) -> Bool { @MainActor private func makeUpdaterController() -> UpdaterProviding { + guard AppProfile.current.validationError == nil, !AppProfile.current.isActive else { + return DisabledUpdaterController() + } let bundleURL = Bundle.main.bundleURL let isBundledApp = bundleURL.pathExtension == "app" guard isBundledApp, isDeveloperIDSigned(bundleURL: bundleURL) else { return DisabledUpdaterController() } - let defaults = UserDefaults.standard + let defaults = AppDefaults.standard let autoUpdateKey = "autoUpdateEnabled" // Default to true; honor the user's last choice otherwise. let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true diff --git a/apps/macos/Sources/OpenClaw/MenuContentView.swift b/apps/macos/Sources/OpenClaw/MenuContentView.swift index c42dce00a059..14996202f234 100644 --- a/apps/macos/Sources/OpenClaw/MenuContentView.swift +++ b/apps/macos/Sources/OpenClaw/MenuContentView.swift @@ -24,9 +24,11 @@ struct MenuContent: View { @State private var micObserver = AudioInputDeviceObserver() @State private var micRefreshTask: Task? @State private var browserControlEnabled = true - @AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false - @AppStorage(appLogLevelKey) private var appLogLevelRaw: String = Logger.Level.info.rawValue - @AppStorage(debugFileLogEnabledKey) private var appFileLoggingEnabled: Bool = false + @AppStorage(cameraEnabledKey, store: AppDefaults.standard) private var cameraEnabled: Bool = false + @AppStorage(appLogLevelKey, store: AppDefaults.standard) + private var appLogLevelRaw: String = Logger.Level.info.rawValue + @AppStorage(debugFileLogEnabledKey, store: AppDefaults.standard) + private var appFileLoggingEnabled: Bool = false init(state: AppState, updater: UpdaterProviding?) { self._state = Bindable(wrappedValue: state) @@ -387,6 +389,11 @@ struct MenuContent: View { } private var healthStatus: (label: String, color: Color) { + if self.state.connectionMode == .local, + let failure = GatewayProcessManager.shared.lastFailureReason + { + return (failure, .red) + } if self.state.connectionMode == .remote { let live = GatewayConnectionPresentation(state: self.controlChannel.state) switch live.tone { diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift index cd9357522751..b2ad15d9e418 100644 --- a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift +++ b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift @@ -216,6 +216,13 @@ extension MenuSessionsInjector { cursor += 1 } + if let notice = self.nodesStore.persistentServiceNotice { + menu.insertItem( + self.makeMessageItem(text: notice, symbolName: "info.circle", width: width), + at: cursor) + cursor += 1 + } + if case .connecting = ControlChannel.shared.state { menu.insertItem( self.makeMessageItem(text: "Connecting…", symbolName: "circle.dashed", width: width), @@ -289,6 +296,7 @@ extension MenuSessionsInjector { return self.sortedNodeEntries().isEmpty || self.nodesStore.lastError?.nonEmpty != nil || self.nodesStore.statusMessage?.nonEmpty != nil + || self.nodesStore.persistentServiceNotice != nil } private func buildContextSubmenu( diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift index c34e86db005e..e862386deb13 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift @@ -65,13 +65,13 @@ final class MacNodeModeCoordinator: NSObject { static let shared = MacNodeModeCoordinator() static var nodeIdentityProfile: GatewayDeviceIdentityProfile { self.resolveNodeIdentityProfile( - defaults: .standard, + defaults: AppDefaults.standard, isExistingInstallation: AppStateStore.shared.onboardingSeen) } static func prepareNodeIdentityProfile(isExistingInstallation: Bool) { _ = self.resolveNodeIdentityProfile( - defaults: .standard, + defaults: AppDefaults.standard, isExistingInstallation: isExistingInstallation) } @@ -166,7 +166,7 @@ final class MacNodeModeCoordinator: NSObject { self.nodeHostWorkerRetryPolicy = nodeHostWorkerRetryPolicy self.refreshEvents = refreshEvents.stream self.refreshContinuation = refreshEvents.continuation - self.lastObservedPaused = initialPaused ?? UserDefaults.standard.bool(forKey: pauseDefaultsKey) + self.lastObservedPaused = initialPaused ?? AppDefaults.standard.bool(forKey: pauseDefaultsKey) self.lastObservedComputerControlEnabled = initialComputerControlEnabled ?? isComputerControlEnabled() super.init() @@ -176,7 +176,7 @@ final class MacNodeModeCoordinator: NSObject { self, selector: #selector(self.refreshNodeConfiguration), name: UserDefaults.didChangeNotification, - object: UserDefaults.standard) + object: AppDefaults.standard) self.notificationCenter.addObserver( self, selector: #selector(self.refreshNodeConfiguration), @@ -273,7 +273,7 @@ final class MacNodeModeCoordinator: NSObject { func refresh() { self.refresh( - isPaused: UserDefaults.standard.bool(forKey: pauseDefaultsKey), + isPaused: AppDefaults.standard.bool(forKey: pauseDefaultsKey), computerControlEnabled: isComputerControlEnabled()) } @@ -389,7 +389,7 @@ final class MacNodeModeCoordinator: NSObject { private func run() async { var retryDelay: UInt64 = 1_000_000_000 var refreshIterator = self.refreshEvents.makeAsyncIterator() - let defaults = UserDefaults.standard + let defaults = AppDefaults.standard while !Task.isCancelled { // A stop/refresh immediately followed by start/unpause must not install @@ -815,7 +815,7 @@ final class MacNodeModeCoordinator: NSObject { codexThreadCatalogEnabled: Bool, claudeSessionCatalogEnabled: Bool) -> [String] { - let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" + let rawLocationMode = AppDefaults.standard.string(forKey: locationModeKey) ?? "off" let computerControlEnabled = isComputerControlEnabled() return Self.resolvedCaps( browserControlEnabled: browserControlEnabled, @@ -848,7 +848,8 @@ final class MacNodeModeCoordinator: NSObject { } else { switch await CLIInstaller.status() { case let .ready(location, _): - launch = MacNodeHostWorkerLaunch(command: [location, "node", "worker"]) + launch = MacNodeHostWorkerLaunch(command: CommandResolver.nodeHostWorkerCommand( + prefix: [location])) case let status: throw MacNodeHostWorker.WorkerError.unavailable(status.message) } diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodePresenceReporter.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodePresenceReporter.swift index a3fc13d818ae..9298689826ba 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodePresenceReporter.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodePresenceReporter.swift @@ -49,7 +49,7 @@ final class MacNodePresenceReporter { private let idleSecondsProvider: IdleSecondsProvider init( - reportingEnabled: Bool = UserDefaults.standard.bool(forKey: activeComputerPresenceEnabledKey), + reportingEnabled: Bool = AppDefaults.standard.bool(forKey: activeComputerPresenceEnabledKey), idleSecondsProvider: @escaping IdleSecondsProvider = { guard AXIsProcessTrusted() else { return nil } return SystemPresenceInfo.lastHardwareInputSeconds() diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift index 0e270abdf18f..16d4a38d7d2f 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift @@ -980,11 +980,11 @@ extension MacNodeRuntime { } private nonisolated static func canvasEnabled() -> Bool { - UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true + AppDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true } private nonisolated static func cameraEnabled() -> Bool { - UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false + AppDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false } nonisolated static func computerControlEnabledDefault() -> Bool { @@ -992,15 +992,15 @@ extension MacNodeRuntime { } private nonisolated static func locationMode() -> OpenClawLocationMode { - let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" + let raw = AppDefaults.standard.string(forKey: locationModeKey) ?? "off" return OpenClawLocationMode(rawValue: raw) ?? .off } private nonisolated static func locationPreciseEnabled() -> Bool { - if UserDefaults.standard.object(forKey: locationPreciseKey) == nil { + if AppDefaults.standard.object(forKey: locationPreciseKey) == nil { return true } - return UserDefaults.standard.bool(forKey: locationPreciseKey) + return AppDefaults.standard.bool(forKey: locationPreciseKey) } private static func errorResponse( diff --git a/apps/macos/Sources/OpenClaw/NodeServiceManager.swift b/apps/macos/Sources/OpenClaw/NodeServiceManager.swift index 33b83286d670..a49311cc2b33 100644 --- a/apps/macos/Sources/OpenClaw/NodeServiceManager.swift +++ b/apps/macos/Sources/OpenClaw/NodeServiceManager.swift @@ -8,7 +8,8 @@ enum NodeServiceManager { .appendingPathComponent("Library/LaunchAgents/\(nodeLaunchdLabel).plist") } - static func start() async -> String? { + static func start(profile: AppProfile = .current) async -> String? { + if self.skipUnderProfile(profile, action: "start") { return nil } let result = await self.runServiceCommandResult( ["start"], timeout: 20, @@ -20,7 +21,8 @@ enum NodeServiceManager { return nil } - static func stop() async -> String? { + static func stop(profile: AppProfile = .current) async -> String? { + if self.skipUnderProfile(profile, action: "stop") { return nil } let result = await self.runServiceCommandResult( ["stop"], timeout: 15, @@ -32,7 +34,8 @@ enum NodeServiceManager { return nil } - static func restart() async -> String? { + static func restart(profile: AppProfile = .current) async -> String? { + if self.skipUnderProfile(profile, action: "restart") { return nil } let result = await self.runServiceCommandResult( ["restart"], timeout: 20, @@ -46,13 +49,15 @@ enum NodeServiceManager { /// Empty means no node LaunchAgent. Nil means the on-disk ownership proof /// exists but could not be read, so callers must not treat it as external. - static func launchdProgramArguments() -> [String]? { - self.launchdProgramArguments( + static func launchdProgramArguments(profile: AppProfile = .current) -> [String]? { + if self.skipUnderProfile(profile, action: "status") { return [] } + return self.launchdProgramArguments( plistURL: self.launchdPlistURL, fileManager: .default) } - static func waitUntilRunning() async -> Bool { + static func waitUntilRunning(profile: AppProfile = .current) async -> Bool { + if self.skipUnderProfile(profile, action: "status poll") { return false } var consecutiveRunningChecks = 0 for attempt in 0..<20 { let result = await self.runServiceCommandResult( @@ -77,6 +82,12 @@ enum NodeServiceManager { } extension NodeServiceManager { + private static func skipUnderProfile(_ profile: AppProfile, action: String) -> Bool { + guard profile.isActive else { return false } + self.logger.info("node service \(action, privacy: .public) skipped (unavailable under app profile)") + return true + } + private static func serviceCommand(_ args: [String]) async -> [String] { await CommandResolver.openclawCommand( subcommand: "node", @@ -107,6 +118,9 @@ extension NodeServiceManager { timeout: Double, quiet: Bool) async -> CommandResult { + #if DEBUG + self.testingServiceCommandCalls.append(args) + #endif let command = await self.serviceCommand(args) var env = ProcessInfo.processInfo.environment env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") @@ -187,6 +201,9 @@ extension NodeServiceManager { plistURL: URL, fileManager: FileManager) -> [String]? { + #if DEBUG + self.testingOwnershipReadCount += 1 + #endif guard fileManager.fileExists(atPath: plistURL.path) else { return [] } return LaunchAgentPlist.snapshot(url: plistURL)?.programArguments } @@ -206,6 +223,18 @@ extension NodeServiceManager { #if DEBUG extension NodeServiceManager { + private nonisolated(unsafe) static var testingServiceCommandCalls: [[String]] = [] + private nonisolated(unsafe) static var testingOwnershipReadCount = 0 + + static func _testResetPersistentServiceCalls() { + self.testingServiceCommandCalls = [] + self.testingOwnershipReadCount = 0 + } + + static func _testPersistentServiceCallSnapshot() -> (commands: [[String]], ownershipReads: Int) { + (self.testingServiceCommandCalls, self.testingOwnershipReadCount) + } + static func _testServiceCommand(_ args: [String]) async -> [String] { await self.serviceCommand(args) } diff --git a/apps/macos/Sources/OpenClaw/NodesStore.swift b/apps/macos/Sources/OpenClaw/NodesStore.swift index aa1bcc8bc0bf..82c756b84ffb 100644 --- a/apps/macos/Sources/OpenClaw/NodesStore.swift +++ b/apps/macos/Sources/OpenClaw/NodesStore.swift @@ -51,6 +51,7 @@ final class NodesStore { var nodes: [NodeInfo] = [] var lastError: String? var statusMessage: String? + let persistentServiceNotice: String? var isLoading = false private(set) var localNodeIdentityState: LocalNodeIdentityState = .loading @@ -65,11 +66,15 @@ final class NodesStore { @ObservationIgnored private var localNodeIdentityPreparationTask: Task? init( + appProfile: AppProfile = .current, localNodeIdentityProfile: GatewayDeviceIdentityProfile = MacNodeModeCoordinator.nodeIdentityProfile, localNodeIDLoader: @escaping @Sendable (GatewayDeviceIdentityProfile) -> String? = { profile in DeviceIdentityStore.loadOrCreatePersisted(profile: profile)?.deviceId }) { + self.persistentServiceNotice = appProfile.isActive + ? "Persistent Mac node service unavailable under app profile; runtime node remains available." + : nil self.localNodeIdentityProfile = localNodeIdentityProfile self.localNodeIDLoader = localNodeIDLoader } diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift index 91947dd62fe8..109f66b3b642 100644 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ b/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -151,7 +151,7 @@ enum OnboardingSystemAgentResumeStore { static func isPending( for routeIdentity: String?, - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppDefaults.standard, now: Date = Date()) -> Bool { self.pendingState(for: routeIdentity, defaults: defaults, now: now) != .none @@ -162,7 +162,7 @@ enum OnboardingSystemAgentResumeStore { routeIdentity: String?, activationOwner: ActivationOwner? = nil, activationTimeoutMs: Double = OnboardingSystemAgentResumeStore.maximumActivationTimeoutMs, - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppDefaults.standard, now: Date = Date()) -> Date? { @@ -183,7 +183,7 @@ enum OnboardingSystemAgentResumeStore { routeIdentity: String, activationOwner: ActivationOwner? = nil, deadline: Date, - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppDefaults.standard, now: Date = Date()) { guard let routeIdentity = normalized(routeIdentity) else { return } @@ -199,7 +199,7 @@ enum OnboardingSystemAgentResumeStore { static func markVerified( ifOwnedBy routeIdentity: String?, activationOwner: ActivationOwner? = nil, - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppDefaults.standard, now: Date = Date()) { guard let routeIdentity = normalized(routeIdentity) else { return } @@ -219,7 +219,7 @@ enum OnboardingSystemAgentResumeStore { static func markCompleted( ifOwnedBy routeIdentity: String?, activationOwner: ActivationOwner? = nil, - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppDefaults.standard, now: Date = Date()) -> Bool { guard let routeIdentity = normalized(routeIdentity) else { return false } @@ -238,7 +238,7 @@ enum OnboardingSystemAgentResumeStore { static func activationOwner( for routeIdentity: String?, - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppDefaults.standard, now: Date = Date()) -> ActivationOwner? { guard let routeIdentity = normalized(routeIdentity) else { return nil } @@ -248,7 +248,7 @@ enum OnboardingSystemAgentResumeStore { static func isOwned( by activationOwner: ActivationOwner, for routeIdentity: String?, - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppDefaults.standard, now: Date = Date()) -> Bool { guard let routeIdentity = normalized(routeIdentity), @@ -259,7 +259,7 @@ enum OnboardingSystemAgentResumeStore { static func pendingState( for routeIdentity: String?, - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppDefaults.standard, now: Date = Date()) -> PendingState { guard let routeIdentity = normalized(routeIdentity), @@ -282,7 +282,7 @@ enum OnboardingSystemAgentResumeStore { static func clear( ifOwnedBy routeIdentity: String, activationOwner: ActivationOwner? = nil, - defaults: UserDefaults = .standard) -> Bool + defaults: UserDefaults = AppDefaults.standard) -> Bool { guard let routeIdentity = normalized(routeIdentity) else { return false } var records = self.loadRecords(defaults: defaults) @@ -294,7 +294,7 @@ enum OnboardingSystemAgentResumeStore { return true } - static func clear(defaults: UserDefaults = .standard) { + static func clear(defaults: UserDefaults = AppDefaults.standard) { defaults.removeObject(forKey: onboardingSystemAgentPendingKey) defaults.removeObject(forKey: onboardingSystemAgentPendingRetiredKey) } @@ -537,8 +537,8 @@ final class OnboardingController: NSObject, NSWindowDelegate { var busyReason: String? static func markComplete() { - UserDefaults.standard.set(true, forKey: onboardingSeenKey) - UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) + AppDefaults.standard.set(true, forKey: onboardingSeenKey) + AppDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) AppStateStore.shared.onboardingSeen = true DashboardManager.shared.handleOnboardingCompletion() } @@ -827,7 +827,7 @@ struct OnboardingView: View { localDisplayName: InstanceIdentity.displayName, filterLocalGateways: false), aiSetupGateway: GatewayConnection = .shared, - systemAgentDefaults: UserDefaults = .standard, + systemAgentDefaults: UserDefaults = AppDefaults.standard, aiSetupRouteIdentityProvider: (@MainActor () -> String?)? = nil, configuredGatewayProbeTimeoutMs: Double = 15000, gatewaySelectionPersister: (@MainActor () -> Bool)? = nil, diff --git a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift index 69b47dfb3b77..5319330b5d03 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift @@ -110,7 +110,7 @@ final class OnboardingAISetupModel { init( gateway: GatewayConnection = .shared, - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppDefaults.standard, routeIdentityProvider: @escaping @MainActor () -> String? = { OnboardingSystemAgentResumeStore.selectedRouteIdentity() }, diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 9b1ad484c4b9..914204612cdc 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -1047,8 +1047,12 @@ extension OnboardingView { self.openSettings(tab: .skills) } self.skillsOverview - Toggle("Launch at login", isOn: self.$state.launchAtLogin) - .disabled(!self.state.bundleLocationAllowsPersistentIntegration && !self.state.launchAtLogin) + if AppProfile.current.isActive { + LabeledContent("Launch at login", value: "Unavailable under profile") + } else { + Toggle("Launch at login", isOn: self.$state.launchAtLogin) + .disabled(!self.state.bundleLocationAllowsPersistentIntegration && !self.state.launchAtLogin) + } } } .task { await self.maybeLoadOnboardingSkills() } diff --git a/apps/macos/Sources/OpenClaw/OpenClawPaths.swift b/apps/macos/Sources/OpenClaw/OpenClawPaths.swift index 62347ebf7e36..234a06f1e2d1 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawPaths.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawPaths.swift @@ -23,8 +23,7 @@ enum OpenClawPaths { return URL(fileURLWithPath: override, isDirectory: true) } } - let home = FileManager().homeDirectoryForCurrentUser - return home.appendingPathComponent(".openclaw", isDirectory: true) + return AppProfile.current.stateDirectoryURL() } private static func resolveConfigCandidate(in dir: URL) -> URL? { diff --git a/apps/macos/Sources/OpenClaw/PermissionsSettings.swift b/apps/macos/Sources/OpenClaw/PermissionsSettings.swift index e9b3e96ff720..760c0296b9fe 100644 --- a/apps/macos/Sources/OpenClaw/PermissionsSettings.swift +++ b/apps/macos/Sources/OpenClaw/PermissionsSettings.swift @@ -93,8 +93,9 @@ struct PermissionsSettings: View { private struct LocationAccessSettings: View { private static let controlWidth: CGFloat = 180 - @AppStorage(locationModeKey) private var locationModeRaw: String = OpenClawLocationMode.off.rawValue - @AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true + @AppStorage(locationModeKey, store: AppDefaults.standard) + private var locationModeRaw: String = OpenClawLocationMode.off.rawValue + @AppStorage(locationPreciseKey, store: AppDefaults.standard) private var locationPreciseEnabled: Bool = true @State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue var body: some View { diff --git a/apps/macos/Sources/OpenClaw/PortGuardian.swift b/apps/macos/Sources/OpenClaw/PortGuardian.swift index bb9da20b5b69..f0c3002df196 100644 --- a/apps/macos/Sources/OpenClaw/PortGuardian.swift +++ b/apps/macos/Sources/OpenClaw/PortGuardian.swift @@ -97,6 +97,12 @@ actor PortGuardian { self.logger.warning(message) continue } + if AppProfile.current.isActive { + self.logger.error( + "profile port \(port, privacy: .public) held by \(listener.command, privacy: .public) " + + "(pid \(listener.pid, privacy: .public)); preserving conflict") + continue + } if await Self.terminateProcess(listener.pid) { let message = """ port \(port) was held by \(listener.command) diff --git a/apps/macos/Sources/OpenClaw/PostUpdate.swift b/apps/macos/Sources/OpenClaw/PostUpdate.swift index ed3275b13661..0b44f81ba179 100644 --- a/apps/macos/Sources/OpenClaw/PostUpdate.swift +++ b/apps/macos/Sources/OpenClaw/PostUpdate.swift @@ -61,7 +61,7 @@ enum PostAppUpdateReceiptStore { static func record( fromVersion: String, toVersion: String, - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppDefaults.standard, now: Date = Date()) { let from = fromVersion.trimmingCharacters(in: .whitespacesAndNewlines) @@ -73,7 +73,7 @@ enum PostAppUpdateReceiptStore { static func pending( currentVersion: String?, - defaults: UserDefaults = .standard) -> PostAppUpdateReceipt? + defaults: UserDefaults = AppDefaults.standard) -> PostAppUpdateReceipt? { guard let currentVersion = normalized(currentVersion), let data = defaults.data(forKey: postAppUpdateReceiptKey), @@ -87,7 +87,7 @@ enum PostAppUpdateReceiptStore { currentVersion: String?, onboardingSeen: Bool, allowsUpdateWorkflow: Bool = true, - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppDefaults.standard, now: Date = Date()) -> PostAppUpdateReceipt? { guard let currentVersion = normalized(currentVersion) else { return nil } @@ -116,7 +116,7 @@ enum PostAppUpdateReceiptStore { return receipt } - static func clear(defaults: UserDefaults = .standard) { + static func clear(defaults: UserDefaults = AppDefaults.standard) { defaults.removeObject(forKey: postAppUpdateReceiptKey) } @@ -124,7 +124,7 @@ enum PostAppUpdateReceiptStore { static func setGatewayUpdateIncomplete( _ incomplete: Bool, receipt: PostAppUpdateReceipt, - defaults: UserDefaults = .standard) -> PostAppUpdateReceipt + defaults: UserDefaults = AppDefaults.standard) -> PostAppUpdateReceipt { let updated = PostAppUpdateReceipt( fromVersion: receipt.fromVersion, @@ -140,7 +140,7 @@ enum PostAppUpdateReceiptStore { @discardableResult static func recordNotificationFailure( receipt: PostAppUpdateReceipt, - defaults: UserDefaults = .standard) -> PostAppUpdateReceipt + defaults: UserDefaults = AppDefaults.standard) -> PostAppUpdateReceipt { // One later-launch retry handles restart races. The bound prevents // permanent auth/schema errors from reopening this window forever. @@ -159,7 +159,7 @@ enum PostAppUpdateReceiptStore { static func setNotificationInFlight( _ inFlight: Bool, receipt: PostAppUpdateReceipt, - defaults: UserDefaults = .standard) -> PostAppUpdateReceipt + defaults: UserDefaults = AppDefaults.standard) -> PostAppUpdateReceipt { let updated = PostAppUpdateReceipt( fromVersion: receipt.fromVersion, @@ -266,7 +266,8 @@ final class PostUpdateController: NSObject, NSWindowDelegate { private var task: Task? @discardableResult - func startIfNeeded() -> Bool { + func startIfNeeded(profile: AppProfile = .current) -> Bool { + guard !profile.isActive else { return false } guard let receipt = PostAppUpdateReceiptStore.pendingForLaunch( currentVersion: GatewayEnvironment.appVersionString(), onboardingSeen: AppStateStore.shared.onboardingSeen, @@ -341,6 +342,10 @@ final class PostUpdateController: NSObject, NSWindowDelegate { } private func finishUpdate(receipt: PostAppUpdateReceipt) async { + guard !AppProfile.current.isActive else { + self.finishSilently() + return + } let connectionMode = AppStateStore.shared.connectionMode guard CLIInstallPrompter.shouldManageCLI(connectionMode: connectionMode) else { self.finishSilently() @@ -549,6 +554,7 @@ final class PostUpdateController: NSObject, NSWindowDelegate { } private func verifyRuntime(connectionMode: AppState.ConnectionMode) async -> Bool { + guard !AppProfile.current.isActive else { return true } guard case .ready = await CLIInstaller.managedStatus() else { self.fail( message: String(localized: "Gateway verification failed."), diff --git a/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift b/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift index af8b7e67ad97..b362e7e380d5 100644 --- a/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift +++ b/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift @@ -14,14 +14,14 @@ extension ProcessInfo { } static func resolveStableNixSuite(bundleIdentifier: String?, isAppBundle: Bool) -> UserDefaults? { - // The app's own defaults domain is already represented by UserDefaults.standard. + // The app's own defaults domain is already represented by AppDefaults.standard. // Passing that same identifier to suiteName triggers Foundation's nonsensical-suite warning. guard isAppBundle, bundleIdentifier != launchdLabel else { return nil } return UserDefaults(suiteName: launchdLabel) } /// Nix deployments may write defaults into a stable suite (`ai.openclaw.mac`) even if the shipped - /// app bundle identifier changes (and therefore `UserDefaults.standard` domain changes). + /// app bundle identifier changes (and therefore `AppDefaults.standard` domain changes). static func resolveNixMode( environment: [String: String], standard: UserDefaults, @@ -45,7 +45,7 @@ extension ProcessInfo { isAppBundle: isAppBundle) return Self.resolveNixMode( environment: self.environment, - standard: .standard, + standard: AppDefaults.standard, stableSuite: stableSuite, isAppBundle: isAppBundle) } diff --git a/apps/macos/Sources/OpenClaw/ProfileGatewayPortReservation.swift b/apps/macos/Sources/OpenClaw/ProfileGatewayPortReservation.swift new file mode 100644 index 000000000000..bebe5adf5cec --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ProfileGatewayPortReservation.swift @@ -0,0 +1,58 @@ +import Darwin +import Foundation + +final class ProfileGatewayPortReservation: @unchecked Sendable { + let port: Int + let conflict: String? + private let lock: AppInstanceLock? + + private init(port: Int, conflict: String?, lock: AppInstanceLock?) { + self.port = port + self.conflict = conflict + self.lock = lock + } + + static func acquire( + profile: AppProfile, + port: Int, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + temporaryDirectory: URL = URL(fileURLWithPath: "/tmp", isDirectory: true)) -> Self + { + guard let profileName = profile.name else { return Self(port: port, conflict: nil, lock: nil) } + let lockURL = temporaryDirectory + .appendingPathComponent("openclaw-\(geteuid())-app-profile-ports", isDirectory: true) + .appendingPathComponent("\(port).lock") + let lock: AppInstanceLock + switch AppInstanceLock.acquire(url: lockURL) { + case let .acquired(acquired): lock = acquired + case .busy: + return self.conflict( + profile: profileName, + port: port, + reason: "another running OpenClaw profile already reserves it") + case let .failed(reason): + return self.conflict( + profile: profileName, + port: port, + reason: "the profile reservation could not be verified (\(reason))") + } + + if let reason = GatewayLaunchAgentManager.conflictingProfileClaimOwner( + port: port, + excludingLabel: profile.gatewayLaunchAgentLabel, + homeDirectory: homeDirectory) + { + return self.conflict(profile: profileName, port: port, reason: reason) + } + return Self(port: port, conflict: nil, lock: lock) + } + + private static func conflict(profile: String, port: Int, reason: String) -> Self { + let message = "Profile \"\(profile)\" cannot use Gateway port \(port) because \(reason). " + + "Set gateway.port to a free port for this profile, or stop/uninstall the other Gateway." + return Self( + port: port, + conflict: message, + lock: nil) + } +} diff --git a/apps/macos/Sources/OpenClaw/QuickChatShortcut.swift b/apps/macos/Sources/OpenClaw/QuickChatShortcut.swift index 9c52aeee679c..868cec2e11f3 100644 --- a/apps/macos/Sources/OpenClaw/QuickChatShortcut.swift +++ b/apps/macos/Sources/OpenClaw/QuickChatShortcut.swift @@ -3,6 +3,6 @@ import KeyboardShortcuts extension KeyboardShortcuts.Name { /// KeyboardShortcuts owns UserDefaults persistence for the recorded chord. static let toggleQuickChat = Self( - "toggleQuickChat", + AppProfile.current.name.map { "toggleQuickChat-\($0)" } ?? "toggleQuickChat", initial: .init(.space, modifiers: [.option])) } diff --git a/apps/macos/Sources/OpenClaw/SettingsRootView.swift b/apps/macos/Sources/OpenClaw/SettingsRootView.swift index 8aa53d9e95d3..ff39585bdfc6 100644 --- a/apps/macos/Sources/OpenClaw/SettingsRootView.swift +++ b/apps/macos/Sources/OpenClaw/SettingsRootView.swift @@ -56,6 +56,7 @@ struct SettingsRootView: View { self.detailContainer } .navigationSplitViewStyle(.balanced) + .defaultAppStorage(AppDefaults.standard) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .onReceive(NotificationCenter.default.publisher(for: .openclawSelectSettingsTab)) { note in diff --git a/apps/macos/Sources/OpenClaw/WebChatManager.swift b/apps/macos/Sources/OpenClaw/WebChatManager.swift index a2b3088f9787..b6bcc004f5b1 100644 --- a/apps/macos/Sources/OpenClaw/WebChatManager.swift +++ b/apps/macos/Sources/OpenClaw/WebChatManager.swift @@ -154,10 +154,10 @@ final class WebChatManager { AppNavigationActions.openSettings(tab: .gateways) return } - let preferredID = UserDefaults.standard.string(forKey: Self.lastGatewayProfileIDKey) + let preferredID = AppDefaults.standard.string(forKey: Self.lastGatewayProfileIDKey) switch Self.promptForGatewayProfile(profiles: profiles, preferredID: preferredID) { case let .profile(profile): - UserDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey) + AppDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey) try await self.show(profile: profile) case .manage: AppNavigationActions.openSettings(tab: .gateways) @@ -173,7 +173,7 @@ final class WebChatManager { func openGatewayWindow(profile: MacGatewayProfile) { Task { @MainActor [weak self] in do { - UserDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey) + AppDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey) try await self?.show(profile: profile) } catch { Self.showProfileError(error, message: "Could Not Open Gateway Window") diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 40c39ef8160e..98f3eb32e977 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -17,7 +17,7 @@ private enum WebChatSwiftUILayout { } enum WebChatTracePreferences { - static func displayOptions(defaults: UserDefaults = .standard) -> OpenClawChatDisplayOptions { + static func displayOptions(defaults: UserDefaults = AppDefaults.standard) -> OpenClawChatDisplayOptions { if let legacyValue = defaults.object( forKey: OpenClawChatWindowShell.assistantTraceDefaultsKey) as? Bool { @@ -841,9 +841,9 @@ private struct MacChatSurface: View { @State private var appState = AppStateStore.shared @State private var talkController = TalkModeController.shared @State private var audioInputCatalog = MacChatAudioInputCatalog() - @AppStorage(OpenClawChatWindowShell.assistantReasoningDefaultsKey) + @AppStorage(OpenClawChatWindowShell.assistantReasoningDefaultsKey, store: AppDefaults.standard) private var showsReasoning = WebChatTracePreferences.displayOptions().contains(.reasoning) - @AppStorage(OpenClawChatWindowShell.assistantToolActivityDefaultsKey) + @AppStorage(OpenClawChatWindowShell.assistantToolActivityDefaultsKey, store: AppDefaults.standard) private var showsToolActivity = WebChatTracePreferences.displayOptions().contains(.toolActivity) private let userAccent: Color? @@ -1110,9 +1110,9 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { }, onThinkingPreferenceChanged: { level in if let level { - UserDefaults.standard.set(level, forKey: webChatThinkingLevelDefaultsKey) + AppDefaults.standard.set(level, forKey: webChatThinkingLevelDefaultsKey) } else { - UserDefaults.standard.removeObject(forKey: webChatThinkingLevelDefaultsKey) + AppDefaults.standard.removeObject(forKey: webChatThinkingLevelDefaultsKey) } }, onVerbosePreferenceChanged: { level in @@ -1221,7 +1221,7 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { onClosed?() } - static func persistedThinkingLevel(defaults: UserDefaults = .standard) -> String? { + static func persistedThinkingLevel(defaults: UserDefaults = AppDefaults.standard) -> String? { let stored = defaults.string(forKey: webChatThinkingLevelDefaultsKey)? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() @@ -1233,14 +1233,14 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { return stored } - static func persistedVerboseLevel(defaults: UserDefaults = .standard) -> String? { + static func persistedVerboseLevel(defaults: UserDefaults = AppDefaults.standard) -> String? { let stored = defaults.string(forKey: webChatVerboseLevelDefaultsKey)? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() return OpenClawChatViewModel.verboseLevelOptions.contains(stored ?? "") ? stored : nil } - static func persistVerbosePreference(_ level: String?, defaults: UserDefaults = .standard) { + static func persistVerbosePreference(_ level: String?, defaults: UserDefaults = AppDefaults.standard) { if let level { defaults.set(level, forKey: webChatVerboseLevelDefaultsKey) } else { diff --git a/apps/macos/Tests/OpenClawIPCTests/AppProfileSourceInvariantTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppProfileSourceInvariantTests.swift new file mode 100644 index 000000000000..7edefc258af0 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/AppProfileSourceInvariantTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing + +struct AppProfileSourceInvariantTests { + @Test func `profile persistence has one defaults and gateway-label owner`() throws { + let macRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let sourceRoot = macRoot.appendingPathComponent("Sources/OpenClaw", isDirectory: true) + let files = try #require(FileManager.default.enumerator( + at: sourceRoot, + includingPropertiesForKeys: nil)?.allObjects as? [URL]) + .filter { $0.pathExtension == "swift" } + .sorted { $0.path < $1.path } + + var directStandardOwners: [String] = [] + var hardcodedGatewayLabelOwners: [String] = [] + var unscopedAppStorageOwners: [String] = [] + for file in files { + let source = try String(contentsOf: file, encoding: .utf8) + if source.contains("UserDefaults.standard") { + directStandardOwners.append(file.lastPathComponent) + } + if source.contains("\"ai.openclaw.gateway\"") { + hardcodedGatewayLabelOwners.append(file.lastPathComponent) + } + if source.split(separator: "\n").contains(where: { + $0.contains("@AppStorage(") && !$0.contains("store: AppDefaults.standard") + }) { + unscopedAppStorageOwners.append(file.lastPathComponent) + } + #expect(!source.contains("UserDefaults = .standard"), "Unscoped defaults in \(file.path)") + } + + #expect(directStandardOwners == ["AppProfile.swift"]) + #expect(hardcodedGatewayLabelOwners == ["AppProfile.swift"]) + #expect(unscopedAppStorageOwners.sorted() == ["DebugSettings.swift", "GeneralSettings.swift"]) + let settingsRoot = try String( + contentsOf: sourceRoot.appendingPathComponent("SettingsRootView.swift"), + encoding: .utf8) + #expect(settingsRoot.contains(".defaultAppStorage(AppDefaults.standard)")) + + let menuBar = try String( + contentsOf: sourceRoot.appendingPathComponent("MenuBar.swift"), + encoding: .utf8) + let delegateInit = try #require(menuBar.range(of: "override init()")) + let ownershipGate = try #require(menuBar.range( + of: "AppInstanceLock.acquire", + range: delegateInit.lowerBound..= 5) + + let portGuardian = try String( + contentsOf: sourceRoot.appendingPathComponent("PortGuardian.swift"), + encoding: .utf8) + let profilePreserve = try #require(portGuardian.range(of: "if AppProfile.current.isActive")) + let firstTerminate = try #require(portGuardian.range( + of: "terminateProcess", + range: profilePreserve.lowerBound.. URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("app-profile-port-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) + return root + } + + @Test func `profile launch at login reason outranks bundle location`() { + let profiled = LaunchAtLoginPresentation.resolve( + profile: AppProfile(environment: ["OPENCLAW_PROFILE": "work"]), + bundleLocationAllowsPersistentIntegration: false, + isEnabled: false) + #expect(profiled.subtitle == "Launch at login is unavailable while an app profile is active.") + #expect(profiled.isDisabled) + + let defaultUnavailable = LaunchAtLoginPresentation.resolve( + profile: AppProfile(environment: [:]), + bundleLocationAllowsPersistentIntegration: false, + isEnabled: false) + #expect(defaultUnavailable.subtitle == "Move OpenClaw to Applications before enabling launch at login.") + #expect(defaultUnavailable.isDisabled) + + let defaultAvailable = LaunchAtLoginPresentation.resolve( + profile: AppProfile(environment: [:]), + bundleLocationAllowsPersistentIntegration: true, + isEnabled: false) + #expect(defaultAvailable.subtitle == "Automatically start OpenClaw after you sign in.") + #expect(!defaultAvailable.isDisabled) + } + + @MainActor @Test func `profile lock excludes only the same profile`() throws { + #expect(AppDelegate.processExitCode(for: .busy) == 0) + #expect(AppDelegate.processExitCode(for: .failed("test")) == nil) + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("app-profile-lock-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) + let defaultProfile = AppProfile(environment: [:]) + let workProfile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"]) + let defaultURL = defaultProfile.instanceLockURL(systemTemporaryDirectory: root) + let namedURL = workProfile.instanceLockURL(systemTemporaryDirectory: root) + #expect(defaultURL != namedURL) + #expect(workProfile.instanceLockURL(systemTemporaryDirectory: root) == + AppProfile(environment: [ + "OPENCLAW_PROFILE": "work", + "OPENCLAW_STATE_DIR": "/different/state", + ]).instanceLockURL(systemTemporaryDirectory: root)) + var defaultLock: AppInstanceLock? + switch AppInstanceLock.acquire(url: defaultURL) { + case let .acquired(lock): defaultLock = lock + case .busy, .failed: Issue.record("Expected default lock acquisition") + } + if case .busy = AppInstanceLock.acquire(url: defaultURL) {} else { + Issue.record("Expected same-profile lock rejection") + } + var namedLock: AppInstanceLock? + switch AppInstanceLock.acquire(url: namedURL) { + case let .acquired(lock): namedLock = lock + case .busy, .failed: Issue.record("Expected cross-profile lock acquisition") + } + #expect(defaultLock != nil) + #expect(namedLock != nil) + let defaultAttributes = try FileManager.default.attributesOfItem( + atPath: defaultURL.deletingLastPathComponent().path) + #expect((defaultAttributes[.posixPermissions] as? NSNumber)?.intValue == 0o700) + defaultLock = nil + namedLock = nil + if case .acquired = AppInstanceLock.acquire(url: defaultURL) {} else { + Issue.record("Expected released default lock to transfer") + } + var namedFirst: AppInstanceLock? + if case let .acquired(lock) = AppInstanceLock.acquire(url: namedURL) { + namedFirst = lock + } else { + Issue.record("Expected named-first lock acquisition") + } + if case .acquired = AppInstanceLock.acquire(url: defaultURL) {} else { + Issue.record("Expected named-to-default coexistence") + } + #expect(namedFirst != nil) + } + + @Test func `profile lock reports unsafe ownership errors`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("app-profile-lock-error-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) + let target = root.appendingPathComponent("target") + let link = root.appendingPathComponent("app-instance.lock") + try Data().write(to: target) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + if case .failed = AppInstanceLock.acquire(url: link) {} else { + Issue.record("Expected symlink lock rejection") + } + + let unsafeURL = AppProfile(environment: ["OPENCLAW_PROFILE": "unsafe"]) + .instanceLockURL(systemTemporaryDirectory: root) + let unsafe = unsafeURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: unsafe, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o777], ofItemAtPath: unsafe.path) + if case .failed = AppInstanceLock.acquire(url: unsafeURL) {} else { + Issue.record("Expected unsafe state directory rejection") + } + } + + @Test func `long profile approvals socket uses deterministic private short path`() { + let longState = URL(fileURLWithPath: "/Users/" + String(repeating: "very-long/", count: 16)) + let first = ExecApprovalsStore.socketPath(stateDirectoryURL: longState, profileActive: true) + let second = ExecApprovalsStore.socketPath(stateDirectoryURL: longState, profileActive: true) + #expect(first == second) + #expect(first.hasPrefix("/tmp/openclaw-\(geteuid())/exec-approvals-")) + #expect(first.utf8.count < MemoryLayout.size(ofValue: sockaddr_un().sun_path)) + #expect(ExecApprovalsStore.socketPath(stateDirectoryURL: longState, profileActive: false) + .hasPrefix(longState.path)) + let oldCanonical = longState.appendingPathComponent("exec-approvals.sock").path + #expect(ExecApprovalsStore.resolvedPersistedSocketPath( + existing: nil, + stateDirectoryURL: longState, + computed: first) == first) + #expect(ExecApprovalsStore.resolvedPersistedSocketPath( + existing: oldCanonical, + stateDirectoryURL: longState, + computed: first) == first) + #expect(ExecApprovalsStore.resolvedPersistedSocketPath( + existing: "/custom/approvals.sock", + stateDirectoryURL: longState, + computed: first) == "/custom/approvals.sock") + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift index 243853d2bd68..05a97e59cc1f 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift @@ -176,6 +176,23 @@ struct CLIInstallerTests { "--yes", "--no-restart", ]) + + let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"]) + #expect(CLIInstaller.managedUpdateCommand( + executable: "/opt/openclaw", + targetVersion: "2026.7.4", + profile: profile) == [ + "/opt/openclaw", "--profile", "work", "update", "--tag", "2026.7.4", + "--json", "--timeout", "900", + ]) + #expect(CLIInstaller.managedUpdateCommand( + executable: "/opt/openclaw", + targetVersion: "2026.7.4", + repair: true, + profile: profile) == [ + "/opt/openclaw", "--profile", "work", "update", "repair", "--json", + "--timeout", "900", "--yes", + ]) } @Test func `managed update parses structured updater diagnostics`() throws { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index 1909f0e815ea..f45cb3c85a72 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -152,6 +152,35 @@ private actor GatewayEndpointRemoteEnsureGate { } struct GatewayEndpointStoreTests { + @MainActor + @Test func `live local source uses canonical default and named profile ports`() async throws { + let configPath = TestIsolation.tempConfigPath() + try Data(#"{"gateway":{"mode":"local"}}"#.utf8) + .write(to: URL(fileURLWithPath: configPath)) + defer { try? FileManager.default.removeItem(atPath: configPath) } + + try await TestIsolation.withIsolatedState( + env: [ + "OPENCLAW_CONFIG_PATH": configPath, + "OPENCLAW_GATEWAY_PORT": nil, + ], + defaults: ["gatewayPort": nil]) + { + let state = AppState(preview: true) + let base = await GatewayEndpointStore._testLiveSourceSnapshot( + state: state, + profile: AppProfile(environment: [:]), + beforeConfigRead: {}) + let workProfile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"]) + let work = await GatewayEndpointStore._testLiveSourceSnapshot( + state: state, + profile: workProfile, + beforeConfigRead: {}) + #expect(base.localPort == 18789) + #expect(work.localPort == workProfile.defaultGatewayPort) + } + } + private func makeLaunchAgentSnapshot( env: [String: String] = [:], token: String? = nil, @@ -238,6 +267,7 @@ struct GatewayEndpointStoreTests { token: token, password: { nil }, localPort: { 18789 }, + localUnavailableReason: { nil }, remoteRouteIfRunning: remoteRouteIfRunning, remoteRouteIsCurrent: remoteRouteIsCurrent, canStartRemoteTunnel: canStartRemoteTunnel, @@ -264,6 +294,27 @@ struct GatewayEndpointStoreTests { return ConnectionModeResolver.resolve(root: root, defaults: defaults) } + @Test func `local conflict remains unavailable across refresh until cleared`() async throws { + let source = self.source(mode: .local) + let store = self.makeStore(sourceSnapshot: { source }) + + await store.setLocalUnavailableReason("Profile port conflict") + await store.refresh() + #expect(await store.currentState() == .unavailable( + mode: .local, + reason: "Profile port conflict")) + await #expect(throws: Error.self) { + _ = try await store.requireEndpoint() + } + + await store.setLocalUnavailableReason(nil) + await store.refresh() + guard case .ready = await store.currentState() else { + Issue.record("Expected local endpoint to recover after conflict clears") + return + } + } + private func dashboardURL( _ endpoint: String, mode: AppState.ConnectionMode, @@ -554,8 +605,7 @@ extension GatewayEndpointStoreTests { let sourceGate = GatewayEndpointSourceGate(source) let store = self.makeStore( sourceSnapshot: { await sourceGate.snapshot() }, - liveSourceIsCurrent: { $0.routingGeneration == 7 }, - ) + liveSourceIsCurrent: { $0.routingGeneration == 7 }) _ = try await store.requireEndpoint() #expect(await sourceGate.reads() == 1) @@ -651,8 +701,7 @@ extension GatewayEndpointStoreTests { let routeGate = GatewayEndpointRouteLookupGate() let store = self.makeStore( sourceSnapshot: { await sourceGate.snapshot() }, - remoteRouteIfRunning: { await routeGate.lookup() }, - ) + remoteRouteIfRunning: { await routeGate.lookup() }) let staleRequest = Task { try await store.requireEndpoint() } await routeGate.waitUntilStarted() @@ -741,8 +790,7 @@ extension GatewayEndpointStoreTests { let sourceGate = GatewayEndpointSourceGate(fallbackSource) let store = self.makeStore( sourceSnapshot: { await sourceGate.snapshot() }, - token: { "local-token" }, - ) + token: { "local-token" }) let initialURL = try #require(URL(string: "ws://127.0.0.1:18789")) await sourceGate.suspendNextRead() @@ -813,8 +861,7 @@ extension GatewayEndpointStoreTests { sourceSnapshot: { source }, remoteRouteIfRunning: { await remoteGate.routeIfRunning() }, remoteRouteIsCurrent: { await remoteGate.isCurrent($0) }, - ensureRemoteTunnel: { await remoteGate.ensure() }, - ) + ensureRemoteTunnel: { await remoteGate.ensure() }) let cancelledWaiter = Task { try await store.requireEndpoint() } await remoteGate.waitUntilEnsureStarts() @@ -849,8 +896,7 @@ extension GatewayEndpointStoreTests { sourceSnapshot: { source }, remoteRouteIfRunning: { await remoteGate.routeIfRunning() }, remoteRouteIsCurrent: { await remoteGate.isCurrent($0) }, - ensureRemoteTunnel: { await remoteGate.ensure() }, - ) + ensureRemoteTunnel: { await remoteGate.ensure() }) let first = Task { try await store.requireEndpoint() } await remoteGate.waitUntilEnsureStarts() diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift index 054e5fc9fde7..0faf16caee9e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift @@ -151,6 +151,43 @@ struct GatewayEnvironmentTests { } } + @Test func `named profiles derive stable distinct gateway ports after explicit precedence`() { + let work = AppProfile(environment: ["OPENCLAW_PROFILE": "work"]) + let personal = AppProfile(environment: ["OPENCLAW_PROFILE": "personal"]) + let workPort = GatewayEnvironment.resolvedGatewayPort( + environment: [:], + configPort: nil, + storedPort: 0, + profile: work) + #expect((20000..<60000).contains(workPort)) + #expect(workPort == work.defaultGatewayPort) + #expect(workPort != personal.defaultGatewayPort) + #expect(GatewayEnvironment.resolvedGatewayPort( + environment: ["OPENCLAW_GATEWAY_PORT": "21001"], + configPort: 22001, + storedPort: 23001, + profile: work) == 21001) + #expect(GatewayEnvironment.resolvedGatewayPort( + environment: [:], + configPort: 22001, + storedPort: 23001, + profile: work) == 22001) + #expect(GatewayEnvironment.resolvedGatewayPort( + environment: [:], + configPort: nil, + storedPort: 23001, + profile: work) == 23001) + #expect(AppProfile(environment: [:]).defaultGatewayPort == 18789) + #expect(GatewayEnvironment.gatewayCommand( + prefix: ["/opt/openclaw"], + port: workPort, + bind: "loopback", + profile: work) == [ + "/opt/openclaw", "--profile", "work", "gateway", "--port", "\(workPort)", + "--bind", "loopback", + ]) + } + @Test func `expected gateway version from string uses parser`() { #expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2)) #expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver( diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift index 823545a26b7f..d52370fcb569 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift @@ -4,10 +4,116 @@ import Testing @Suite(.serialized) struct GatewayLaunchAgentManagerTests { + @Test func `gateway launchd artifacts follow default and named profile labels`() { + let home = URL(fileURLWithPath: "/Users/test", isDirectory: true) + let directory = URL(fileURLWithPath: "/state/service-env", isDirectory: true) + let base = AppProfile(environment: [:]) + let work = AppProfile(environment: ["OPENCLAW_PROFILE": "work"]) + + #expect(GatewayLaunchAgentManager.plistURL(homeDirectory: home, profile: base).path == + "/Users/test/Library/LaunchAgents/ai.openclaw.gateway.plist") + #expect(GatewayLaunchAgentManager.plistURL(homeDirectory: home, profile: work).path == + "/Users/test/Library/LaunchAgents/ai.openclaw.work.plist") + let baseArtifacts = GatewayLaunchAgentManager.generatedEnvironmentArtifacts( + directory: directory, + profile: base) + let workArtifacts = GatewayLaunchAgentManager.generatedEnvironmentArtifacts( + directory: directory, + profile: work) + #expect(baseArtifacts.environment.path == "/state/service-env/ai.openclaw.gateway.env") + #expect(baseArtifacts.wrapper.path == "/state/service-env/ai.openclaw.gateway-env-wrapper.sh") + #expect(workArtifacts.environment.path == "/state/service-env/ai.openclaw.work.env") + #expect(workArtifacts.wrapper.path == "/state/service-env/ai.openclaw.work-env-wrapper.sh") + } + + @Test func `gateway daemon command selects named profile at the root`() async throws { + let root = try makeTempDirForTests() + defer { try? FileManager.default.removeItem(at: root) } + let executable = root.appendingPathComponent("node_modules/.bin/openclaw") + try makeExecutableForTests(at: executable) + let command = await CommandResolver.openclawCommand( + subcommand: "gateway", + extraArgs: ["status", "--json"], + projectRoot: root, + profile: AppProfile(environment: ["OPENCLAW_PROFILE": "work"])) + + #expect(command == [ + executable.path, "--profile", "work", "gateway", "status", "--json", + ]) + } + @Test func `daemon commands tolerate first run state migrations`() { #expect(GatewayLaunchAgentManager.startupMigrationTolerance >= 120) } + @Test func `malformed canonical profile claims fail closed`() throws { + let home = try makeTempDirForTests() + defer { try? FileManager.default.removeItem(at: home) } + let agents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true) + try FileManager.default.createDirectory(at: agents, withIntermediateDirectories: true) + let data = try PropertyListSerialization.data( + fromPropertyList: [ + "ProgramArguments": ["node", "openclaw.mjs", "gateway"], + "EnvironmentVariables": [ + "OPENCLAW_SERVICE_MARKER": "openclaw", + "OPENCLAW_SERVICE_KIND": "gateway", + ], + ], + format: .xml, + options: 0) + try data.write(to: agents.appendingPathComponent("ai.openclaw.p1402.plist")) + + let claim = GatewayLaunchAgentManager.conflictingProfileClaimOwner( + port: 55636, + excludingLabel: "ai.openclaw.p2380", + homeDirectory: home) + + #expect(claim?.contains("p1402") == true) + } + + @Test func `same prefix ssh tunnel is not a profile Gateway claim`() throws { + let home = try makeTempDirForTests() + defer { try? FileManager.default.removeItem(at: home) } + let agents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true) + try FileManager.default.createDirectory(at: agents, withIntermediateDirectories: true) + let data = try PropertyListSerialization.data( + fromPropertyList: [ + "ProgramArguments": ["/usr/bin/ssh", "-N", "-L", "55636:127.0.0.1:18789"], + ], + format: .xml, + options: 0) + try data.write(to: agents.appendingPathComponent("ai.openclaw.gateway-tunnel.plist")) + try Data("not a plist".utf8).write(to: agents.appendingPathComponent("ai.openclaw.unrelated.plist")) + + #expect(GatewayLaunchAgentManager.conflictingProfileClaimOwner( + port: 55636, + excludingLabel: "ai.openclaw.qa", + homeDirectory: home) == nil) + } + + @Test func `default generated Gateway claim is recognized`() throws { + let home = try makeTempDirForTests() + defer { try? FileManager.default.removeItem(at: home) } + let agents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true) + try FileManager.default.createDirectory(at: agents, withIntermediateDirectories: true) + let data = try PropertyListSerialization.data( + fromPropertyList: [ + "ProgramArguments": ["node", "openclaw.mjs", "gateway", "--port", "18789"], + "EnvironmentVariables": [ + "OPENCLAW_SERVICE_MARKER": "openclaw", + "OPENCLAW_SERVICE_KIND": "gateway", + ], + ], + format: .xml, + options: 0) + try data.write(to: agents.appendingPathComponent("ai.openclaw.gateway.plist")) + + #expect(GatewayLaunchAgentManager.conflictingProfileClaimOwner( + port: 18789, + excludingLabel: "ai.openclaw.qa", + homeDirectory: home)?.contains("default") == true) + } + @Test func `reads Gateway service ownership command directly from launchd`() throws { let url = FileManager.default.temporaryDirectory .appendingPathComponent("openclaw-gateway-\(UUID().uuidString).plist") diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift index 05c5c50ab136..c72e35b3a960 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift @@ -7,6 +7,29 @@ import Testing @Suite(.serialized) @MainActor struct GatewayProcessManagerTests { + @Test func `colliding profile ports cannot attach another profile gateway`() { + let first = AppProfile(environment: ["OPENCLAW_PROFILE": "p1402"]) + let second = AppProfile(environment: ["OPENCLAW_PROFILE": "p2380"]) + #expect(first.defaultGatewayPort == 55636) + #expect(second.defaultGatewayPort == 55636) + #expect(GatewayProcessManager.profileAllowsExistingGatewayAttachment( + profile: first, + listenerPID: 1402, + managedServicePID: 1402)) + #expect(!GatewayProcessManager.profileAllowsExistingGatewayAttachment( + profile: second, + listenerPID: 1402, + managedServicePID: 2380)) + #expect(!GatewayProcessManager.profileAllowsExistingGatewayAttachment( + profile: second, + listenerPID: 1402, + managedServicePID: nil)) + #expect(GatewayProcessManager.profileAllowsExistingGatewayAttachment( + profile: AppProfile(environment: [:]), + listenerPID: 1402, + managedServicePID: nil)) + } + private func availableGatewayPort() throws -> Int { let fd = socket(AF_INET, SOCK_STREAM, 0) guard fd >= 0 else { @@ -114,12 +137,16 @@ struct GatewayProcessManagerTests { private nonisolated func gatewayTask( healthSucceedsAfter unavailableResponses: Int?, - stallsFirstHealthResponse: Bool = false) -> GatewayTestWebSocketTask + stallsFirstHealthResponse: Bool = false, + healthResponseGates: [AsyncTestGate] = []) -> GatewayTestWebSocketTask { GatewayTestWebSocketTask( sendHook: { task, message, sendIndex in guard sendIndex > 0 else { return } guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return } + if healthResponseGates.indices.contains(sendIndex - 1) { + await healthResponseGates[sendIndex - 1].wait() + } if stallsFirstHealthResponse, sendIndex == 1 { return } if unavailableResponses.map({ sendIndex <= $0 }) ?? true { let response = Data( @@ -1074,8 +1101,11 @@ struct GatewayProcessManagerTests { @Test func `new launchd gateway can cross multiple readiness deadlines`() async throws { let port = 19116 let url = try #require(URL(string: "ws://example.invalid")) - let (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) { - self.gatewayTask(healthSucceedsAfter: 2) + let responseGates = [AsyncTestGate(), AsyncTestGate()] + let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) { + self.gatewayTask( + healthSucceedsAfter: 2, + healthResponseGates: responseGates) } defer { manager.setTestingConnection(nil) } @@ -1097,8 +1127,18 @@ struct GatewayProcessManagerTests { manager._testStartLaunchdGatewayReadiness( port: port, pid: 4242, - readinessWindow: 0.05, - firstInstallReadinessBudget: 0.15) + readinessWindow: 0.2, + firstInstallReadinessBudget: 5) + // Release each response only after its 200 ms window so the test owns + // both deadline crossings instead of depending on runner scheduling. + await self.waitForCondition { session.latestTask()?.snapshotSendCount() ?? 0 >= 2 } + #expect(session.latestTask()?.snapshotSendCount() ?? 0 >= 2) + try await Task.sleep(for: .milliseconds(250)) + responseGates[0].open() + await self.waitForCondition { session.latestTask()?.snapshotSendCount() ?? 0 >= 3 } + #expect(session.latestTask()?.snapshotSendCount() ?? 0 >= 3) + try await Task.sleep(for: .milliseconds(250)) + responseGates[1].open() await manager.waitForStartupAttempt() #expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot() diff --git a/apps/macos/Tests/OpenClawIPCTests/GeneralSettingsStatusTests.swift b/apps/macos/Tests/OpenClawIPCTests/GeneralSettingsStatusTests.swift index 9c38a7323c55..ca645c599811 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GeneralSettingsStatusTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GeneralSettingsStatusTests.swift @@ -3,6 +3,19 @@ import Testing @MainActor struct GeneralSettingsStatusTests { + @Test func `local profile conflict outranks connected state`() { + let status = GeneralStatusPresentation.resolve( + mode: .local, + isPaused: true, + controlState: .connected, + localFailure: "Profile p2380 cannot use Gateway port 55636") + + #expect(status.title == "OpenClaw needs attention") + #expect(status.subtitle.contains("55636")) + #expect(status.tone == .attention) + #expect(status.showsConnectionAction) + } + @Test func `connected remote gateway is healthy`() { let status = GeneralStatusPresentation.resolve( mode: .remote, diff --git a/apps/macos/Tests/OpenClawIPCTests/LaunchAgentManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/LaunchAgentManagerTests.swift index 34e8a5b759cc..aae2172d5891 100644 --- a/apps/macos/Tests/OpenClawIPCTests/LaunchAgentManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/LaunchAgentManagerTests.swift @@ -3,6 +3,26 @@ import Testing @testable import OpenClaw struct LaunchAgentManagerTests { + @Test func `active profile performs no login agent reads or writes`() async { + let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"]) + var writes: [String] = [] + LaunchAgentManager._testResetLaunchctlCalls() + + #expect(await !(LaunchAgentManager.status(profile: profile))) + #expect(await !(LaunchAgentManager.set( + enabled: true, + bundlePath: "/Applications/OpenClaw.app", + profile: profile, + writePlist: { writes.append($0) }))) + #expect(await !(LaunchAgentManager.set( + enabled: false, + bundlePath: "/Applications/OpenClaw.app", + profile: profile, + writePlist: { writes.append($0) }))) + #expect(writes.isEmpty) + #expect(LaunchAgentManager._testLaunchctlCallSnapshot().isEmpty) + } + @Test func `enabling an already loaded login job only refreshes its plist`() async { var persistedBundlePaths: [String] = [] let reloaded = await LaunchAgentManager.set( diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift index 844cec9972ca..e64cd0877f63 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift @@ -293,8 +293,10 @@ struct MacNodeHostWorkerTests { @Test func `ready worker exit notifies its route owner`() async throws { try await confirmation("unexpected worker exit") { confirmed in + let exitGate = AsyncTestGate() let worker = MacNodeHostWorker(session: GatewayNodeSession()) { confirmed() + exitGate.open() } let script = """ printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}' @@ -304,7 +306,7 @@ struct MacNodeHostWorkerTests { _ = try await worker.start(launch: MacNodeHostWorkerLaunch( command: ["/bin/sh", "-c", script])) - try? await Task.sleep(for: .milliseconds(200)) + await exitGate.wait() } } diff --git a/apps/macos/Tests/OpenClawIPCTests/NodeServiceManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodeServiceManagerTests.swift index 4bddf08d7aa2..2b7de02c01b2 100644 --- a/apps/macos/Tests/OpenClawIPCTests/NodeServiceManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/NodeServiceManagerTests.swift @@ -3,6 +3,20 @@ import Testing @testable import OpenClaw @Suite(.serialized) struct NodeServiceManagerTests { + @Test func `active profile performs no persistent node service work`() async { + let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"]) + NodeServiceManager._testResetPersistentServiceCalls() + + #expect(await NodeServiceManager.start(profile: profile) == nil) + #expect(await NodeServiceManager.stop(profile: profile) == nil) + #expect(await NodeServiceManager.restart(profile: profile) == nil) + #expect(NodeServiceManager.launchdProgramArguments(profile: profile) == []) + #expect(await !(NodeServiceManager.waitUntilRunning(profile: profile))) + let snapshot = NodeServiceManager._testPersistentServiceCallSnapshot() + #expect(snapshot.commands.isEmpty) + #expect(snapshot.ownershipReads == 0) + } + @Test func `builds node service commands with current CLI shape`() async throws { try await TestIsolation.withUserDefaultsValues(["openclaw.gatewayProjectRootPath": nil]) { let tmp = try makeTempDirForTests() diff --git a/apps/macos/Tests/OpenClawIPCTests/NodesStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodesStoreTests.swift index 40f99c005c78..46bdeeb92ff5 100644 --- a/apps/macos/Tests/OpenClawIPCTests/NodesStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/NodesStoreTests.swift @@ -6,6 +6,20 @@ import Testing @Suite(.serialized) @MainActor struct NodesStoreTests { + @Test func `named profile keeps durable node service unavailability informational`() { + let store = NodesStore( + appProfile: AppProfile(environment: ["OPENCLAW_PROFILE": "work"]), + localNodeIdentityProfile: .node, + localNodeIDLoader: { _ in nil }) + + #expect(store.persistentServiceNotice == + "Persistent Mac node service unavailable under app profile; runtime node remains available.") + #expect(store.lastError == nil) + store.statusMessage = "Refreshing devices…" + #expect(store.persistentServiceNotice != nil) + #expect(store.lastError == nil) + } + @Test func `local node identity is prepared once`() async { let loader = LocalNodeIdentityLoader(results: ["node-id"]) let store = NodesStore( diff --git a/apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift b/apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift index 1783c18e0481..a712bafa011f 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift @@ -68,7 +68,7 @@ enum TestIsolation { } } - let userDefaults = UserDefaults.standard + let userDefaults = AppDefaults.standard var previousDefaults: [String: Any?] = [:] for (key, value) in defaults { previousDefaults[key] = userDefaults.object(forKey: key) diff --git a/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift b/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift index e5ffa22b7c32..54203f5cdb2b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift @@ -7,6 +7,19 @@ import Testing @Suite(.serialized) @MainActor struct UpdateOrchestrationTests { + @MainActor + @Test func `post update performs no service work under active profile`() { + let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"]) + NodeServiceManager._testResetPersistentServiceCalls() + GatewayLaunchAgentManager.clearTestingDaemonCommandCalls() + + #expect(!PostUpdateController.shared.startIfNeeded(profile: profile)) + let snapshot = NodeServiceManager._testPersistentServiceCallSnapshot() + #expect(snapshot.commands.isEmpty) + #expect(snapshot.ownershipReads == 0) + #expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty) + } + @Test func `Sparkle receipt appears only after the target app launches`() throws { let suite = "UpdateOrchestrationTests.post-update.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) @@ -340,7 +353,7 @@ struct UpdateOrchestrationTests { onStart: { starts += 1 }) updater.startAfterResolvingGatewayUpdateChannel() - for _ in 0 ..< 10 where !updater.isAvailable { + for _ in 0..<10 where !updater.isAvailable { await Task.yield() } @@ -356,7 +369,7 @@ struct UpdateOrchestrationTests { onStart: { starts += 1 }) updater.startAfterResolvingGatewayUpdateChannel() - for _ in 0 ..< 10 where !updater.isAvailable { + for _ in 0..<10 where !updater.isAvailable { await Task.yield() } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift index 66c3490496f3..9009e260c13d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift @@ -46,9 +46,29 @@ public struct DeviceIdentity: Codable, Sendable, Equatable { } } +struct DeviceIdentityStateRootState { + private(set) var url: URL? + private(set) var used = false + + mutating func configure(_ url: URL) -> Bool { + let normalized = url.standardizedFileURL + if let configured = self.url { return configured == normalized } + guard !self.used else { return false } + self.url = normalized + return true + } + + mutating func resolve() -> URL? { + self.used = true + return self.url + } +} + enum DeviceIdentityPaths { private static let stateDirEnv = ["OPENCLAW_STATE_DIR"] @TaskLocal static var scopedStateDirURL: URL? + private static let configuredStateLock = NSLock() + private nonisolated(unsafe) static var configuredState = DeviceIdentityStateRootState() /// Entitlements are baked into the code signature, so resolve the gate once per process. /// Every identity load and DeviceAuthStore read/write resolves the state dir through here; @@ -65,6 +85,10 @@ enum DeviceIdentityPaths { temporaryDirectory: FileManager.default.temporaryDirectory) } + static func configureStateDirURL(_ url: URL) -> Bool { + self.configuredStateLock.withLock { self.configuredState.configure(url) } + } + static func stateDirURL( overrideURL: URL?, legacyStateDirURL: URL?, @@ -90,6 +114,9 @@ enum DeviceIdentityPaths { if let scopedStateDirURL { return scopedStateDirURL } + if let configured = self.configuredStateLock.withLock({ self.configuredState.resolve() }) { + return configured + } for key in self.stateDirEnv { if let raw = getenv(key) { let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines) @@ -204,6 +231,11 @@ public enum DeviceIdentityStore { self.loadOrCreate(profile: .primary) } + @discardableResult + public static func configureStateDirectory(_ url: URL) -> Bool { + DeviceIdentityPaths.configureStateDirURL(url) + } + #if compiler(>=6.4) nonisolated(nonsending) static func withStateDirectory( _ url: URL, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift index 1a204dcf8ebf..ee8a2976804d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift @@ -264,6 +264,25 @@ struct GatewayTLSKeychainOperations: @unchecked Sendable { delete: { SecItemDelete($0) }) } +struct GatewayTLSKeychainNamespaceState { + private(set) var suffix: String? + private(set) var used = false + + mutating func configure(suffix: String) -> Bool { + if let configured = self.suffix { + return configured == suffix + } + guard !self.used || suffix.isEmpty else { return false } + self.suffix = suffix + return true + } + + mutating func service(base: String) -> String { + self.used = true + return base + (self.suffix ?? "") + } +} + public enum GatewayTLSStore { @TaskLocal static var keychainOperations = GatewayTLSKeychainOperations.live @@ -273,7 +292,19 @@ public enum GatewayTLSStore { case unavailable } - private static let keychainService = "ai.openclaw.tls-pinning" + private static let baseKeychainService = "ai.openclaw.tls-pinning" + private static let keychainServiceLock = NSLock() + private nonisolated(unsafe) static var keychainNamespace = GatewayTLSKeychainNamespaceState() + private static var keychainService: String { + self.keychainServiceLock.withLock { + self.keychainNamespace.service(base: self.baseKeychainService) + } + } + + private static var usesDefaultKeychainService: Bool { + self.keychainServiceLock.withLock { (self.keychainNamespace.suffix ?? "").isEmpty } + } + private static let keychainAccountPrefix = "fingerprint.v3." private static let legacyCanonicalAccountPrefix = "fingerprint.v2." @@ -282,6 +313,19 @@ public enum GatewayTLSStore { private static let legacyKeyPrefix = "gateway.tls." private static let firstUseClaims = GatewayTLSFirstUseClaims() + /// The macOS app profile is immutable for the process lifetime. Configure its + /// Keychain namespace before constructing any Gateway connection. + @discardableResult + public static func configureKeychainServiceSuffix(_ suffix: String) -> Bool { + self.keychainServiceLock.withLock { + self.keychainNamespace.configure(suffix: suffix) + } + } + + static func resolvedKeychainService(suffix: String) -> String { + self.baseKeychainService + suffix + } + public static func loadFingerprint(stableID: String) -> String? { guard case let .value(fingerprint) = self.loadFingerprintResult(stableID: stableID) else { return nil @@ -512,6 +556,7 @@ public enum GatewayTLSStore { } private static func readLegacyDefaultsFingerprint(stableID: String) -> FingerprintRead { + guard self.usesDefaultKeychainService else { return .missing } guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return .unavailable } let key = self.legacyKeyPrefix + stableID guard let value = defaults.object(forKey: key) else { return .missing } @@ -608,8 +653,10 @@ public enum GatewayTLSStore { } ?? true guard self.canSafelyReadLegacyRawStorageKey(stableID) else { return removedV2 } let removedRaw = self.deleteFingerprint(account: stableID) - UserDefaults(suiteName: self.legacySuiteName)? - .removeObject(forKey: self.legacyKeyPrefix + stableID) + if self.usesDefaultKeychainService { + UserDefaults(suiteName: self.legacySuiteName)? + .removeObject(forKey: self.legacyKeyPrefix + stableID) + } return removedRaw && removedV2 } @@ -624,6 +671,7 @@ public enum GatewayTLSStore { } private static func clearAllLegacyFingerprints() { + guard self.usesDefaultKeychainService else { return } guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return } for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(self.legacyKeyPrefix) { defaults.removeObject(forKey: key) diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift index 6b31f5fb3194..6ad2a5c4ff80 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift @@ -74,6 +74,30 @@ private func deviceAuthEntry( @Suite(.serialized) struct DeviceIdentityStoreTests { + @Test func `process state root configures once before identity use`() { + var state = DeviceIdentityStateRootState() + let work = URL(fileURLWithPath: "/Users/test/.openclaw-work", isDirectory: true) + let other = URL(fileURLWithPath: "/Users/test/.openclaw-other", isDirectory: true) + let configuredWork = state.configure(work) + let reconfiguredWork = state.configure(work) + let configuredOther = state.configure(other) + let resolvedWork = state.resolve() + let configuredWorkAfterUse = state.configure(work) + let configuredOtherAfterUse = state.configure(other) + #expect(configuredWork) + #expect(reconfiguredWork) + #expect(!configuredOther) + #expect(resolvedWork == work) + #expect(configuredWorkAfterUse) + #expect(!configuredOtherAfterUse) + + var usedDefault = DeviceIdentityStateRootState() + let resolvedDefault = usedDefault.resolve() + let configuredDefaultAfterUse = usedDefault.configure(work) + #expect(resolvedDefault == nil) + #expect(!configuredDefaultAfterUse) + } + @Test func `task scoped state directories isolate concurrent identity stores`() async throws { let fixture = DeviceIdentityMigrationFixture() diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift index 7ebc8c557796..80bdb6fafeb1 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift @@ -105,6 +105,30 @@ private func gatewayTLSTestTrust(systemTrusted: Bool) throws -> SecTrust { } struct GatewayTLSPinningTests { + @Test func `keychain namespace configures once and fails closed after use`() { + var state = GatewayTLSKeychainNamespaceState() + let configuredWork = state.configure(suffix: ".profile.work") + let reconfiguredWork = state.configure(suffix: ".profile.work") + let configuredOther = state.configure(suffix: ".profile.other") + let workService = state.service(base: "ai.openclaw.tls-pinning") + let configuredWorkAfterUse = state.configure(suffix: ".profile.work") + let configuredDefaultAfterUse = state.configure(suffix: "") + #expect(configuredWork) + #expect(reconfiguredWork) + #expect(!configuredOther) + #expect(workService == "ai.openclaw.tls-pinning.profile.work") + #expect(configuredWorkAfterUse) + #expect(!configuredDefaultAfterUse) + + var usedDefault = GatewayTLSKeychainNamespaceState() + let defaultService = usedDefault.service(base: "ai.openclaw.tls-pinning") + let configuredDefault = usedDefault.configure(suffix: "") + let configuredProfileAfterDefaultUse = usedDefault.configure(suffix: ".profile.work") + #expect(defaultService == "ai.openclaw.tls-pinning") + #expect(configuredDefault) + #expect(!configuredProfileAfterDefaultUse) + } + private func withFakeKeychain(_ operation: (GatewayTLSFakeKeychain) throws -> T) rethrows -> T { let keychain = GatewayTLSFakeKeychain() return try GatewayTLSStore.$keychainOperations.withValue(keychain.operations) { diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh index 21437b919e3b..fa8ad374e07e 100755 --- a/scripts/restart-mac.sh +++ b/scripts/restart-mac.sh @@ -153,6 +153,12 @@ fi if [[ "$TARGET_ONLY" -eq 1 && -n "$APP_BUNDLE" ]]; then fail "--target-only does not accept OPENCLAW_APP_BUNDLE" fi +if [[ -n "${OPENCLAW_PROFILE:-}" ]]; then + normalized_profile="$(printf '%s' "${OPENCLAW_PROFILE}" | tr '[:upper:]' '[:lower:]')" + if [[ "${normalized_profile}" != "default" ]]; then + fail "restart-mac.sh cannot safely target one app profile; launch that profile directly instead" + fi +fi canonicalize_app_bundle mkdir -p "$(dirname "$LOG_PATH")" @@ -505,14 +511,15 @@ fi # 4) Launch the installed app in the foreground so the menu bar extra appears. # LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.). # That can cause launchd spawn failures and is undesirable for a GUI app anyway. -run_step "launch app" env -i \ - HOME="${HOME}" \ - USER="${USER:-$(id -un)}" \ - LOGNAME="${LOGNAME:-$(id -un)}" \ - TMPDIR="${TMPDIR:-/tmp}" \ - PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ - LANG="${LANG:-en_US.UTF-8}" \ - /usr/bin/open "${OPEN_ARGS[@]}" +LAUNCH_ENV=( + "HOME=${HOME}" + "USER=${USER:-$(id -un)}" + "LOGNAME=${LOGNAME:-$(id -un)}" + "TMPDIR=${TMPDIR:-/tmp}" + "PATH=/usr/bin:/bin:/usr/sbin:/sbin" + "LANG=${LANG:-en_US.UTF-8}" +) +run_step "launch app" env -i "${LAUNCH_ENV[@]}" /usr/bin/open "${OPEN_ARGS[@]}" # 5) Verify the app is alive. sleep 1.5 diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index baee09bac4a5..62693b2bae9c 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -263,9 +263,11 @@ async function runRestartLaunchAgentWithFakeTimers(args: Parameters) { +function expectLaunchctlEnableBootstrapOrder( + env: Record, + label = "ai.openclaw.gateway", +) { const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; - const label = "ai.openclaw.gateway"; const plistPath = resolveLaunchAgentPlistPath(env); const serviceId = `${domain}/${label}`; const enableIndex = state.launchctlCalls.findIndex( @@ -1643,11 +1645,9 @@ describe("launchd install", () => { "print", "bootout", "unload", - "bootout", - "unload", "enable", "bootstrap", - "bootout", + "print", "enable", "bootstrap", ]); @@ -1742,14 +1742,7 @@ describe("launchd install", () => { expect(state.files.get(plistPath)).toBe(previous); expect(state.serviceLoaded).toBe(false); expect(state.serviceRunning).toBe(false); - expect(launchctlCommandNames()).toEqual([ - "print", - "bootout", - "unload", - "enable", - "bootstrap", - "bootout", - ]); + expect(launchctlCommandNames()).toEqual(["print", "enable", "bootstrap", "print"]); }); it("removes generated artifacts after a failed fresh install", async () => { @@ -1761,19 +1754,49 @@ describe("launchd install", () => { state.serviceRunning = false; state.bootstrapError = "Operation not permitted"; state.bootstrapTransient = true; + state.bootoutError = "Boot-out failed: 5: Input/output error"; + state.bootoutCode = 5; - await expect( - installLaunchAgent({ - env, - stdout: new PassThrough(), - programArguments: defaultProgramArguments, - environment: { OPENCLAW_GATEWAY_PORT: "19000" }, - }), - ).rejects.toThrow("launchctl bootstrap failed: Operation not permitted"); + const error = await installLaunchAgent({ + env, + stdout: new PassThrough(), + programArguments: defaultProgramArguments, + environment: { OPENCLAW_GATEWAY_PORT: "19000" }, + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("launchctl bootstrap failed: Operation not permitted"); expect(state.files.has(plistPath)).toBe(false); expect(state.files.has(envFilePath)).toBe(false); expect(state.files.has(wrapperPath)).toBe(false); + expect(launchctlCommandNames()).toEqual(["print", "enable", "bootstrap", "print"]); + }); + + it("fails closed when rollback cannot determine the replacement state", async () => { + const env = createDefaultLaunchdEnv(); + const plistPath = resolveLaunchAgentPlistPath(env); + state.printNotLoadedRemaining = 1; + state.printError = "launchctl print permission denied"; + state.printFailuresRemaining = 1; + state.bootstrapError = "Operation not permitted"; + + const error = await installLaunchAgent({ + env, + stdout: new PassThrough(), + programArguments: defaultProgramArguments, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + "launchctl bootstrap failed: Operation not permitted", + ); + expect((error as Error).message).toContain( + "The previous LaunchAgent supervision could not be restored.", + ); + expect((error as Error).cause).toBeInstanceOf(Error); + expect(((error as Error).cause as Error).message).toContain("could not determine whether"); + expect(state.files.has(plistPath)).toBe(true); + expect(launchctlCommandNames()).toEqual(["print", "enable", "bootstrap", "print"]); }); it("restores the exact prior plist and supervision after external bootstrap failure", async () => { @@ -1826,7 +1849,7 @@ describe("launchd install", () => { "unload", "enable", "bootstrap", - "bootout", + "print", "enable", "bootstrap", ]); @@ -1892,22 +1915,71 @@ describe("launchd install", () => { expect(state.launchctlCalls).toEqual([]); }); - it("enables service before bootstrap without self-restarting the fresh agent", async () => { - const env = createDefaultLaunchdEnv(); - await installLaunchAgent({ - env, - stdout: new PassThrough(), + it.each([ + { + name: "default gateway", + env: createDefaultLaunchdEnv(), + label: "ai.openclaw.gateway", programArguments: defaultProgramArguments, - }); + }, + { + name: "profiled gateway", + env: { HOME: "/Users/test", OPENCLAW_PROFILE: "qa" }, + label: "ai.openclaw.qa", + programArguments: defaultProgramArguments, + }, + { + name: "node service", + env: { HOME: "/Users/test", OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.node" }, + label: "ai.openclaw.node", + programArguments: ["node", "node-host.js"], + }, + ])( + "installs a fresh $name without booting out the absent job", + async ({ env, label, programArguments }) => { + state.bootoutError = "Boot-out failed: 5: Input/output error"; + state.bootoutCode = 5; + await installLaunchAgent({ + env, + stdout: new PassThrough(), + programArguments, + }); - const plist = state.files.get(resolveLaunchAgentPlistPath(env)) ?? ""; - expect(plist).toContain("Comment\n OpenClaw Gateway"); - expect(plist).not.toContain("OPENCLAW_SERVICE_VERSION"); - const { serviceId } = expectLaunchctlEnableBootstrapOrder(env); - const installKickstartIndex = state.launchctlCalls.findIndex( - (c) => c[0] === "kickstart" && c[2] === serviceId, + const plist = state.files.get(resolveLaunchAgentPlistPath(env)) ?? ""; + expect(plist).not.toContain("OPENCLAW_SERVICE_VERSION"); + const { serviceId } = expectLaunchctlEnableBootstrapOrder(env, label); + const installKickstartIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "kickstart" && c[2] === serviceId, + ); + expect(installKickstartIndex).toBe(-1); + expect(launchctlCommandNames()).toEqual(["print", "enable", "bootstrap"]); + }, + ); + + it("keeps loaded reinstall deactivation failures fatal", async () => { + const env = createDefaultLaunchdEnv(); + const plistPath = resolveLaunchAgentPlistPath(env); + state.files.set( + plistPath, + createTestLaunchAgentPlist({ + label: "ai.openclaw.gateway", + programArguments: defaultProgramArguments, + }), ); - expect(installKickstartIndex).toBe(-1); + state.bootoutError = "Boot-out failed: 5: Input/output error"; + state.bootoutCode = 5; + + await expect( + installLaunchAgent({ + env, + stdout: new PassThrough(), + programArguments: defaultProgramArguments, + }), + ).rejects.toThrow( + "launchctl bootout failed during LaunchAgent install: Boot-out failed: 5: Input/output error", + ); + + expect(launchctlCommandNames()).toEqual(["print", "bootout", "print", "bootout"]); }); it("writes a version-free node service description", async () => { diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index df2cc40f3d8c..cca71ae6eba8 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -1487,9 +1487,19 @@ async function restoreLaunchAgentInstall(params: { snapshot: LaunchAgentInstallSnapshot; }): Promise { const serviceTarget = `${params.domain}/${params.label}`; - const bootout = await execLaunchctl(["bootout", serviceTarget]); - if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { - throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`); + // A failed bootstrap may leave no registered job. Restore files directly in + // that state; only a loaded replacement must be removed before rollback. + const currentState = await probeLaunchAgentState(serviceTarget); + if (currentState.state === "unknown") { + throw new Error( + `launchctl print could not determine whether ${serviceTarget} is loaded during LaunchAgent rollback: ${currentState.detail ?? "unknown error"}`, + ); + } + if (currentState.state !== "not-loaded") { + const bootout = await execLaunchctl(["bootout", serviceTarget]); + if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { + throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`); + } } await restoreLaunchAgentInstallArtifacts({ env: params.env, @@ -1546,9 +1556,15 @@ async function activateLaunchAgent(params: { // the plist write cannot race us into two KeepAlive managers. await assertNoSystemLaunchDaemonOwnership(label); for (const legacy of params.snapshot.legacy) { - await deactivateLaunchAgentDefinition(domain, legacy.plistPath); + if (legacy.loaded) { + await deactivateLaunchAgentDefinition(domain, legacy.plistPath); + } + } + // Plist-form bootout reports EIO for a valid definition that was never loaded. + // The pre-publication snapshot is the authoritative cutover fact. + if (params.snapshot.loaded) { + await deactivateLaunchAgentDefinition(domain, params.plistPath); } - await deactivateLaunchAgentDefinition(domain, params.plistPath); // launchd can persist "disabled" state even after bootout + plist removal; clear it before bootstrap. await bootstrapLaunchAgentOrThrow({ domain, diff --git a/test/scripts/restart-mac.test.ts b/test/scripts/restart-mac.test.ts index a97c4528b033..ce2cc0311f06 100644 --- a/test/scripts/restart-mac.test.ts +++ b/test/scripts/restart-mac.test.ts @@ -217,6 +217,30 @@ function runRestartArgParser(...args: string[]) { return spawnSync("bash", [harnessPath, ...args], { encoding: "utf8" }); } +function runProfileGuard(profile: string) { + const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-profile-test-")); + tempRoots.push(root); + const script = readFileSync(restartScriptPath, "utf8"); + const start = script.indexOf('if [[ -n "${OPENCLAW_PROFILE:-}" ]]'); + const guardBlock = script.slice(start, script.indexOf("canonicalize_app_bundle", start)); + const harnessPath = join(root, "profile-guard.sh"); + writeFileSync( + harnessPath, + [ + "#!/bin/bash", + "set -euo pipefail", + 'fail() { printf "ERROR: %s\\n" "$*" >&2; exit 1; }', + guardBlock, + 'printf "safe\\n"', + ].join("\n"), + ); + chmodSync(harnessPath, 0o755); + return spawnSync("/bin/bash", [harnessPath], { + encoding: "utf8", + env: { ...process.env, OPENCLAW_PROFILE: profile }, + }); +} + function runLaunchArgBuilder(...args: string[]) { const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-test-")); tempRoots.push(root); @@ -404,6 +428,19 @@ describe("scripts/restart-mac.sh", () => { expect(script).not.toContain('LOG_PATH="${OPENCLAW_RESTART_LOG:-/tmp/openclaw-restart.log}"'); }); + it("rejects named app profiles before global process or launchd cleanup", () => { + const script = readFileSync(restartScriptPath, "utf8"); + + expect(script).toContain('if [[ -n "${OPENCLAW_PROFILE:-}"'); + expect(script).toContain("restart-mac.sh cannot safely target one app profile"); + expect(script.indexOf("cannot safely target one app profile")).toBeLessThan( + script.indexOf("\nacquire_lock\n"), + ); + expect(runProfileGuard("work").status).toBe(1); + expect(runProfileGuard("default").stdout.trim()).toBe("safe"); + expect(runProfileGuard("Default").stdout.trim()).toBe("safe"); + }); + it("does not remove a live restart lock it did not acquire", () => { const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-test-")); tempRoots.push(root);